From 223fc437c495b2c3ee6f265e2ac6120ca13fa884 Mon Sep 17 00:00:00 2001 From: Hamza Israr <71447999+HamzaIsrar12@users.noreply.github.com> Date: Mon, 6 May 2024 16:38:06 +0500 Subject: [PATCH 01/56] fix: Ensure cookies authentication prior to webview loading (#312) Ensure cookies' expiry time is verified before loading a webview. Additionally, addressed a race condition within the `clearWebViewCookie` method. This race condition caused the premature reset of `authSessionCookieExpiration` to -1 due to delays in the callback execution. Fixes: LEARNER-9891 --- .../org/openedx/core/extension/ViewExt.kt | 15 ++++++++ .../openedx/core/system/AppCookieManager.kt | 14 +------ .../unit/html/HtmlUnitFragment.kt | 38 +++++++++++++++---- .../presentation/program/ProgramFragment.kt | 22 +++++++---- .../presentation/program/ProgramViewModel.kt | 6 +-- 5 files changed, 65 insertions(+), 30 deletions(-) diff --git a/core/src/main/java/org/openedx/core/extension/ViewExt.kt b/core/src/main/java/org/openedx/core/extension/ViewExt.kt index ff2e95d47..9146a3159 100644 --- a/core/src/main/java/org/openedx/core/extension/ViewExt.kt +++ b/core/src/main/java/org/openedx/core/extension/ViewExt.kt @@ -6,8 +6,12 @@ import android.graphics.Rect import android.util.DisplayMetrics import android.view.View import android.view.ViewGroup +import android.webkit.WebView import android.widget.Toast import androidx.fragment.app.DialogFragment +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.openedx.core.system.AppCookieManager fun Context.dpToPixel(dp: Int): Float { return dp * (resources.displayMetrics.densityDpi.toFloat() / DisplayMetrics.DENSITY_DEFAULT) @@ -46,3 +50,14 @@ fun DialogFragment.setWidthPercent(percentage: Int) { fun Context.toastMessage(message: String) { Toast.makeText(this, message, Toast.LENGTH_SHORT).show() } + +fun WebView.loadUrl(url: String, scope: CoroutineScope, cookieManager: AppCookieManager) { + if (cookieManager.isSessionCookieMissingOrExpired()) { + scope.launch { + cookieManager.tryToRefreshSessionCookie() + loadUrl(url) + } + } else { + loadUrl(url) + } +} diff --git a/core/src/main/java/org/openedx/core/system/AppCookieManager.kt b/core/src/main/java/org/openedx/core/system/AppCookieManager.kt index f09e16362..7df19c627 100644 --- a/core/src/main/java/org/openedx/core/system/AppCookieManager.kt +++ b/core/src/main/java/org/openedx/core/system/AppCookieManager.kt @@ -11,8 +11,6 @@ import java.util.concurrent.TimeUnit class AppCookieManager(private val config: Config, private val api: CookiesApi) { companion object { - private const val REV_934_COOKIE = - "REV_934=mobile; expires=Tue, 31 Dec 2021 12:00:20 GMT; domain=.edx.org;" private val FRESHNESS_INTERVAL = TimeUnit.HOURS.toMillis(1) } @@ -34,19 +32,11 @@ class AppCookieManager(private val config: Config, private val api: CookiesApi) } fun clearWebViewCookie() { - CookieManager.getInstance().removeAllCookies { result -> - if (result) { - authSessionCookieExpiration = -1 - } - } + CookieManager.getInstance().removeAllCookies(null) + authSessionCookieExpiration = -1 } fun isSessionCookieMissingOrExpired(): Boolean { return authSessionCookieExpiration < System.currentTimeMillis() } - - fun setMobileCookie() { - CookieManager.getInstance().setCookie(config.getApiHostURL(), REV_934_COOKIE) - } - } diff --git a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt index a74b9d5ee..3a49e0e4b 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt @@ -9,13 +9,30 @@ import android.os.Bundle import android.util.Log import android.view.LayoutInflater import android.view.ViewGroup -import android.webkit.* +import android.webkit.JavascriptInterface +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView +import android.webkit.WebViewClient import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.* -import androidx.compose.runtime.* +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -33,10 +50,15 @@ import androidx.fragment.app.Fragment import kotlinx.coroutines.launch import org.koin.androidx.viewmodel.ext.android.viewModel import org.openedx.core.extension.isEmailValid +import org.openedx.core.extension.loadUrl import org.openedx.core.system.AppCookieManager -import org.openedx.core.ui.* +import org.openedx.core.ui.ConnectionErrorView +import org.openedx.core.ui.WindowSize +import org.openedx.core.ui.rememberWindowSize +import org.openedx.core.ui.roundBorderWithoutBottom import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.windowSizeValue import org.openedx.core.utils.EmailUtil class HtmlUnitFragment : Fragment() { @@ -268,13 +290,15 @@ private fun HTMLContentView( } isVerticalScrollBarEnabled = false isHorizontalScrollBarEnabled = false - loadUrl(url) + + loadUrl(url, coroutineScope, cookieManager) } }, update = { webView -> if (!isLoading && injectJSList.isNotEmpty()) { injectJSList.forEach { webView.evaluateJavascript(it, null) } } - }) + } + ) } 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 4e97efe18..ee3e04a3b 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 @@ -23,6 +23,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi @@ -41,10 +42,14 @@ import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.zIndex import androidx.core.os.bundleOf import androidx.fragment.app.Fragment +import kotlinx.coroutines.launch +import org.koin.androidx.compose.koinViewModel import org.koin.androidx.viewmodel.ext.android.viewModel +import org.openedx.core.extension.loadUrl import org.openedx.core.extension.toastMessage import org.openedx.core.presentation.dialog.alert.ActionDialogFragment import org.openedx.core.presentation.dialog.alert.InfoDialogFragment +import org.openedx.core.system.AppCookieManager import org.openedx.core.ui.ConnectionErrorView import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.Toolbar @@ -119,6 +124,7 @@ class ProgramFragment(private val myPrograms: Boolean = false) : Fragment() { windowSize = windowSize, uiState = uiState, contentUrl = getInitialUrl(), + cookieManager = viewModel.cookieManager, canShowBackBtn = arguments?.getString(ARG_PATH_ID, "") ?.isNotEmpty() == true, uriScheme = viewModel.uriScheme, @@ -182,15 +188,11 @@ class ProgramFragment(private val myPrograms: Boolean = false) : Fragment() { onSettingsClick = { viewModel.navigateToSettings(requireActivity().supportFragmentManager) }, - refreshSessionCookie = { - viewModel.refreshCookie() - }, ) } } } - private fun getInitialUrl(): String { return arguments?.let { args -> val pathId = args.getString(ARG_PATH_ID) ?: "" @@ -219,6 +221,7 @@ private fun ProgramInfoScreen( windowSize: WindowSize, uiState: ProgramUIState?, contentUrl: String, + cookieManager: AppCookieManager, uriScheme: String, canShowBackBtn: Boolean, hasInternetConnection: Boolean, @@ -227,10 +230,10 @@ private fun ProgramInfoScreen( onSettingsClick: () -> Unit, onBackClick: () -> Unit, onUriClick: (String, WebViewLink.Authority) -> Unit, - refreshSessionCookie: () -> Unit = {}, ) { val scaffoldState = rememberScaffoldState() val configuration = LocalConfiguration.current + val coroutineScope = rememberCoroutineScope() val isLoading = uiState is ProgramUIState.Loading when (uiState) { @@ -290,7 +293,11 @@ private fun ProgramInfoScreen( uriScheme = uriScheme, isAllLinksExternal = true, onWebPageLoaded = onWebPageLoaded, - refreshSessionCookie = refreshSessionCookie, + refreshSessionCookie = { + coroutineScope.launch { + cookieManager.tryToRefreshSessionCookie() + } + }, onUriClick = onUriClick, ) @@ -301,7 +308,7 @@ private fun ProgramInfoScreen( webView }, update = { - webView.loadUrl(contentUrl) + webView.loadUrl(contentUrl, coroutineScope, cookieManager) } ) } else { @@ -339,6 +346,7 @@ fun MyProgramsPreview() { windowSize = WindowSize(WindowType.Compact, WindowType.Compact), uiState = ProgramUIState.Loading, contentUrl = "https://www.example.com/", + cookieManager = koinViewModel().cookieManager, uriScheme = "", canShowBackBtn = false, hasInternetConnection = false, diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramViewModel.kt b/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramViewModel.kt index 68bbdc6be..1bed6d2cd 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramViewModel.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramViewModel.kt @@ -34,6 +34,8 @@ class ProgramViewModel( val programConfig get() = config.getProgramConfig().webViewConfig + val cookieManager get() = edxCookieManager + val hasInternetConnection: Boolean get() = networkConnection.isOnline() private val _uiState = MutableSharedFlow( @@ -104,8 +106,4 @@ class ProgramViewModel( fun navigateToSettings(fragmentManager: FragmentManager) { router.navigateToSettings(fragmentManager) } - - fun refreshCookie() { - viewModelScope.launch { edxCookieManager.tryToRefreshSessionCookie() } - } } From 498f0ef64cfeb8b88c62817f465d4dea19662ef4 Mon Sep 17 00:00:00 2001 From: droid Date: Mon, 15 Apr 2024 16:54:02 +0200 Subject: [PATCH 02/56] fix: crash when restoring the app after a long period of inactivity --- .../java/org/openedx/app/di/ScreenModule.kt | 25 +++- .../core/system/notifier/CourseDataReady.kt | 5 - .../core/system/notifier/CourseNotifier.kt | 1 - .../data/repository/CourseRepository.kt | 53 +++---- .../domain/interactor/CourseInteractor.kt | 22 +-- .../container/CourseContainerFragment.kt | 33 +++-- .../container/CourseContainerViewModel.kt | 11 +- .../dates/CourseDatesViewModel.kt | 31 ++-- .../outline/CourseOutlineViewModel.kt | 10 +- .../section/CourseSectionViewModel.kt | 4 +- .../container/CourseUnitContainerFragment.kt | 3 +- .../container/CourseUnitContainerViewModel.kt | 35 +++-- .../videos/CourseVideoViewModel.kt | 15 +- .../container/CourseContainerViewModelTest.kt | 62 +++++--- .../dates/CourseDatesViewModelTest.kt | 19 ++- .../outline/CourseOutlineViewModelTest.kt | 39 ++--- .../section/CourseSectionViewModelTest.kt | 31 ++-- .../CourseUnitContainerViewModelTest.kt | 133 +++++++++--------- .../videos/CourseVideoViewModelTest.kt | 26 ++-- .../topics/DiscussionTopicsScreen.kt | 2 +- .../topics/DiscussionTopicsViewModel.kt | 20 +-- .../topics/DiscussionTopicsViewModelTest.kt | 15 +- 22 files changed, 319 insertions(+), 276 deletions(-) delete mode 100644 core/src/main/java/org/openedx/core/system/notifier/CourseDataReady.kt diff --git a/app/src/main/java/org/openedx/app/di/ScreenModule.kt b/app/src/main/java/org/openedx/app/di/ScreenModule.kt index 4efd1a19e..c9c395a01 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -148,10 +148,23 @@ val screenModule = module { viewModel { (qualityType: String) -> VideoQualityViewModel(qualityType, get(), get(), get()) } viewModel { DeleteProfileViewModel(get(), get(), get(), get(), get()) } viewModel { (username: String) -> AnothersProfileViewModel(get(), get(), username) } - viewModel { SettingsViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } + viewModel { + SettingsViewModel( + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get() + ) + } viewModel { ManageAccountViewModel(get(), get(), get(), get(), get()) } - single { CourseRepository(get(), get(), get(), get()) } + single { CourseRepository(get(), get(), get(), get(), get()) } factory { CourseInteractor(get()) } viewModel { (pathId: String, infoType: String) -> CourseInfoViewModel( @@ -279,8 +292,10 @@ val screenModule = module { get(), ) } - viewModel { (enrollmentMode: String) -> + viewModel { (courseId: String, courseTitle: String, enrollmentMode: String) -> CourseDatesViewModel( + courseId, + courseTitle, enrollmentMode, get(), get(), @@ -305,8 +320,10 @@ val screenModule = module { single { DiscussionRepository(get(), get(), get()) } factory { DiscussionInteractor(get()) } - viewModel { + viewModel { (courseId: String, courseTitle: String) -> DiscussionTopicsViewModel( + courseId, + courseTitle, get(), get(), get(), diff --git a/core/src/main/java/org/openedx/core/system/notifier/CourseDataReady.kt b/core/src/main/java/org/openedx/core/system/notifier/CourseDataReady.kt deleted file mode 100644 index 0ad123d17..000000000 --- a/core/src/main/java/org/openedx/core/system/notifier/CourseDataReady.kt +++ /dev/null @@ -1,5 +0,0 @@ -package org.openedx.core.system.notifier - -import org.openedx.core.domain.model.CourseStructure - -data class CourseDataReady(val courseStructure: CourseStructure) : CourseEvent 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 63660b4de..f4908bdef 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,6 +18,5 @@ 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: CourseDataReady) = channel.emit(event) suspend fun send(event: CourseRefresh) = channel.emit(event) } diff --git a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt index 17dc6a240..c32397a48 100644 --- a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt +++ b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt @@ -5,9 +5,11 @@ import org.openedx.core.ApiConstants import org.openedx.core.data.api.CourseApi import org.openedx.core.data.model.BlocksCompletionBody import org.openedx.core.data.storage.CorePreferences -import org.openedx.core.domain.model.* +import org.openedx.core.domain.model.CourseComponentStatus +import org.openedx.core.domain.model.CourseStructure import org.openedx.core.exception.NoCachedDataException import org.openedx.core.module.db.DownloadDao +import org.openedx.core.system.connection.NetworkConnection import org.openedx.course.data.storage.CourseDao class CourseRepository( @@ -15,8 +17,9 @@ class CourseRepository( private val courseDao: CourseDao, private val downloadDao: DownloadDao, private val preferencesManager: CorePreferences, + private val networkConnection: NetworkConnection, ) { - private var courseStructure: CourseStructure? = null + private var courseStructure = mutableMapOf() suspend fun removeDownloadModel(id: String) { downloadDao.removeDownloadModel(id) @@ -26,35 +29,33 @@ class CourseRepository( list.map { it.mapToDomain() } } - suspend fun preloadCourseStructure(courseId: String) { - val response = api.getCourseStructure( - "stale-if-error=0", - "v3", - preferencesManager.user?.username, - courseId - ) - courseDao.insertCourseStructureEntity(response.mapToRoomEntity()) - courseStructure = null - courseStructure = response.mapToDomain() + fun hasCourses(courseId: String): Boolean { + return courseStructure[courseId] != null } - suspend fun preloadCourseStructureFromCache(courseId: String) { - val cachedCourseStructure = courseDao.getCourseStructureById(courseId) - courseStructure = null - if (cachedCourseStructure != null) { - courseStructure = cachedCourseStructure.mapToDomain() - } else { - throw NoCachedDataException() - } - } + suspend fun getCourseStructure(courseId: String, isNeedRefresh: Boolean): CourseStructure { + if (!isNeedRefresh) courseStructure[courseId]?.let { return it } + + if (networkConnection.isOnline()) { + val response = api.getCourseStructure( + "stale-if-error=0", + "v3", + preferencesManager.user?.username, + courseId + ) + courseDao.insertCourseStructureEntity(response.mapToRoomEntity()) + courseStructure[courseId] = response.mapToDomain() - @Throws(IllegalStateException::class) - fun getCourseStructureFromCache(): CourseStructure { - if (courseStructure != null) { - return courseStructure!! } else { - throw IllegalStateException("Course structure is empty") + val cachedCourseStructure = courseDao.getCourseStructureById(courseId) + if (cachedCourseStructure != null) { + courseStructure[courseId] = cachedCourseStructure.mapToDomain() + } else { + throw NoCachedDataException() + } } + + return courseStructure[courseId]!! } suspend fun getCourseStatus(courseId: String): CourseComponentStatus { diff --git a/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt b/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt index 6c8bd1009..5bc859120 100644 --- a/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt +++ b/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt @@ -9,18 +9,18 @@ class CourseInteractor( private val repository: CourseRepository ) { - suspend fun preloadCourseStructure(courseId: String) = - repository.preloadCourseStructure(courseId) - - suspend fun preloadCourseStructureFromCache(courseId: String) = - repository.preloadCourseStructureFromCache(courseId) - - @Throws(IllegalStateException::class) - fun getCourseStructureFromCache() = repository.getCourseStructureFromCache() + suspend fun getCourseStructure( + courseId: String, + isNeedRefresh: Boolean = false + ): CourseStructure { + return repository.getCourseStructure(courseId, isNeedRefresh) + } - @Throws(IllegalStateException::class) - fun getCourseStructureForVideos(): CourseStructure { - val courseStructure = repository.getCourseStructureFromCache() + suspend fun getCourseStructureForVideos( + courseId: String, + isNeedRefresh: Boolean = false + ): CourseStructure { + val courseStructure = repository.getCourseStructure(courseId, isNeedRefresh) val blocks = courseStructure.blockData val videoBlocks = blocks.filter { it.type == BlockType.VIDEO } val resultBlocks = ArrayList() 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 c44733948..669b1f661 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 @@ -30,6 +30,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -294,6 +295,8 @@ 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 pagerState = rememberPagerState(pageCount = { CourseContainerTab.entries.size }) val tabState = rememberLazyListState() val snackState = remember { SnackbarHostState() } @@ -351,15 +354,17 @@ fun CourseDashboard( fragmentManager.popBackStack() }, bodyContent = { - DashboardPager( - windowSize = windowSize, - viewModel = viewModel, - pagerState = pagerState, - isNavigationEnabled = isNavigationEnabled, - isResumed = isResumed, - fragmentManager = fragmentManager, - bundle = bundle - ) + if (dataReady.value == true) { + DashboardPager( + windowSize = windowSize, + viewModel = viewModel, + pagerState = pagerState, + isNavigationEnabled = isNavigationEnabled, + isResumed = isResumed, + fragmentManager = fragmentManager, + bundle = bundle + ) + } } ) PullRefreshIndicator( @@ -462,6 +467,8 @@ fun DashboardPager( courseDatesViewModel = koinViewModel( parameters = { parametersOf( + bundle.getString(CourseContainerFragment.ARG_COURSE_ID, ""), + bundle.getString(CourseContainerFragment.ARG_TITLE, ""), bundle.getString(CourseContainerFragment.ARG_ENROLLMENT_MODE, "") ) } @@ -478,6 +485,14 @@ fun DashboardPager( CourseContainerTab.DISCUSSIONS -> { DiscussionTopicsScreen( + discussionTopicsViewModel = koinViewModel( + parameters = { + parametersOf( + bundle.getString(CourseContainerFragment.ARG_COURSE_ID, ""), + bundle.getString(CourseContainerFragment.ARG_TITLE, ""), + ) + } + ), windowSize = windowSize, fragmentManager = fragmentManager ) 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 c61d7e165..8562289af 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 @@ -31,7 +31,6 @@ import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CalendarSyncEvent.CheckCalendarSyncEvent import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEvent import org.openedx.core.system.notifier.CourseCompletionSet -import org.openedx.core.system.notifier.CourseDataReady import org.openedx.core.system.notifier.CourseDatesShifted import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier @@ -169,12 +168,7 @@ class CourseContainerViewModel( _showProgress.value = true viewModelScope.launch { try { - if (networkConnection.isOnline()) { - interactor.preloadCourseStructure(courseId) - } else { - interactor.preloadCourseStructureFromCache(courseId) - } - val courseStructure = interactor.getCourseStructureFromCache() + val courseStructure = interactor.getCourseStructure(courseId) courseName = courseStructure.name _organization = courseStructure.org _isSelfPaced = courseStructure.isSelfPaced @@ -183,7 +177,6 @@ class CourseContainerViewModel( val isReady = start < Date() if (isReady) { _isNavigationEnabled.value = true - courseNotifier.send(CourseDataReady(courseStructure)) } isReady } @@ -248,7 +241,7 @@ class CourseContainerViewModel( fun updateData() { viewModelScope.launch { try { - interactor.preloadCourseStructure(courseId) + interactor.getCourseStructure(courseId, isNeedRefresh = true) } catch (e: Exception) { if (e.isInternetError()) { _errorMessage.value = 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 79f866ba7..e5ce08ed7 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 @@ -19,6 +19,7 @@ import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.CourseBannerType import org.openedx.core.domain.model.CourseDateBlock +import org.openedx.core.domain.model.CourseStructure import org.openedx.core.extension.getSequentialBlocks import org.openedx.core.extension.getVerticalBlocks import org.openedx.core.extension.isInternetError @@ -26,7 +27,6 @@ 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.CourseDataReady import org.openedx.core.system.notifier.CourseDatesShifted import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier @@ -41,6 +41,8 @@ import org.openedx.course.presentation.calendarsync.CalendarSyncUIState import org.openedx.core.R as CoreR class CourseDatesViewModel( + val courseId: String, + courseTitle: String, private val enrollmentMode: String, private val courseNotifier: CourseNotifier, private val interactor: CourseInteractor, @@ -51,8 +53,6 @@ class CourseDatesViewModel( private val config: Config, ) : BaseViewModel() { - var courseId = "" - var courseName = "" var isSelfPaced = true private val _uiState = MutableLiveData(DatesUIState.Loading) @@ -66,7 +66,7 @@ class CourseDatesViewModel( private val _calendarSyncUIState = MutableStateFlow( CalendarSyncUIState( isCalendarSyncEnabled = isCalendarSyncEnabled(), - calendarTitle = calendarManager.getCourseCalendarTitle(courseName), + calendarTitle = calendarManager.getCourseCalendarTitle(courseTitle), isSynced = false, ) ) @@ -74,6 +74,7 @@ class CourseDatesViewModel( _calendarSyncUIState.asStateFlow() private var courseBannerType: CourseBannerType = CourseBannerType.BLANK + private var courseStructure: CourseStructure? = null val isCourseExpandableSectionsEnabled get() = config.isCourseNestedListEnabled() @@ -90,22 +91,19 @@ class CourseDatesViewModel( loadingCourseDatesInternal() } } - - is CourseDataReady -> { - courseId = event.courseStructure.id - courseName = event.courseStructure.name - isSelfPaced = event.courseStructure.isSelfPaced - loadingCourseDatesInternal() - updateAndFetchCalendarSyncState() - } } } } + + loadingCourseDatesInternal() + updateAndFetchCalendarSyncState() } private fun loadingCourseDatesInternal() { viewModelScope.launch { try { + courseStructure = interactor.getCourseStructure(courseId = courseId) + isSelfPaced = courseStructure?.isSelfPaced ?: false val datesResponse = interactor.getCourseDates(courseId = courseId) if (datesResponse.datesSection.isEmpty()) { _uiState.value = DatesUIState.Empty @@ -146,8 +144,8 @@ class CourseDatesViewModel( fun getVerticalBlock(blockId: String): Block? { return try { - val courseStructure = interactor.getCourseStructureFromCache() - courseStructure.blockData.getVerticalBlocks().find { it.descendants.contains(blockId) } + courseStructure?.blockData?.getVerticalBlocks() + ?.find { it.descendants.contains(blockId) } } catch (e: Exception) { null } @@ -155,9 +153,8 @@ class CourseDatesViewModel( fun getSequentialBlock(blockId: String): Block? { return try { - val courseStructure = interactor.getCourseStructureFromCache() - courseStructure.blockData.getSequentialBlocks() - .find { it.descendants.contains(blockId) } + courseStructure?.blockData?.getSequentialBlocks() + ?.find { it.descendants.contains(blockId) } } catch (e: Exception) { null } 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 569498ab6..7a6e08b58 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt @@ -28,7 +28,6 @@ import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEvent -import org.openedx.core.system.notifier.CourseDataReady import org.openedx.core.system.notifier.CourseDatesShifted import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier @@ -84,15 +83,12 @@ class CourseOutlineViewModel( init { viewModelScope.launch { courseNotifier.notifier.collect { event -> - when(event) { + when (event) { is CourseStructureUpdated -> { if (event.courseId == courseId) { updateCourseData() } } - is CourseDataReady -> { - getCourseData() - } } } } @@ -113,6 +109,8 @@ class CourseOutlineViewModel( } } } + + getCourseData() } override fun saveDownloadModels(folder: String, id: String) { @@ -166,7 +164,7 @@ class CourseOutlineViewModel( private fun getCourseDataInternal() { viewModelScope.launch { try { - var courseStructure = interactor.getCourseStructureFromCache() + var courseStructure = interactor.getCourseStructure(courseId) val blocks = courseStructure.blockData val courseStatus = if (networkConnection.isOnline()) { diff --git a/course/src/main/java/org/openedx/course/presentation/section/CourseSectionViewModel.kt b/course/src/main/java/org/openedx/course/presentation/section/CourseSectionViewModel.kt index 97f241650..33870c69c 100644 --- a/course/src/main/java/org/openedx/course/presentation/section/CourseSectionViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/section/CourseSectionViewModel.kt @@ -89,8 +89,8 @@ class CourseSectionViewModel( viewModelScope.launch { try { val courseStructure = when (mode) { - CourseViewMode.FULL -> interactor.getCourseStructureFromCache() - CourseViewMode.VIDEOS -> interactor.getCourseStructureForVideos() + CourseViewMode.FULL -> interactor.getCourseStructure(courseId) + CourseViewMode.VIDEOS -> interactor.getCourseStructureForVideos(courseId) } val blocks = courseStructure.blockData setBlocks(blocks) diff --git a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt index fc7c9213f..1bc26e1a4 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt @@ -134,8 +134,7 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta super.onCreate(savedInstanceState) lifecycle.addObserver(viewModel) componentId = requireArguments().getString(ARG_COMPONENT_ID, "") - viewModel.loadBlocks(requireArguments().serializable(ARG_MODE)!!) - viewModel.setupCurrentIndex(componentId) + viewModel.loadBlocks(requireArguments().serializable(ARG_MODE)!!, componentId) viewModel.courseUnitContainerShowedEvent() } diff --git a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt index 323adb7cb..f479f08c0 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt @@ -76,23 +76,28 @@ class CourseUnitContainerViewModel( var hasNextBlock = false private var currentMode: CourseViewMode? = null + private var currentComponentId = "" private var courseName = "" private val _descendantsBlocks = MutableStateFlow>(listOf()) val descendantsBlocks = _descendantsBlocks.asStateFlow() - fun loadBlocks(mode: CourseViewMode) { + fun loadBlocks(mode: CourseViewMode, componentId: String = "") { currentMode = mode - try { - val courseStructure = when (mode) { - CourseViewMode.FULL -> interactor.getCourseStructureFromCache() - CourseViewMode.VIDEOS -> interactor.getCourseStructureForVideos() + viewModelScope.launch { + try { + val courseStructure = when (mode) { + CourseViewMode.FULL -> interactor.getCourseStructure(courseId) + CourseViewMode.VIDEOS -> interactor.getCourseStructureForVideos(courseId) + } + val blocks = courseStructure.blockData + courseName = courseStructure.name + this@CourseUnitContainerViewModel.blocks.clearAndAddAll(blocks) + + setupCurrentIndex(componentId) + } catch (e: Exception) { + e.printStackTrace() } - val blocks = courseStructure.blockData - courseName = courseStructure.name - this.blocks.clearAndAddAll(blocks) - } catch (e: Exception) { - //ignore e.printStackTrace() } } @@ -104,7 +109,7 @@ class CourseUnitContainerViewModel( if (event is CourseStructureUpdated) { if (event.courseId != courseId) return@collect - currentMode?.let { loadBlocks(it) } + currentMode?.let { loadBlocks(it, currentComponentId) } val blockId = blocks[currentVerticalIndex].id _subSectionUnitBlocks.value = getSubSectionUnitBlocks(blocks, getSubSectionId(blockId)) @@ -113,10 +118,10 @@ class CourseUnitContainerViewModel( } } - fun setupCurrentIndex(componentId: String = "") { - if (currentSectionIndex != -1) { - return - } + private fun setupCurrentIndex(componentId: String = "") { + if (currentSectionIndex != -1) return + currentComponentId = componentId + blocks.forEachIndexed { index, block -> if (block.id == unitId) { currentVerticalIndex = index 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 dc88105a8..f5e9be934 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 @@ -20,7 +20,6 @@ import org.openedx.core.module.download.BaseDownloadViewModel import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection -import org.openedx.core.system.notifier.CourseDataReady import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseStructureUpdated @@ -75,13 +74,9 @@ class CourseVideoViewModel( when (event) { is CourseStructureUpdated -> { if (event.courseId == courseId) { - updateVideos() + getVideos() } } - - is CourseDataReady -> { - getVideos() - } } } } @@ -114,6 +109,8 @@ class CourseVideoViewModel( } _videoSettings.value = preferencesManager.videoSettings + + getVideos() } override fun saveDownloadModels(folder: String, id: String) { @@ -141,13 +138,9 @@ class CourseVideoViewModel( super.saveAllDownloadModels(folder) } - private fun updateVideos() { - getVideos() - } - fun getVideos() { viewModelScope.launch { - var courseStructure = interactor.getCourseStructureForVideos() + var courseStructure = interactor.getCourseStructureForVideos(courseId) val blocks = courseStructure.blockData if (blocks.isEmpty()) { _uiState.value = CourseVideosUIState.Empty( 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 63dce6272..1b2cb6cca 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 @@ -25,6 +25,8 @@ import org.junit.rules.TestRule import org.openedx.core.ImageProcessor import org.openedx.core.R import org.openedx.core.config.Config +import org.openedx.core.data.api.CourseApi +import org.openedx.core.data.model.CourseStructureModel import org.openedx.core.data.model.User import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.AppConfig @@ -64,6 +66,7 @@ class CourseContainerViewModelTest { private val mockBitmap = mockk() private val imageProcessor = mockk() private val courseRouter = mockk() + private val courseApi = mockk() private val openEdx = "OpenEdx" private val calendarTitle = "OpenEdx - Abc" @@ -108,6 +111,23 @@ class CourseContainerViewModelTest { isSelfPaced = false ) + private val courseStructureModel = CourseStructureModel( + root = "", + blockData = mapOf(), + id = "id", + name = "Course name", + number = "", + org = "Org", + start = "", + startDisplay = "", + startType = "", + end = null, + coursewareAccess = null, + media = null, + certificate = null, + isSelfPaced = false + ) + @Before fun setUp() { Dispatchers.setMain(dispatcher) @@ -129,7 +149,7 @@ class CourseContainerViewModelTest { } @Test - fun `preloadCourseStructure internet connection exception`() = runTest { + fun `getCourseStructure internet connection exception`() = runTest { val viewModel = CourseContainerViewModel( "", "", @@ -147,12 +167,12 @@ class CourseContainerViewModelTest { courseRouter ) every { networkConnection.isOnline() } returns true - coEvery { interactor.preloadCourseStructure(any()) } throws UnknownHostException() + coEvery { interactor.getCourseStructure(any()) } throws UnknownHostException() every { analytics.logEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } returns Unit viewModel.preloadCourseStructure() advanceUntilIdle() - coVerify(exactly = 1) { interactor.preloadCourseStructure(any()) } + coVerify(exactly = 1) { interactor.getCourseStructure(any()) } verify(exactly = 1) { analytics.logEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } val message = viewModel.errorMessage.value @@ -162,7 +182,7 @@ class CourseContainerViewModelTest { } @Test - fun `preloadCourseStructure unknown exception`() = runTest { + fun `getCourseStructure unknown exception`() = runTest { val viewModel = CourseContainerViewModel( "", "", @@ -180,12 +200,12 @@ class CourseContainerViewModelTest { courseRouter ) every { networkConnection.isOnline() } returns true - coEvery { interactor.preloadCourseStructure(any()) } throws Exception() + coEvery { interactor.getCourseStructure(any()) } throws Exception() every { analytics.logEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } returns Unit viewModel.preloadCourseStructure() advanceUntilIdle() - coVerify(exactly = 1) { interactor.preloadCourseStructure(any()) } + coVerify(exactly = 1) { interactor.getCourseStructure(any()) } verify(exactly = 1) { analytics.logEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } val message = viewModel.errorMessage.value @@ -195,7 +215,7 @@ class CourseContainerViewModelTest { } @Test - fun `preloadCourseStructure success with internet`() = runTest { + fun `getCourseStructure success with internet`() = runTest { val viewModel = CourseContainerViewModel( "", "", @@ -213,13 +233,12 @@ class CourseContainerViewModelTest { courseRouter ) every { networkConnection.isOnline() } returns true - coEvery { interactor.preloadCourseStructure(any()) } returns Unit - every { interactor.getCourseStructureFromCache() } returns courseStructure + coEvery { interactor.getCourseStructure(any()) } returns courseStructure every { analytics.logEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } returns Unit viewModel.preloadCourseStructure() advanceUntilIdle() - coVerify(exactly = 1) { interactor.preloadCourseStructure(any()) } + coVerify(exactly = 1) { interactor.getCourseStructure(any()) } verify(exactly = 1) { analytics.logEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } assert(viewModel.errorMessage.value == null) @@ -228,7 +247,7 @@ class CourseContainerViewModelTest { } @Test - fun `preloadCourseStructure success without internet`() = runTest { + fun `getCourseStructure success without internet`() = runTest { val viewModel = CourseContainerViewModel( "", "", @@ -246,14 +265,15 @@ class CourseContainerViewModelTest { courseRouter ) every { networkConnection.isOnline() } returns false - coEvery { interactor.preloadCourseStructureFromCache(any()) } returns Unit - every { interactor.getCourseStructureFromCache() } returns courseStructure + coEvery { interactor.getCourseStructure(any()) } returns courseStructure every { analytics.logEvent(any(), any()) } returns Unit + coEvery { + courseApi.getCourseStructure(any(), any(), any(), any()) + } returns courseStructureModel viewModel.preloadCourseStructure() advanceUntilIdle() - coVerify(exactly = 0) { interactor.preloadCourseStructure(any()) } - coVerify(exactly = 1) { interactor.preloadCourseStructureFromCache(any()) } + coVerify(exactly = 0) { courseApi.getCourseStructure(any(), any(), any(), any()) } verify(exactly = 1) { analytics.logEvent(any(), any()) } assert(viewModel.errorMessage.value == null) @@ -279,12 +299,12 @@ class CourseContainerViewModelTest { imageProcessor, courseRouter ) - coEvery { interactor.preloadCourseStructure(any()) } throws UnknownHostException() + coEvery { interactor.getCourseStructure(any(), true) } throws UnknownHostException() coEvery { notifier.send(CourseStructureUpdated("")) } returns Unit viewModel.updateData() advanceUntilIdle() - coVerify(exactly = 1) { interactor.preloadCourseStructure(any()) } + coVerify(exactly = 1) { interactor.getCourseStructure(any(), true) } val message = viewModel.errorMessage.value assertEquals(noInternet, message) @@ -309,12 +329,12 @@ class CourseContainerViewModelTest { imageProcessor, courseRouter ) - coEvery { interactor.preloadCourseStructure(any()) } throws Exception() + coEvery { interactor.getCourseStructure(any(), true) } throws Exception() coEvery { notifier.send(CourseStructureUpdated("")) } returns Unit viewModel.updateData() advanceUntilIdle() - coVerify(exactly = 1) { interactor.preloadCourseStructure(any()) } + coVerify(exactly = 1) { interactor.getCourseStructure(any(), true) } val message = viewModel.errorMessage.value assertEquals(somethingWrong, message) @@ -339,12 +359,12 @@ class CourseContainerViewModelTest { imageProcessor, courseRouter ) - coEvery { interactor.preloadCourseStructure(any()) } returns Unit + coEvery { interactor.getCourseStructure(any(), true) } returns courseStructure coEvery { notifier.send(CourseStructureUpdated("")) } returns Unit viewModel.updateData() advanceUntilIdle() - coVerify(exactly = 1) { interactor.preloadCourseStructure(any()) } + coVerify(exactly = 1) { interactor.getCourseStructure(any(), true) } assert(viewModel.errorMessage.value == null) assert(!viewModel.refreshing.value) 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 40a2d41c0..13e78fe91 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 @@ -39,7 +39,6 @@ import org.openedx.core.domain.model.CoursewareAccess import org.openedx.core.domain.model.DatesSection import org.openedx.core.system.ResourceManager import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEvent -import org.openedx.core.system.notifier.CourseDataReady import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier import org.openedx.course.domain.interactor.CourseInteractor @@ -143,15 +142,15 @@ class CourseDatesViewModelTest { every { resourceManager.getString(id = R.string.platform_name) } returns openEdx every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong - every { interactor.getCourseStructureFromCache() } returns courseStructure + coEvery { interactor.getCourseStructure(any()) } returns courseStructure every { corePreferences.user } returns user every { corePreferences.appConfig } returns appConfig - every { notifier.notifier } returns flowOf(CourseDataReady(courseStructure)) + every { notifier.notifier } returns flowOf(CourseLoading(false)) every { calendarManager.getCourseCalendarTitle(any()) } returns calendarTitle every { calendarManager.isCalendarExists(any()) } returns true coEvery { notifier.send(any()) } returns Unit coEvery { notifier.send(any()) } returns Unit - coEvery { notifier.send(any()) } returns Unit + coEvery { notifier.send(any()) } returns Unit } @After @@ -162,6 +161,8 @@ class CourseDatesViewModelTest { @Test fun `getCourseDates no internet connection exception`() = runTest(UnconfinedTestDispatcher()) { val viewModel = CourseDatesViewModel( + "id", + "", "", notifier, interactor, @@ -179,15 +180,17 @@ class CourseDatesViewModelTest { } advanceUntilIdle() - coVerify(exactly = 1) { interactor.getCourseDates(any()) } + coVerify(exactly = 1) { interactor.getCourseDates(any()) } Assert.assertEquals(noInternet, message.await()?.message) assert(viewModel.uiState.value is DatesUIState.Loading) } @Test - fun `getCourseDates unknown exception`() = runTest(UnconfinedTestDispatcher()) { + fun `getCourseDates unknown exception`() = runTest(UnconfinedTestDispatcher()) { val viewModel = CourseDatesViewModel( + "id", + "", "", notifier, interactor, @@ -214,6 +217,8 @@ class CourseDatesViewModelTest { @Test fun `getCourseDates success with internet`() = runTest(UnconfinedTestDispatcher()) { val viewModel = CourseDatesViewModel( + "id", + "", "", notifier, interactor, @@ -240,6 +245,8 @@ class CourseDatesViewModelTest { @Test fun `getCourseDates success with EmptyList`() = runTest(UnconfinedTestDispatcher()) { val viewModel = CourseDatesViewModel( + "id", + "", "", notifier, interactor, 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 098960a2a..c2b2cff57 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 @@ -218,7 +218,7 @@ class CourseOutlineViewModelTest { @Test fun `getCourseDataInternal no internet connection exception`() = runTest(UnconfinedTestDispatcher()) { - every { interactor.getCourseStructureFromCache() } returns courseStructure + coEvery { interactor.getCourseStructure(any()) } returns courseStructure every { networkConnection.isOnline() } returns true every { downloadDao.readAllData() } returns flow { emit(emptyList()) } coEvery { interactor.getCourseStatus(any()) } throws UnknownHostException() @@ -244,8 +244,8 @@ class CourseOutlineViewModelTest { viewModel.getCourseData() advanceUntilIdle() - verify(exactly = 1) { interactor.getCourseStructureFromCache() } - coVerify(exactly = 1) { interactor.getCourseStatus(any()) } + coVerify(exactly = 2) { interactor.getCourseStructure(any()) } + coVerify(exactly = 2) { interactor.getCourseStatus(any()) } assertEquals(noInternet, message.await()?.message) assert(viewModel.uiState.value is CourseOutlineUIState.Loading) @@ -253,7 +253,7 @@ class CourseOutlineViewModelTest { @Test fun `getCourseDataInternal unknown exception`() = runTest(UnconfinedTestDispatcher()) { - every { interactor.getCourseStructureFromCache() } returns courseStructure + coEvery { interactor.getCourseStructure(any()) } returns courseStructure every { networkConnection.isOnline() } returns true every { downloadDao.readAllData() } returns flow { emit(emptyList()) } coEvery { interactor.getCourseStatus(any()) } throws Exception() @@ -278,8 +278,8 @@ class CourseOutlineViewModelTest { viewModel.getCourseData() advanceUntilIdle() - verify(exactly = 1) { interactor.getCourseStructureFromCache() } - coVerify(exactly = 1) { interactor.getCourseStatus(any()) } + coVerify(exactly = 2) { interactor.getCourseStructure(any()) } + coVerify(exactly = 2) { interactor.getCourseStatus(any()) } assertEquals(somethingWrong, message.await()?.message) assert(viewModel.uiState.value is CourseOutlineUIState.Loading) @@ -287,7 +287,7 @@ class CourseOutlineViewModelTest { @Test fun `getCourseDataInternal success with internet connection`() = runTest(UnconfinedTestDispatcher()) { - every { interactor.getCourseStructureFromCache() } returns courseStructure + coEvery { interactor.getCourseStructure(any()) } returns courseStructure every { networkConnection.isOnline() } returns true coEvery { downloadDao.readAllData() } returns flow { emit( @@ -321,11 +321,12 @@ class CourseOutlineViewModelTest { viewModel.uiMessage.first() as? UIMessage.SnackBarMessage } } + viewModel.getCourseData() advanceUntilIdle() - verify(exactly = 1) { interactor.getCourseStructureFromCache() } - coVerify(exactly = 1) { interactor.getCourseStatus(any()) } + coVerify(exactly = 2) { interactor.getCourseStructure(any()) } + coVerify(exactly = 2) { interactor.getCourseStatus(any()) } assert(message.await() == null) assert(viewModel.uiState.value is CourseOutlineUIState.CourseData) @@ -333,7 +334,7 @@ class CourseOutlineViewModelTest { @Test fun `getCourseDataInternal success without internet connection`() = runTest(UnconfinedTestDispatcher()) { - every { interactor.getCourseStructureFromCache() } returns courseStructure + coEvery { interactor.getCourseStructure(any()) } returns courseStructure every { networkConnection.isOnline() } returns false coEvery { downloadDao.readAllData() } returns flow { emit( @@ -370,7 +371,7 @@ class CourseOutlineViewModelTest { viewModel.getCourseData() advanceUntilIdle() - verify(exactly = 1) { interactor.getCourseStructureFromCache() } + coVerify(exactly = 2) { interactor.getCourseStructure(any()) } coVerify(exactly = 0) { interactor.getCourseStatus(any()) } assert(message.await() == null) @@ -379,7 +380,7 @@ class CourseOutlineViewModelTest { @Test fun `updateCourseData success with internet connection`() = runTest(UnconfinedTestDispatcher()) { - every { interactor.getCourseStructureFromCache() } returns courseStructure + coEvery { interactor.getCourseStructure(any()) } returns courseStructure every { networkConnection.isOnline() } returns true coEvery { downloadDao.readAllData() } returns flow { emit( @@ -417,8 +418,8 @@ class CourseOutlineViewModelTest { viewModel.updateCourseData() advanceUntilIdle() - coVerify(exactly = 2) { interactor.getCourseStructureFromCache() } - coVerify(exactly = 2) { interactor.getCourseStatus(any()) } + coVerify(exactly = 3) { interactor.getCourseStructure(any()) } + coVerify(exactly = 3) { interactor.getCourseStatus(any()) } assert(message.await() == null) assert(viewModel.uiState.value is CourseOutlineUIState.CourseData) @@ -442,7 +443,7 @@ class CourseOutlineViewModelTest { workerController ) coEvery { notifier.notifier } returns flow { emit(CourseStructureUpdated("")) } - every { interactor.getCourseStructureFromCache() } returns courseStructure + coEvery { interactor.getCourseStructure(any()) } returns courseStructure every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") @@ -454,14 +455,14 @@ class CourseOutlineViewModelTest { viewModel.getCourseData() advanceUntilIdle() - coVerify(exactly = 1) { interactor.getCourseStructureFromCache() } + coVerify(exactly = 2) { interactor.getCourseStructure(any()) } coVerify(exactly = 1) { interactor.getCourseStatus(any()) } } @Test fun `saveDownloadModels test`() = runTest(UnconfinedTestDispatcher()) { every { preferencesManager.videoSettings.wifiDownloadOnly } returns false - every { interactor.getCourseStructureFromCache() } returns courseStructure + coEvery { interactor.getCourseStructure(any()) } returns courseStructure every { networkConnection.isWifiConnected() } returns true every { networkConnection.isOnline() } returns true every { @@ -508,7 +509,7 @@ class CourseOutlineViewModelTest { @Test fun `saveDownloadModels only wifi download, with connection`() = runTest(UnconfinedTestDispatcher()) { - every { interactor.getCourseStructureFromCache() } returns courseStructure + coEvery { interactor.getCourseStructure(any()) } returns courseStructure coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") every { preferencesManager.videoSettings.wifiDownloadOnly } returns true every { networkConnection.isWifiConnected() } returns true @@ -545,7 +546,7 @@ class CourseOutlineViewModelTest { @Test fun `saveDownloadModels only wifi download, without connection`() = runTest(UnconfinedTestDispatcher()) { - every { interactor.getCourseStructureFromCache() } returns courseStructure + coEvery { interactor.getCourseStructure(any()) } returns courseStructure every { preferencesManager.videoSettings.wifiDownloadOnly } returns true every { networkConnection.isWifiConnected() } returns false every { networkConnection.isOnline() } returns false diff --git a/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt index ba6aa779c..0a398371b 100644 --- a/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt @@ -185,14 +185,14 @@ class CourseSectionViewModelTest { downloadDao, ) - coEvery { interactor.getCourseStructureFromCache() } throws UnknownHostException() - coEvery { interactor.getCourseStructureForVideos() } throws UnknownHostException() + coEvery { interactor.getCourseStructure(any()) } throws UnknownHostException() + coEvery { interactor.getCourseStructureForVideos(any()) } throws UnknownHostException() viewModel.getBlocks("", CourseViewMode.FULL) advanceUntilIdle() - coVerify(exactly = 1) { interactor.getCourseStructureFromCache() } - coVerify(exactly = 0) { interactor.getCourseStructureForVideos() } + coVerify(exactly = 1) { interactor.getCourseStructure(any()) } + coVerify(exactly = 0) { interactor.getCourseStructureForVideos(any()) } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assertEquals(noInternet, message?.message) @@ -215,14 +215,14 @@ class CourseSectionViewModelTest { downloadDao, ) - coEvery { interactor.getCourseStructureFromCache() } throws Exception() - coEvery { interactor.getCourseStructureForVideos() } throws Exception() + coEvery { interactor.getCourseStructure(any()) } throws Exception() + coEvery { interactor.getCourseStructureForVideos(any()) } throws Exception() viewModel.getBlocks("id2", CourseViewMode.FULL) advanceUntilIdle() - coVerify(exactly = 1) { interactor.getCourseStructureFromCache() } - coVerify(exactly = 0) { interactor.getCourseStructureForVideos() } + coVerify(exactly = 1) { interactor.getCourseStructure(any()) } + coVerify(exactly = 0) { interactor.getCourseStructureForVideos(any()) } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assertEquals(somethingWrong, message?.message) @@ -247,14 +247,17 @@ class CourseSectionViewModelTest { downloadDao, ) - coEvery { interactor.getCourseStructureFromCache() } returns courseStructure - coEvery { interactor.getCourseStructureForVideos() } returns courseStructure + coEvery { downloadDao.readAllData() } returns flow { + emit(listOf(DownloadModelEntity.createFrom(downloadModel))) + } + coEvery { interactor.getCourseStructure(any()) } returns courseStructure + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure viewModel.getBlocks("id", CourseViewMode.VIDEOS) advanceUntilIdle() - coVerify(exactly = 0) { interactor.getCourseStructureFromCache() } - coVerify(exactly = 1) { interactor.getCourseStructureForVideos() } + coVerify(exactly = 0) { interactor.getCourseStructure(any()) } + coVerify(exactly = 1) { interactor.getCourseStructureForVideos(any()) } assert(viewModel.uiMessage.value == null) assert(viewModel.uiState.value is CourseSectionUIState.Blocks) @@ -366,8 +369,8 @@ class CourseSectionViewModelTest { ) coEvery { notifier.notifier } returns flow { } - coEvery { interactor.getCourseStructureFromCache() } returns courseStructure - coEvery { interactor.getCourseStructureForVideos() } returns courseStructure + coEvery { interactor.getCourseStructure(any()) } returns courseStructure + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure val mockLifeCycleOwner: LifecycleOwner = mockk() val lifecycleRegistry = LifecycleRegistry(mockLifeCycleOwner) diff --git a/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt index b92a02f5a..1e5354a95 100644 --- a/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt @@ -1,13 +1,18 @@ package org.openedx.course.presentation.unit.container import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk -import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.test.* +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Before import org.junit.Rule @@ -147,159 +152,161 @@ class CourseUnitContainerViewModelTest { val viewModel = CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics) - every { interactor.getCourseStructureFromCache() } throws UnknownHostException() - every { interactor.getCourseStructureForVideos() } throws UnknownHostException() + coEvery { interactor.getCourseStructure(any()) } throws UnknownHostException() + coEvery { interactor.getCourseStructureForVideos(any()) } throws UnknownHostException() viewModel.loadBlocks(CourseViewMode.FULL) advanceUntilIdle() - verify(exactly = 1) { interactor.getCourseStructureFromCache() } + coVerify(exactly = 1) { interactor.getCourseStructure(any()) } } @Test fun `getBlocks unknown exception`() = runTest { every { notifier.notifier } returns MutableSharedFlow() - val viewModel = CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics) + val viewModel = + CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics) - every { interactor.getCourseStructureFromCache() } throws UnknownHostException() - every { interactor.getCourseStructureForVideos() } throws UnknownHostException() + coEvery { interactor.getCourseStructure(any()) } throws UnknownHostException() + coEvery { interactor.getCourseStructureForVideos(any()) } throws UnknownHostException() viewModel.loadBlocks(CourseViewMode.FULL) advanceUntilIdle() - verify(exactly = 1) { interactor.getCourseStructureFromCache() } + coVerify(exactly = 1) { interactor.getCourseStructure(any()) } } @Test fun `getBlocks unknown success`() = runTest { every { notifier.notifier } returns MutableSharedFlow() - val viewModel = CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics) + val viewModel = + CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics) - every { interactor.getCourseStructureFromCache() } returns courseStructure - every { interactor.getCourseStructureForVideos() } returns courseStructure + coEvery { interactor.getCourseStructure(any()) } returns courseStructure + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure viewModel.loadBlocks(CourseViewMode.VIDEOS) advanceUntilIdle() - verify(exactly = 0) { interactor.getCourseStructureFromCache() } - verify(exactly = 1) { interactor.getCourseStructureForVideos() } + coVerify(exactly = 0) { interactor.getCourseStructure(any()) } + coVerify(exactly = 1) { interactor.getCourseStructureForVideos(any()) } } @Test fun setupCurrentIndex() = runTest { every { notifier.notifier } returns MutableSharedFlow() - val viewModel = CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics) - every { interactor.getCourseStructureFromCache() } returns courseStructure - every { interactor.getCourseStructureForVideos() } returns courseStructure + val viewModel = + CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics) + coEvery { interactor.getCourseStructure(any()) } returns courseStructure + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - viewModel.loadBlocks(CourseViewMode.VIDEOS) - viewModel.setupCurrentIndex("id") + viewModel.loadBlocks(CourseViewMode.VIDEOS, "id") advanceUntilIdle() - verify(exactly = 0) { interactor.getCourseStructureFromCache() } - verify(exactly = 1) { interactor.getCourseStructureForVideos() } + coVerify(exactly = 0) { interactor.getCourseStructure(any()) } + coVerify(exactly = 1) { interactor.getCourseStructureForVideos(any()) } } @Test fun `getCurrentBlock test`() = runTest { every { notifier.notifier } returns MutableSharedFlow() - val viewModel = CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics) - every { interactor.getCourseStructureFromCache() } returns courseStructure - every { interactor.getCourseStructureForVideos() } returns courseStructure + val viewModel = + CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics) + coEvery { interactor.getCourseStructure(any()) } returns courseStructure + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - viewModel.loadBlocks(CourseViewMode.VIDEOS) - viewModel.setupCurrentIndex("id") + viewModel.loadBlocks(CourseViewMode.VIDEOS, "id") advanceUntilIdle() - verify(exactly = 0) { interactor.getCourseStructureFromCache() } - verify(exactly = 1) { interactor.getCourseStructureForVideos() } + coVerify(exactly = 0) { interactor.getCourseStructure("") } + coVerify(exactly = 1) { interactor.getCourseStructureForVideos("") } assert(viewModel.getCurrentBlock().id == "id") } @Test fun `moveToPrevBlock null`() = runTest { every { notifier.notifier } returns MutableSharedFlow() - val viewModel = CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics) - every { interactor.getCourseStructureFromCache() } returns courseStructure - every { interactor.getCourseStructureForVideos() } returns courseStructure + val viewModel = + CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics) + coEvery { interactor.getCourseStructure(any()) } returns courseStructure + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - viewModel.loadBlocks(CourseViewMode.VIDEOS) - viewModel.setupCurrentIndex("id") + viewModel.loadBlocks(CourseViewMode.VIDEOS, "id") advanceUntilIdle() - verify(exactly = 0) { interactor.getCourseStructureFromCache() } - verify(exactly = 1) { interactor.getCourseStructureForVideos() } + coVerify(exactly = 0) { interactor.getCourseStructure("") } + coVerify(exactly = 1) { interactor.getCourseStructureForVideos("") } assert(viewModel.moveToPrevBlock() == null) } @Test fun `moveToPrevBlock not null`() = runTest { every { notifier.notifier } returns MutableSharedFlow() - val viewModel = CourseUnitContainerViewModel("", "id", config, interactor, notifier, analytics) - every { interactor.getCourseStructureFromCache() } returns courseStructure - every { interactor.getCourseStructureForVideos() } returns courseStructure + val viewModel = + CourseUnitContainerViewModel("", "id", config, interactor, notifier, analytics) + coEvery { interactor.getCourseStructure(any()) } returns courseStructure + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - viewModel.loadBlocks(CourseViewMode.VIDEOS) - viewModel.setupCurrentIndex("id1") + viewModel.loadBlocks(CourseViewMode.VIDEOS, "id1") advanceUntilIdle() - verify(exactly = 0) { interactor.getCourseStructureFromCache() } - verify(exactly = 1) { interactor.getCourseStructureForVideos() } + coVerify(exactly = 0) { interactor.getCourseStructure("") } + coVerify(exactly = 1) { interactor.getCourseStructureForVideos("") } assert(viewModel.moveToPrevBlock() != null) } @Test fun `moveToNextBlock null`() = runTest { every { notifier.notifier } returns MutableSharedFlow() - val viewModel = CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics) - every { interactor.getCourseStructureFromCache() } returns courseStructure - every { interactor.getCourseStructureForVideos() } returns courseStructure + val viewModel = + CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics) + coEvery { interactor.getCourseStructure(any()) } returns courseStructure + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - viewModel.loadBlocks(CourseViewMode.VIDEOS) - viewModel.setupCurrentIndex("id3") + viewModel.loadBlocks(CourseViewMode.VIDEOS, "id3") advanceUntilIdle() - verify(exactly = 0) { interactor.getCourseStructureFromCache() } - verify(exactly = 1) { interactor.getCourseStructureForVideos() } + coVerify(exactly = 0) { interactor.getCourseStructure("") } + coVerify(exactly = 1) { interactor.getCourseStructureForVideos("") } assert(viewModel.moveToNextBlock() == null) } @Test fun `moveToNextBlock not null`() = runTest { every { notifier.notifier } returns MutableSharedFlow() - val viewModel = CourseUnitContainerViewModel("", "id", config, interactor, notifier, analytics) - every { interactor.getCourseStructureFromCache() } returns courseStructure - every { interactor.getCourseStructureForVideos() } returns courseStructure + val viewModel = + CourseUnitContainerViewModel("", "id", config, interactor, notifier, analytics) + coEvery { interactor.getCourseStructure("") } returns courseStructure + coEvery { interactor.getCourseStructureForVideos("") } returns courseStructure - viewModel.loadBlocks(CourseViewMode.VIDEOS) - viewModel.setupCurrentIndex("id") + viewModel.loadBlocks(CourseViewMode.VIDEOS, "id") advanceUntilIdle() - verify(exactly = 0) { interactor.getCourseStructureFromCache() } - verify(exactly = 1) { interactor.getCourseStructureForVideos() } + coVerify(exactly = 0) { interactor.getCourseStructure("") } + coVerify(exactly = 1) { interactor.getCourseStructureForVideos("") } assert(viewModel.moveToNextBlock() != null) } @Test fun `currentIndex isLastIndex`() = runTest { every { notifier.notifier } returns MutableSharedFlow() - val viewModel = CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics) - every { interactor.getCourseStructureFromCache() } returns courseStructure - every { interactor.getCourseStructureForVideos() } returns courseStructure + val viewModel = + CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics) + coEvery { interactor.getCourseStructure(any()) } returns courseStructure + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - viewModel.loadBlocks(CourseViewMode.VIDEOS) - viewModel.setupCurrentIndex("id3") + viewModel.loadBlocks(CourseViewMode.VIDEOS, "id3") advanceUntilIdle() - verify(exactly = 0) { interactor.getCourseStructureFromCache() } - verify(exactly = 1) { interactor.getCourseStructureForVideos() } + coVerify(exactly = 0) { interactor.getCourseStructure(any()) } + coVerify(exactly = 1) { interactor.getCourseStructureForVideos(any()) } } } \ No newline at end of file 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 43d057a6c..a2dae8b2e 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 @@ -46,7 +46,7 @@ import org.openedx.core.module.db.FileType import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection -import org.openedx.core.system.notifier.CourseDataReady +import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseStructureUpdated import org.openedx.core.system.notifier.VideoNotifier @@ -171,7 +171,7 @@ class CourseVideoViewModelTest { every { resourceManager.getString(R.string.course_can_download_only_with_wifi) } returns cantDownload Dispatchers.setMain(dispatcher) every { config.getApiHostURL() } returns "http://localhost:8000" - every { courseNotifier.notifier } returns flowOf(CourseDataReady(courseStructure)) + every { courseNotifier.notifier } returns flowOf(CourseLoading(false)) } @After @@ -182,7 +182,8 @@ class CourseVideoViewModelTest { @Test fun `getVideos empty list`() = runTest { every { config.isCourseNestedListEnabled() } returns false - every { interactor.getCourseStructureForVideos() } returns courseStructure.copy(blockData = emptyList()) + coEvery { interactor.getCourseStructureForVideos(any()) } returns + courseStructure.copy(blockData = emptyList()) every { downloadDao.readAllData() } returns flow { emit(emptyList()) } every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( @@ -204,7 +205,7 @@ class CourseVideoViewModelTest { viewModel.getVideos() advanceUntilIdle() - coVerify(exactly = 2) { interactor.getCourseStructureForVideos() } + coVerify(exactly = 2) { interactor.getCourseStructureForVideos(any()) } assert(viewModel.uiState.value is CourseVideosUIState.Empty) } @@ -212,7 +213,7 @@ class CourseVideoViewModelTest { @Test fun `getVideos success`() = runTest { every { config.isCourseNestedListEnabled() } returns false - every { interactor.getCourseStructureForVideos() } returns courseStructure + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure every { downloadDao.readAllData() } returns flow { emit(emptyList()) } every { preferencesManager.videoSettings } returns VideoSettings.default @@ -236,7 +237,7 @@ class CourseVideoViewModelTest { viewModel.getVideos() advanceUntilIdle() - coVerify(exactly = 2) { interactor.getCourseStructureForVideos() } + coVerify(exactly = 2) { interactor.getCourseStructureForVideos(any()) } assert(viewModel.uiState.value is CourseVideosUIState.CourseData) } @@ -244,10 +245,9 @@ class CourseVideoViewModelTest { @Test fun `updateVideos success`() = runTest { every { config.isCourseNestedListEnabled() } returns false - every { interactor.getCourseStructureForVideos() } returns courseStructure + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure coEvery { courseNotifier.notifier } returns flow { emit(CourseStructureUpdated("")) - emit(CourseDataReady(courseStructure)) } every { downloadDao.readAllData() } returns flow { repeat(5) { @@ -279,7 +279,7 @@ class CourseVideoViewModelTest { advanceUntilIdle() - coVerify(exactly = 2) { interactor.getCourseStructureForVideos() } + coVerify(exactly = 2) { interactor.getCourseStructureForVideos(any()) } assert(viewModel.uiState.value is CourseVideosUIState.CourseData) } @@ -288,7 +288,7 @@ class CourseVideoViewModelTest { fun `setIsUpdating success`() = runTest { every { config.isCourseNestedListEnabled() } returns false every { preferencesManager.videoSettings } returns VideoSettings.default - coEvery { interactor.getCourseStructureForVideos() } returns courseStructure + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure coEvery { downloadDao.readAllData() } returns flow { emit(listOf(downloadModelEntity)) } advanceUntilIdle() } @@ -312,7 +312,7 @@ class CourseVideoViewModelTest { downloadDao, workerController ) - coEvery { interactor.getCourseStructureForVideos() } returns courseStructure + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure coEvery { downloadDao.readAllData() } returns flow { emit(listOf(downloadModelEntity)) } every { preferencesManager.videoSettings.wifiDownloadOnly } returns false every { networkConnection.isWifiConnected() } returns true @@ -348,7 +348,7 @@ class CourseVideoViewModelTest { downloadDao, workerController ) - coEvery { interactor.getCourseStructureForVideos() } returns courseStructure + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure coEvery { downloadDao.readAllData() } returns flow { emit(listOf(downloadModelEntity)) } every { preferencesManager.videoSettings.wifiDownloadOnly } returns true every { networkConnection.isWifiConnected() } returns true @@ -391,7 +391,7 @@ class CourseVideoViewModelTest { every { preferencesManager.videoSettings.wifiDownloadOnly } returns true every { networkConnection.isWifiConnected() } returns false every { networkConnection.isOnline() } returns false - coEvery { interactor.getCourseStructureForVideos() } returns courseStructure + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure coEvery { downloadDao.readAllData() } returns flow { emit(listOf(downloadModelEntity)) } coEvery { workerController.saveModels(any()) } returns Unit val message = async { diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsScreen.kt b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsScreen.kt index 2797ed1a6..62ec564b6 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsScreen.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsScreen.kt @@ -58,7 +58,7 @@ import org.openedx.discussion.R as discussionR @Composable fun DiscussionTopicsScreen( - discussionTopicsViewModel: DiscussionTopicsViewModel = koinViewModel(), + discussionTopicsViewModel: DiscussionTopicsViewModel, windowSize: WindowSize, fragmentManager: FragmentManager ) { 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 5bdd90d70..46552edc9 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 @@ -13,7 +13,6 @@ 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.CourseDataReady import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseRefresh @@ -22,6 +21,8 @@ import org.openedx.discussion.presentation.DiscussionAnalytics import org.openedx.discussion.presentation.DiscussionRouter class DiscussionTopicsViewModel( + val courseId: String, + private val courseTitle: String, private val interactor: DiscussionInteractor, private val resourceManager: ResourceManager, private val analytics: DiscussionAnalytics, @@ -29,9 +30,6 @@ class DiscussionTopicsViewModel( val discussionRouter: DiscussionRouter, ) : BaseViewModel() { - var courseId: String = "" - var courseName: String = "" - private val _uiState = MutableLiveData() val uiState: LiveData get() = _uiState @@ -42,6 +40,8 @@ class DiscussionTopicsViewModel( init { collectCourseNotifier() + + getCourseTopic() } private fun getCourseTopic() { @@ -64,15 +64,15 @@ class DiscussionTopicsViewModel( fun discussionClickedEvent(action: String, data: String, title: String) { when (action) { ALL_POSTS -> { - analytics.discussionAllPostsClickedEvent(courseId, courseName) + analytics.discussionAllPostsClickedEvent(courseId, courseTitle) } FOLLOWING_POSTS -> { - analytics.discussionFollowingClickedEvent(courseId, courseName) + analytics.discussionFollowingClickedEvent(courseId, courseTitle) } TOPIC -> { - analytics.discussionTopicClickedEvent(courseId, courseName, data, title) + analytics.discussionTopicClickedEvent(courseId, courseTitle, data, title) } } } @@ -81,12 +81,6 @@ class DiscussionTopicsViewModel( viewModelScope.launch { courseNotifier.notifier.collect { event -> when (event) { - is CourseDataReady -> { - courseId = event.courseStructure.id - courseName = event.courseStructure.name - getCourseTopic() - } - is CourseRefresh -> { if (event.courseContainerTab == CourseContainerTab.DISCUSSIONS) { getCourseTopic() diff --git a/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt b/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt index fcff13a30..b74cc6644 100644 --- a/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt +++ b/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt @@ -31,7 +31,6 @@ import org.openedx.core.domain.model.BlockCounts import org.openedx.core.domain.model.CourseStructure import org.openedx.core.domain.model.CoursewareAccess import org.openedx.core.system.ResourceManager -import org.openedx.core.system.notifier.CourseDataReady import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier import org.openedx.discussion.domain.interactor.DiscussionInteractor @@ -136,7 +135,7 @@ class DiscussionTopicsViewModelTest { Dispatchers.setMain(dispatcher) every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong - every { courseNotifier.notifier } returns flowOf(CourseDataReady(courseStructure)) + every { courseNotifier.notifier } returns flowOf(CourseLoading(false)) coEvery { courseNotifier.send(any()) } returns Unit } @@ -147,7 +146,7 @@ class DiscussionTopicsViewModelTest { @Test fun `getCourseTopics no internet exception`() = runTest(UnconfinedTestDispatcher()) { - val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, courseNotifier, router) + val viewModel = DiscussionTopicsViewModel("id", "", interactor, resourceManager, analytics, courseNotifier, router) coEvery { interactor.getCourseTopics(any()) } throws UnknownHostException() val message = async { @@ -164,7 +163,7 @@ class DiscussionTopicsViewModelTest { @Test fun `getCourseTopics unknown exception`() = runTest(UnconfinedTestDispatcher()) { - val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, courseNotifier, router) + val viewModel = DiscussionTopicsViewModel("id", "", interactor, resourceManager, analytics, courseNotifier, router) coEvery { interactor.getCourseTopics(any()) } throws Exception() val message = async { @@ -181,7 +180,7 @@ class DiscussionTopicsViewModelTest { @Test fun `getCourseTopics success`() = runTest(UnconfinedTestDispatcher()) { - val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, courseNotifier, router) + val viewModel = DiscussionTopicsViewModel("id", "", interactor, resourceManager, analytics, courseNotifier, router) coEvery { interactor.getCourseTopics(any()) } returns mockk() advanceUntilIdle() @@ -198,7 +197,7 @@ class DiscussionTopicsViewModelTest { @Test fun `updateCourseTopics no internet exception`() = runTest(UnconfinedTestDispatcher()) { - val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, courseNotifier, router) + val viewModel = DiscussionTopicsViewModel("id", "", interactor, resourceManager, analytics, courseNotifier, router) coEvery { interactor.getCourseTopics(any()) } throws UnknownHostException() val message = async { @@ -215,7 +214,7 @@ class DiscussionTopicsViewModelTest { @Test fun `updateCourseTopics unknown exception`() = runTest(UnconfinedTestDispatcher()) { - val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, courseNotifier, router) + val viewModel = DiscussionTopicsViewModel("id", "", interactor, resourceManager, analytics, courseNotifier, router) coEvery { interactor.getCourseTopics(any()) } throws Exception() val message = async { @@ -232,7 +231,7 @@ class DiscussionTopicsViewModelTest { @Test fun `updateCourseTopics success`() = runTest(UnconfinedTestDispatcher()) { - val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, courseNotifier, router) + val viewModel = DiscussionTopicsViewModel("id", "", interactor, resourceManager, analytics, courseNotifier, router) coEvery { interactor.getCourseTopics(any()) } returns mockk() val message = async { From 4d78460d76229d0e5b13b91cfa2618f3f943e6d0 Mon Sep 17 00:00:00 2001 From: Volodymyr Chekyrta Date: Tue, 14 May 2024 22:47:47 +0300 Subject: [PATCH 03/56] feat: Course home. Moved certificate access. --- .../outline/CourseOutlineScreen.kt | 23 ++ .../course/presentation/ui/CourseUI.kt | 304 ++++-------------- .../res/drawable/ic_course_certificate.xml | 9 + .../res/drawable/ic_course_completed_mark.xml | 31 -- course/src/main/res/values-uk/strings.xml | 6 +- course/src/main/res/values/strings.xml | 8 +- 6 files changed, 102 insertions(+), 279 deletions(-) create mode 100644 course/src/main/res/drawable/ic_course_certificate.xml delete mode 100644 course/src/main/res/drawable/ic_course_completed_mark.xml 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 7e950cba8..6b9f9626d 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 @@ -66,6 +66,7 @@ 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.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 @@ -272,6 +273,28 @@ private fun CourseOutlineUI( } } } + + val certificate = uiState.courseStructure.certificate + if (certificate?.isCertificateEarned() == true) { + item { + CourseMessage( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp) + .then(listPadding), + icon = painterResource(R.drawable.ic_course_certificate), + message = stringResource( + R.string.course_you_earned_certificate, + uiState.courseStructure.name + ), + action = stringResource(R.string.course_view_certificate), + onActionClick = { + onCertificateClick(certificate.certificateURL ?: "") + } + ) + } + } + if (uiState.resumeComponent != null) { item { Box(listPadding) { 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 f9f028c0f..50d569308 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 @@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.Column 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.navigationBarsPadding @@ -45,8 +44,6 @@ import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Home -import androidx.compose.material.icons.filled.TaskAlt import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -60,32 +57,24 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration 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.compose.ui.zIndex -import coil.compose.AsyncImage -import coil.request.ImageRequest import org.jsoup.Jsoup import org.openedx.core.BlockType import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts -import org.openedx.core.domain.model.Certificate import org.openedx.core.domain.model.CourseDatesBannerInfo -import org.openedx.core.domain.model.CourseSharingUtmParameters -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.extension.isLinkValid import org.openedx.core.extension.nonZero import org.openedx.core.extension.toFileSize import org.openedx.core.module.db.DownloadModel @@ -97,7 +86,6 @@ import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.OpenEdXOutlinedButton import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.noRippleClickable -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 @@ -110,89 +98,6 @@ import subtitleFile.TimedTextObject import java.util.Date import org.openedx.core.R as coreR -@Composable -fun CourseImageHeader( - modifier: Modifier, - apiHostUrl: String, - courseImage: String?, - courseCertificate: Certificate?, - onCertificateClick: (String) -> Unit = {}, - courseName: String, -) { - val configuration = LocalConfiguration.current - val windowSize = rememberWindowSize() - val contentScale = - if (!windowSize.isTablet && configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) { - ContentScale.Fit - } else { - ContentScale.Crop - } - val imageUrl = if (courseImage?.isLinkValid() == true) { - courseImage - } else { - apiHostUrl.dropLast(1) + courseImage - } - Box(modifier = modifier, contentAlignment = Alignment.Center) { - AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(imageUrl) - .error(coreR.drawable.core_no_image_course) - .placeholder(coreR.drawable.core_no_image_course) - .build(), - contentDescription = stringResource( - id = coreR.string.core_accessibility_header_image_for, - courseName - ), - contentScale = contentScale, - modifier = Modifier - .fillMaxSize() - .clip(MaterialTheme.appShapes.cardShape) - ) - if (courseCertificate?.isCertificateEarned() == true) { - Column( - Modifier - .fillMaxSize() - .clip(MaterialTheme.appShapes.cardShape) - .background(MaterialTheme.appColors.certificateForeground), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Icon( - modifier = Modifier.testTag("ic_congratulations"), - painter = painterResource(id = R.drawable.ic_course_completed_mark), - contentDescription = stringResource(id = R.string.course_congratulations), - tint = Color.White - ) - Spacer(Modifier.height(6.dp)) - Text( - modifier = Modifier.testTag("txt_congratulations"), - text = stringResource(id = R.string.course_congratulations), - style = MaterialTheme.appTypography.headlineMedium, - color = Color.White - ) - Spacer(Modifier.height(4.dp)) - Text( - modifier = Modifier.testTag("txt_course_passed"), - text = stringResource(id = R.string.course_passed), - style = MaterialTheme.appTypography.bodyMedium, - color = Color.White - ) - Spacer(Modifier.height(20.dp)) - OpenEdXOutlinedButton( - modifier = Modifier, - borderColor = Color.White, - textColor = MaterialTheme.appColors.buttonText, - text = stringResource(id = R.string.course_view_certificate), - onClick = { - courseCertificate.certificateURL?.let { - onCertificateClick(it) - } - }) - } - } - } -} - @Composable fun CourseSectionCard( block: Block, @@ -377,48 +282,6 @@ fun CardArrow( ) } -@Composable -fun SequentialItem( - block: Block, - onClick: (Block) -> Unit -) { - val icon = if (block.isCompleted()) Icons.Filled.TaskAlt else Icons.Filled.Home - val iconColor = - if (block.isCompleted()) MaterialTheme.appColors.primary else MaterialTheme.appColors.onSurface - Row( - Modifier - .fillMaxWidth() - .padding( - horizontal = 20.dp, - vertical = 12.dp - ) - .clickable { onClick(block) }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Row(Modifier.weight(1f)) { - Icon( - imageVector = icon, - contentDescription = null, - tint = iconColor - ) - Spacer(modifier = Modifier.width(16.dp)) - Text( - block.displayName, - style = MaterialTheme.appTypography.titleMedium, - color = MaterialTheme.appColors.textPrimary, - overflow = TextOverflow.Ellipsis, - maxLines = 1 - ) - } - Icon( - imageVector = Icons.Filled.ChevronRight, - tint = MaterialTheme.appColors.onSurface, - contentDescription = "Expandable Arrow" - ) - } -} - @Composable fun VideoTitle( text: String, @@ -859,36 +722,6 @@ fun CourseSubSectionItem( } } -@Composable -fun CourseToolbar( - title: String, - onBackClick: () -> Unit -) { - OpenEdXTheme { - Box( - modifier = Modifier - .fillMaxWidth() - .displayCutoutForLandscape() - .zIndex(1f) - .statusBarsPadding(), - contentAlignment = Alignment.CenterStart - ) { - BackBtn { onBackClick() } - Text( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 56.dp), - text = title, - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.titleMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center - ) - } - } -} - @Composable fun CourseUnitToolbar( title: String, @@ -1207,6 +1040,49 @@ fun DatesShiftedSnackBar( } } +@Composable +fun CourseMessage( + modifier: Modifier = Modifier, + icon: Painter, + message: String, + action: String? = null, + onActionClick: () -> Unit = {} +) { + Column { + Row( + modifier + .semantics(mergeDescendants = true) {} + .noRippleClickable(onActionClick) + ) { + Icon( + painter = icon, + contentDescription = null, + modifier = Modifier.align(Alignment.CenterVertically), + tint = MaterialTheme.appColors.textPrimary + ) + Column(Modifier.padding(start = 12.dp)) { + Text( + text = message, + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.labelLarge + ) + if (action != null) { + Text( + text = action, + modifier = Modifier.padding(top = 4.dp), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.labelLarge.copy(textDecoration = TextDecoration.Underline) + ) + } + } + } + Divider( + color = MaterialTheme.appColors.divider + ) + } + +} + @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable @@ -1263,17 +1139,6 @@ private fun NavigationUnitsButtonsWithNextPreview() { } } -@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun SequentialItemPreview() { - OpenEdXTheme { - Surface(color = MaterialTheme.appColors.background) { - SequentialItem(block = mockChapterBlock, onClick = {}) - } - } -} - @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable @@ -1290,26 +1155,6 @@ private fun CourseChapterItemPreview() { } } -@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun CourseHeaderPreview() { - OpenEdXTheme { - Surface(color = MaterialTheme.appColors.background) { - CourseImageHeader( - modifier = Modifier - .fillMaxWidth() - .height(200.dp) - .padding(6.dp), - apiHostUrl = "", - courseCertificate = Certificate(""), - courseImage = "", - courseName = "" - ) - } - } -} - @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable @@ -1361,42 +1206,27 @@ private fun OfflineQueueCardPreview() { } } -private val mockCourse = EnrolledCourse( - auditAccessExpires = Date(), - created = "created", - certificate = Certificate(""), - mode = "mode", - isActive = true, - course = EnrolledCourseData( - id = "id", - name = "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 - ) -) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun CourseMessagePreview() { + OpenEdXTheme { + Surface(color = MaterialTheme.appColors.background) { + CourseMessage( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 12.dp), + icon = painterResource(R.drawable.ic_course_certificate), + message = stringResource( + R.string.course_you_earned_certificate, + "Demo Course" + ), + action = stringResource(R.string.course_view_certificate), + ) + } + } +} + private val mockChapterBlock = Block( id = "id", blockId = "blockId", diff --git a/course/src/main/res/drawable/ic_course_certificate.xml b/course/src/main/res/drawable/ic_course_certificate.xml new file mode 100644 index 000000000..53ca91779 --- /dev/null +++ b/course/src/main/res/drawable/ic_course_certificate.xml @@ -0,0 +1,9 @@ + + + diff --git a/course/src/main/res/drawable/ic_course_completed_mark.xml b/course/src/main/res/drawable/ic_course_completed_mark.xml deleted file mode 100644 index bf3307778..000000000 --- a/course/src/main/res/drawable/ic_course_completed_mark.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - diff --git a/course/src/main/res/values-uk/strings.xml b/course/src/main/res/values-uk/strings.xml index ffbf7c459..14f3487c4 100644 --- a/course/src/main/res/values-uk/strings.xml +++ b/course/src/main/res/values-uk/strings.xml @@ -5,12 +5,8 @@ Одиниці курсу Підрозділи курсу Відео - Ви успішно пройшли курс! Тепер ви можете отримати сертифікат - Ви успішно пройшли курс - Вітаємо! + Вітаємо, ви отримали сертифікат про проходження курсу \"%s\". Переглянути сертифікат - Ви можете отримати сертифікат після проходження курсу (заробіть необхідну оцінку) - Отримати сертифікат Назад Попередня одиниця Далі diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index c6b370267..802065471 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -5,12 +5,8 @@ Course units Course subsections Videos - You have passed the course! Now you can get the certificate - You’ve completed the course - Congratulations! - View the certificate - You can get a certificate after completing the course (earn required grade) - Get the certificate + Congratulations, you have earned this course certificate in \"%s\". + View certificate Prev Previous Unit Next From a712170eab2fe9f64ea547320adc92588a4a6ea4 Mon Sep 17 00:00:00 2001 From: Farhan Arshad <43750646+farhan-arshad-dev@users.noreply.github.com> Date: Thu, 23 May 2024 14:43:44 +0500 Subject: [PATCH 04/56] chore: enhance app theme capability for prod edX theme/branding (#262) chore: enhance app theme capability for prod edX theme/branding - Integrate Program config updates - theming/branding code improvements for light and dark modes - Force dark mode for the WebView (beta version) - No major change in the Open edX theme fixes: LEARNER-9783 --- app/src/main/res/layout/fragment_main.xml | 5 +- .../logistration/LogistrationFragment.kt | 5 +- .../presentation/signin/compose/SignInView.kt | 12 +++-- .../presentation/signup/compose/SignUpView.kt | 4 +- .../openedx/auth/presentation/ui/AuthUI.kt | 13 +++-- .../auth/presentation/ui/SocialAuthView.kt | 11 ++-- auth/src/main/res/values-uk/strings.xml | 2 - auth/src/main/res/values/strings.xml | 4 +- build.gradle | 2 + core/build.gradle | 2 + .../org/openedx/core/config/ProgramConfig.kt | 2 +- .../org/openedx/core/extension/ViewExt.kt | 25 +++++++++ .../dialog/appreview/AppReviewUI.kt | 4 +- .../global/app_upgrade/AppUpdateUI.kt | 6 +-- .../java/org/openedx/core/ui/ComposeCommon.kt | 28 ++++++---- .../org/openedx/core/ui/WebContentScreen.kt | 2 + .../org/openedx/core/ui/theme/AppColors.kt | 21 ++++++-- .../java/org/openedx/core/ui/theme/Theme.kt | 42 ++++++++++++--- .../java/org/openedx/core/ui/theme/Type.kt | 2 - .../org/openedx/core/ui/theme/Colors.kt | 54 ++++++++++++++----- .../presentation/ChapterEndFragmentDialog.kt | 16 +++--- .../outline/CourseOutlineScreen.kt | 4 +- .../course/presentation/ui/CourseUI.kt | 14 +++-- .../course/presentation/ui/CourseVideosUI.kt | 1 + .../unit/NotSupportedUnitFragment.kt | 4 +- .../unit/html/HtmlUnitFragment.kt | 5 ++ .../presentation/catalog/CatalogWebView.kt | 5 +- .../detail/CourseDetailsFragment.kt | 4 ++ .../threads/DiscussionThreadsFragment.kt | 8 +-- .../presentation/ui/DiscussionUI.kt | 3 +- .../presentation/edit/EditProfileFragment.kt | 26 +++++---- .../compose/ManageAccountView.kt | 2 +- .../profile/compose/ProfileView.kt | 2 +- .../whatsnew/presentation/ui/WhatsNewUI.kt | 10 ++-- 34 files changed, 246 insertions(+), 104 deletions(-) diff --git a/app/src/main/res/layout/fragment_main.xml b/app/src/main/res/layout/fragment_main.xml index eb6f37a6f..89cf2914a 100644 --- a/app/src/main/res/layout/fragment_main.xml +++ b/app/src/main/res/layout/fragment_main.xml @@ -14,11 +14,11 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> - - - \ No newline at end of file + diff --git a/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationFragment.kt b/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationFragment.kt index 738364c34..ae3d2365e 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationFragment.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationFragment.kt @@ -33,6 +33,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -131,7 +132,6 @@ private fun LogistrationScreen( LogistrationLogoView() Text( text = stringResource(id = R.string.pre_auth_title), - color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.headlineSmall, modifier = Modifier .testTag("txt_screen_title") @@ -177,7 +177,8 @@ private fun LogistrationScreen( }, text = stringResource(id = R.string.pre_auth_explore_all_courses), color = MaterialTheme.appColors.primary, - style = MaterialTheme.appTypography.labelLarge + style = MaterialTheme.appTypography.labelLarge, + textDecoration = TextDecoration.Underline ) Spacer(modifier = Modifier.weight(1f)) diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt index 77e290994..37309cadf 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt @@ -49,6 +49,7 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp @@ -195,7 +196,8 @@ internal fun LoginScreen( modifier = Modifier.testTag("txt_${state.agreement.name}"), fullText = linkedText.text, hyperLinks = linkedText.links, - linkTextColor = MaterialTheme.appColors.primary, + linkTextColor = MaterialTheme.appColors.textHyperLink, + linkTextDecoration = TextDecoration.Underline, action = { link -> onEvent(AuthEvent.OpenLink(linkedText.links, link)) }, @@ -264,7 +266,7 @@ private fun AuthForm( onEvent(AuthEvent.ForgotPasswordClick) }, text = stringResource(id = R.string.auth_forgot_password), - color = MaterialTheme.appColors.primary, + color = MaterialTheme.appColors.info_variant, style = MaterialTheme.appTypography.labelLarge ) } @@ -275,6 +277,8 @@ private fun AuthForm( OpenEdXButton( modifier = buttonWidth.testTag("btn_sign_in"), text = stringResource(id = coreR.string.core_sign_in), + textColor = MaterialTheme.appColors.primaryButtonText, + backgroundColor = MaterialTheme.appColors.secondaryButtonBackground, onClick = { onEvent(AuthEvent.SignIn(login = login, password = password)) } @@ -323,8 +327,10 @@ private fun PasswordTextField( onValueChanged(it.text.trim()) }, colors = TextFieldDefaults.outlinedTextFieldColors( + textColor = MaterialTheme.appColors.textFieldText, + backgroundColor = MaterialTheme.appColors.textFieldBackground, unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, - backgroundColor = MaterialTheme.appColors.textFieldBackground + cursorColor = MaterialTheme.appColors.textFieldText, ), shape = MaterialTheme.appShapes.textFieldShape, placeholder = { diff --git a/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt b/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt index 2e2180d83..42fd894df 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt @@ -329,7 +329,7 @@ internal fun SignUpView( modifier = Modifier .testTag("txt_sign_up_title") .fillMaxWidth(), - text = stringResource(id = R.string.auth_sign_up), + text = stringResource(id = coreR.string.core_register), color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.displaySmall ) @@ -437,6 +437,8 @@ internal fun SignUpView( OpenEdXButton( modifier = buttonWidth.testTag("btn_create_account"), text = stringResource(id = R.string.auth_create_account), + textColor = MaterialTheme.appColors.primaryButtonText, + backgroundColor = MaterialTheme.appColors.secondaryButtonBackground, onClick = { showErrorMap.clear() onRegisterClick(AuthType.PASSWORD) diff --git a/auth/src/main/java/org/openedx/auth/presentation/ui/AuthUI.kt b/auth/src/main/java/org/openedx/auth/presentation/ui/AuthUI.kt index 4f98ea50c..90fb91ee1 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/ui/AuthUI.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/ui/AuthUI.kt @@ -44,6 +44,7 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import org.openedx.auth.R @@ -170,7 +171,8 @@ fun OptionalFields( HyperlinkText( fullText = linkedText.text, hyperLinks = linkedText.links, - linkTextColor = MaterialTheme.appColors.primary, + linkTextColor = MaterialTheme.appColors.textHyperLink, + linkTextDecoration = TextDecoration.Underline, action = { hyperLinkAction?.invoke(linkedText.links, it) }, @@ -250,8 +252,10 @@ fun LoginTextField( onValueChanged(it.text.trim()) }, colors = TextFieldDefaults.outlinedTextFieldColors( + textColor = MaterialTheme.appColors.textFieldText, + backgroundColor = MaterialTheme.appColors.textFieldBackground, unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, - backgroundColor = MaterialTheme.appColors.textFieldBackground + cursorColor = MaterialTheme.appColors.textFieldText, ), shape = MaterialTheme.appShapes.textFieldShape, placeholder = { @@ -332,8 +336,11 @@ fun InputRegistrationField( } }, colors = TextFieldDefaults.outlinedTextFieldColors( + textColor = MaterialTheme.appColors.textFieldText, + backgroundColor = MaterialTheme.appColors.textFieldBackground, + focusedBorderColor = MaterialTheme.appColors.textFieldBorder, unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, - backgroundColor = MaterialTheme.appColors.textFieldBackground + cursorColor = MaterialTheme.appColors.textFieldText, ), shape = MaterialTheme.appShapes.textFieldShape, placeholder = { diff --git a/auth/src/main/java/org/openedx/auth/presentation/ui/SocialAuthView.kt b/auth/src/main/java/org/openedx/auth/presentation/ui/SocialAuthView.kt index 336c09f8f..028439290 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/ui/SocialAuthView.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/ui/SocialAuthView.kt @@ -45,7 +45,7 @@ internal fun SocialAuthView( .testTag("btn_google_auth") .padding(top = 24.dp) .fillMaxWidth(), - backgroundColor = MaterialTheme.appColors.background, + backgroundColor = MaterialTheme.appColors.authGoogleButtonBackground, borderColor = MaterialTheme.appColors.primary, textColor = Color.Unspecified, onClick = { @@ -62,7 +62,8 @@ internal fun SocialAuthView( modifier = Modifier .testTag("txt_google_auth") .padding(start = 10.dp), - text = stringResource(id = stringRes) + text = stringResource(id = stringRes), + color = MaterialTheme.appColors.primaryButtonBorderedText, ) } } @@ -87,13 +88,13 @@ internal fun SocialAuthView( Icon( painter = painterResource(id = R.drawable.ic_auth_facebook), contentDescription = null, - tint = MaterialTheme.appColors.buttonText, + tint = MaterialTheme.appColors.primaryButtonText, ) Text( modifier = Modifier .testTag("txt_facebook_auth") .padding(start = 10.dp), - color = MaterialTheme.appColors.buttonText, + color = MaterialTheme.appColors.primaryButtonText, text = stringResource(id = stringRes) ) } @@ -125,7 +126,7 @@ internal fun SocialAuthView( modifier = Modifier .testTag("txt_microsoft_auth") .padding(start = 10.dp), - color = MaterialTheme.appColors.buttonText, + color = MaterialTheme.appColors.primaryButtonText, text = stringResource(id = stringRes) ) } diff --git a/auth/src/main/res/values-uk/strings.xml b/auth/src/main/res/values-uk/strings.xml index c2c34abef..9c1a1aa69 100644 --- a/auth/src/main/res/values-uk/strings.xml +++ b/auth/src/main/res/values-uk/strings.xml @@ -5,7 +5,6 @@ Електронна пошта Неправильна E-mail адреса Пароль занадто короткий - Ласкаво просимо! Будь ласка, авторизуйтесь, щоб продовжити. Показати додаткові поля Приховати додаткові поля Створити акаунт @@ -15,6 +14,5 @@ Перевірте свою електронну пошту Ми надіслали інструкції щодо відновлення пароля на вашу електронну пошту %s Введіть пароль - Створити новий аккаунт. diff --git a/auth/src/main/res/values/strings.xml b/auth/src/main/res/values/strings.xml index 4f8ce12d8..49b3e0bbd 100644 --- a/auth/src/main/res/values/strings.xml +++ b/auth/src/main/res/values/strings.xml @@ -11,7 +11,7 @@ Email or Username Invalid email or username Password is too short - Welcome back! Please authorize to continue. + Welcome back! Sign in to access your courses. Show optional fields Hide optional fields Create account @@ -23,7 +23,7 @@ username@domain.com Enter email or username Enter password - Create new account. + Create an account to start learning today! Complete your registration Sign in with Google Sign in with Facebook diff --git a/build.gradle b/build.gradle index ef9ca662c..250f56863 100644 --- a/build.gradle +++ b/build.gradle @@ -56,6 +56,8 @@ ext { extented_spans_version = "1.3.0" + webkit_version = "1.11.0" + configHelper = new ConfigHelper(projectDir, getCurrentFlavor()) //testing diff --git a/core/build.gradle b/core/build.gradle index f1f091823..f69d633cb 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -154,6 +154,8 @@ dependencies { //Play In-App Review api "com.google.android.play:review-ktx:$in_app_review" + api "androidx.webkit:webkit:$webkit_version" + testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' diff --git a/core/src/main/java/org/openedx/core/config/ProgramConfig.kt b/core/src/main/java/org/openedx/core/config/ProgramConfig.kt index 55714dadc..c553f8997 100644 --- a/core/src/main/java/org/openedx/core/config/ProgramConfig.kt +++ b/core/src/main/java/org/openedx/core/config/ProgramConfig.kt @@ -14,7 +14,7 @@ data class ProgramConfig( } data class ProgramWebViewConfig( - @SerializedName("PROGRAM_URL") + @SerializedName("BASE_URL") val programUrl: String = "", @SerializedName("PROGRAM_DETAIL_URL_TEMPLATE") val programDetailUrlTemplate: String = "", diff --git a/core/src/main/java/org/openedx/core/extension/ViewExt.kt b/core/src/main/java/org/openedx/core/extension/ViewExt.kt index 9146a3159..ebd007d3d 100644 --- a/core/src/main/java/org/openedx/core/extension/ViewExt.kt +++ b/core/src/main/java/org/openedx/core/extension/ViewExt.kt @@ -3,12 +3,15 @@ package org.openedx.core.extension import android.content.Context import android.content.res.Resources import android.graphics.Rect +import android.os.Build import android.util.DisplayMetrics import android.view.View import android.view.ViewGroup import android.webkit.WebView import android.widget.Toast import androidx.fragment.app.DialogFragment +import androidx.webkit.WebSettingsCompat +import androidx.webkit.WebViewFeature import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.openedx.core.system.AppCookieManager @@ -61,3 +64,25 @@ fun WebView.loadUrl(url: String, scope: CoroutineScope, cookieManager: AppCookie loadUrl(url) } } + +fun WebView.applyDarkModeIfEnabled(isDarkTheme: Boolean) { + if (isDarkTheme && WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + settings.setAlgorithmicDarkeningAllowed(true) + } else { + // Switch WebView to dark mode; uses default dark theme + if (WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) { + WebSettingsCompat.setForceDark( + settings, + WebSettingsCompat.FORCE_DARK_ON + ) + } + if (WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK_STRATEGY)) { + WebSettingsCompat.setForceDarkStrategy( + settings, + WebSettingsCompat.DARK_STRATEGY_PREFER_WEB_THEME_OVER_USER_AGENT_DARKENING + ) + } + } + } +} diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/appreview/AppReviewUI.kt b/core/src/main/java/org/openedx/core/presentation/dialog/appreview/AppReviewUI.kt index e2d6a471f..b924cd543 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/appreview/AppReviewUI.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/appreview/AppReviewUI.kt @@ -320,8 +320,8 @@ fun DefaultTextButton( val textColor: Color val backgroundColor: Color if (isEnabled) { - textColor = MaterialTheme.appColors.buttonText - backgroundColor = MaterialTheme.appColors.buttonBackground + textColor = MaterialTheme.appColors.primaryButtonText + backgroundColor = MaterialTheme.appColors.primaryButtonBackground } else { textColor = MaterialTheme.appColors.inactiveButtonText backgroundColor = MaterialTheme.appColors.inactiveButtonBackground diff --git a/core/src/main/java/org/openedx/core/presentation/global/app_upgrade/AppUpdateUI.kt b/core/src/main/java/org/openedx/core/presentation/global/app_upgrade/AppUpdateUI.kt index f0502b49d..3f8dd6fa9 100644 --- a/core/src/main/java/org/openedx/core/presentation/global/app_upgrade/AppUpdateUI.kt +++ b/core/src/main/java/org/openedx/core/presentation/global/app_upgrade/AppUpdateUI.kt @@ -292,7 +292,7 @@ fun DefaultTextButton( .testTag("btn_primary") .height(42.dp), colors = ButtonDefaults.buttonColors( - backgroundColor = MaterialTheme.appColors.buttonBackground + backgroundColor = MaterialTheme.appColors.primaryButtonBackground ), elevation = null, shape = MaterialTheme.appShapes.navigationButtonShape, @@ -305,7 +305,7 @@ fun DefaultTextButton( Text( modifier = Modifier.testTag("txt_primary"), text = text, - color = MaterialTheme.appColors.buttonText, + color = MaterialTheme.appColors.primaryButtonText, style = MaterialTheme.appTypography.labelLarge ) } @@ -401,4 +401,4 @@ private fun AppUpgradeRecommendDialogPreview() { onUpdateClick = {} ) } -} \ No newline at end of file +} 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 3b97742f1..212014177 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt @@ -434,7 +434,7 @@ fun HyperlinkText( append(fullText) addStyle( style = SpanStyle( - color = MaterialTheme.appColors.textPrimary, + color = MaterialTheme.appColors.textPrimaryLight, fontSize = fontSize ), start = 0, @@ -450,7 +450,7 @@ fun HyperlinkText( color = linkTextColor, fontSize = fontSize, fontWeight = linkTextFontWeight, - textDecoration = linkTextDecoration + textDecoration = linkTextDecoration, ), start = startIndex, end = endIndex @@ -635,7 +635,8 @@ fun SheetContent( .padding(10.dp), textAlign = TextAlign.Center, style = MaterialTheme.appTypography.titleMedium, - text = title + text = title, + color = MaterialTheme.appColors.onBackground ) SearchBarStateless( modifier = Modifier @@ -667,6 +668,7 @@ fun SheetContent( onItemClick(item) } .padding(vertical = 12.dp), + color = MaterialTheme.appColors.onBackground, text = item.name, style = MaterialTheme.appTypography.bodyLarge, textAlign = TextAlign.Center @@ -1049,8 +1051,9 @@ fun OpenEdXButton( text: String = "", onClick: () -> Unit, enabled: Boolean = true, - backgroundColor: Color = MaterialTheme.appColors.buttonBackground, - content: (@Composable RowScope.() -> Unit)? = null, + textColor: Color = MaterialTheme.appColors.primaryButtonText, + backgroundColor: Color = MaterialTheme.appColors.primaryButtonBackground, + content: (@Composable RowScope.() -> Unit)? = null ) { Button( modifier = Modifier @@ -1068,7 +1071,7 @@ fun OpenEdXButton( Text( modifier = Modifier.testTag("txt_${text.tagId()}"), text = text, - color = MaterialTheme.appColors.buttonText, + color = textColor, style = MaterialTheme.appTypography.labelLarge ) } else { @@ -1084,6 +1087,7 @@ fun OpenEdXOutlinedButton( borderColor: Color, textColor: Color, text: String = "", + enabled: Boolean = true, onClick: () -> Unit, content: (@Composable RowScope.() -> Unit)? = null, ) { @@ -1093,6 +1097,7 @@ fun OpenEdXOutlinedButton( .then(modifier) .height(42.dp), onClick = onClick, + enabled = enabled, border = BorderStroke(1.dp, borderColor), shape = MaterialTheme.appShapes.buttonShape, colors = ButtonDefaults.outlinedButtonColors(backgroundColor = backgroundColor) @@ -1163,7 +1168,9 @@ fun ConnectionErrorView( modifier = Modifier .widthIn(Dp.Unspecified, 162.dp), text = stringResource(id = R.string.core_reload), - onClick = onReloadClick + textColor = MaterialTheme.appColors.primaryButtonText, + backgroundColor = MaterialTheme.appColors.secondaryButtonBackground, + onClick = onReloadClick, ) } } @@ -1180,6 +1187,8 @@ fun AuthButtonsPanel( .width(0.dp) .weight(1f), text = stringResource(id = R.string.core_register), + textColor = MaterialTheme.appColors.primaryButtonText, + backgroundColor = MaterialTheme.appColors.secondaryButtonBackground, onClick = { onRegisterClick() } ) @@ -1190,8 +1199,9 @@ fun AuthButtonsPanel( .padding(start = 16.dp), text = stringResource(id = R.string.core_sign_in), onClick = { onSignInClick() }, - borderColor = MaterialTheme.appColors.textFieldBorder, - textColor = MaterialTheme.appColors.primary + textColor = MaterialTheme.appColors.secondaryButtonBorderedText, + backgroundColor = MaterialTheme.appColors.secondaryButtonBorderedBackground, + borderColor = MaterialTheme.appColors.secondaryButtonBorder, ) } } diff --git a/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt b/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt index c9c7c4ba1..06aa70ea2 100644 --- a/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt +++ b/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.zIndex +import org.openedx.core.extension.applyDarkModeIfEnabled import org.openedx.core.extension.isEmailValid import org.openedx.core.extension.replaceLinkTags import org.openedx.core.ui.theme.appColors @@ -195,6 +196,7 @@ private fun WebViewContent( contentUrl?.let { loadUrl(it) } + applyDarkModeIfEnabled(isDarkTheme) } }, update = { webView -> diff --git a/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt b/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt index 4b7a0ba10..968fd9fe3 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt @@ -8,6 +8,8 @@ data class AppColors( val textPrimary: Color, val textPrimaryVariant: Color, + val textPrimaryLight: Color, + val textHyperLink: Color, val textSecondary: Color, val textDark: Color, val textAccent: Color, @@ -19,9 +21,18 @@ data class AppColors( val textFieldText: Color, val textFieldHint: Color, - val buttonBackground: Color, - val buttonSecondaryBackground: Color, - val buttonText: Color, + val primaryButtonBackground: Color, + val primaryButtonText: Color, + val primaryButtonBorder: Color, + val primaryButtonBorderedText: Color, + + // The default secondary button styling is identical to the primary button styling. + // However, you can customize it if your brand utilizes two accent colors. + val secondaryButtonBackground: Color, + val secondaryButtonText: Color, + val secondaryButtonBorder: Color, + val secondaryButtonBorderedBackground: Color, + val secondaryButtonBorderedText: Color, val cardViewBackground: Color, val cardViewBorder: Color, @@ -31,6 +42,9 @@ data class AppColors( val bottomSheetToggle: Color, val warning: Color, val info: Color, + val info_variant: Color, + val onWarning: Color, + val onInfo: Color, val rateStars: Color, val inactiveButtonBackground: Color, @@ -44,6 +58,7 @@ data class AppColors( val datesSectionBarNextWeek: Color, val datesSectionBarUpcoming: Color, + val authGoogleButtonBackground: Color, val authFacebookButtonBackground: Color, val authMicrosoftButtonBackground: Color, diff --git a/core/src/main/java/org/openedx/core/ui/theme/Theme.kt b/core/src/main/java/org/openedx/core/ui/theme/Theme.kt index 1ffa3c73d..291192c1c 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/Theme.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/Theme.kt @@ -27,10 +27,12 @@ private val DarkColorPalette = AppColors( ), textPrimary = dark_text_primary, textPrimaryVariant = dark_text_primary_variant, + textPrimaryLight = dark_text_primary_light, textSecondary = dark_text_secondary, textDark = dark_text_dark, textAccent = dark_text_accent, textWarning = dark_text_warning, + textHyperLink = dark_text_hyper_link, textFieldBackground = dark_text_field_background, textFieldBackgroundVariant = dark_text_field_background_variant, @@ -38,9 +40,16 @@ private val DarkColorPalette = AppColors( textFieldText = dark_text_field_text, textFieldHint = dark_text_field_hint, - buttonBackground = dark_button_background, - buttonSecondaryBackground = dark_button_secondary_background, - buttonText = dark_button_text, + primaryButtonBackground = dark_primary_button_background, + primaryButtonText = dark_primary_button_text, + primaryButtonBorder = dark_primary_button_border, + primaryButtonBorderedText = dark_primary_button_bordered_text, + + secondaryButtonBackground = dark_secondary_button_background, + secondaryButtonText = dark_secondary_button_text, + secondaryButtonBorder = dark_secondary_button_border, + secondaryButtonBorderedBackground = dark_secondary_button_bordered_background, + secondaryButtonBorderedText = dark_secondary_button_bordered_text, cardViewBackground = dark_card_view_background, cardViewBorder = dark_card_view_border, @@ -51,10 +60,13 @@ private val DarkColorPalette = AppColors( warning = dark_warning, info = dark_info, + info_variant = dark_info_variant, + onWarning = dark_onWarning, + onInfo = dark_onInfo, rateStars = dark_rate_stars, inactiveButtonBackground = dark_inactive_button_background, - inactiveButtonText = dark_button_text, + inactiveButtonText = dark_primary_button_text, accessGreen = dark_access_green, @@ -64,6 +76,7 @@ private val DarkColorPalette = AppColors( datesSectionBarNextWeek = dark_dates_section_bar_next_week, datesSectionBarUpcoming = dark_dates_section_bar_upcoming, + authGoogleButtonBackground = dark_auth_google_button_background, authFacebookButtonBackground = dark_auth_facebook_button_background, authMicrosoftButtonBackground = dark_auth_microsoft_button_background, @@ -98,10 +111,12 @@ private val LightColorPalette = AppColors( ), textPrimary = light_text_primary, textPrimaryVariant = light_text_primary_variant, + textPrimaryLight = light_text_primary_light, textSecondary = light_text_secondary, textDark = light_text_dark, textAccent = light_text_accent, textWarning = light_text_warning, + textHyperLink = light_text_hyper_link, textFieldBackground = light_text_field_background, textFieldBackgroundVariant = light_text_field_background_variant, @@ -109,9 +124,16 @@ private val LightColorPalette = AppColors( textFieldText = light_text_field_text, textFieldHint = light_text_field_hint, - buttonBackground = light_button_background, - buttonSecondaryBackground = light_button_secondary_background, - buttonText = light_button_text, + primaryButtonBackground = light_primary_button_background, + primaryButtonText = light_primary_button_text, + primaryButtonBorder = light_primary_button_border, + primaryButtonBorderedText = light_primary_button_bordered_text, + + secondaryButtonBackground = light_secondary_button_background, + secondaryButtonText = light_secondary_button_text, + secondaryButtonBorder = light_secondary_button_border, + secondaryButtonBorderedBackground = light_secondary_button_bordered_background, + secondaryButtonBorderedText = light_secondary_button_bordered_text, cardViewBackground = light_card_view_background, cardViewBorder = light_card_view_border, @@ -122,10 +144,13 @@ private val LightColorPalette = AppColors( warning = light_warning, info = light_info, + info_variant = light_info_variant, + onWarning = light_onWarning, + onInfo = light_onInfo, rateStars = light_rate_stars, inactiveButtonBackground = light_inactive_button_background, - inactiveButtonText = light_button_text, + inactiveButtonText = light_primary_button_text, accessGreen = light_access_green, @@ -135,6 +160,7 @@ private val LightColorPalette = AppColors( datesSectionBarNextWeek = light_dates_section_bar_next_week, datesSectionBarUpcoming = light_dates_section_bar_upcoming, + authGoogleButtonBackground = light_auth_google_button_background, authFacebookButtonBackground = light_auth_facebook_button_background, authMicrosoftButtonBackground = light_auth_microsoft_button_background, 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 edd2afcc7..0160196f9 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 @@ -34,7 +34,6 @@ data class AppTypography( val fontFamily = FontFamily( Font(R.font.regular, FontWeight.Black, FontStyle.Normal), Font(R.font.bold, FontWeight.Bold, FontStyle.Normal), - Font(R.font.bold, FontWeight.Bold, FontStyle.Normal), Font(R.font.extra_light, FontWeight.Light, FontStyle.Normal), Font(R.font.light, FontWeight.Light, FontStyle.Normal), Font(R.font.medium, FontWeight.Medium, FontStyle.Normal), @@ -43,7 +42,6 @@ val fontFamily = FontFamily( Font(R.font.thin, FontWeight.Thin, FontStyle.Normal), ) - internal val LocalTypography = staticCompositionLocalOf { AppTypography( displayLarge = TextStyle( diff --git a/core/src/openedx/org/openedx/core/ui/theme/Colors.kt b/core/src/openedx/org/openedx/core/ui/theme/Colors.kt index 1cc4c3495..0bb1114c8 100644 --- a/core/src/openedx/org/openedx/core/ui/theme/Colors.kt +++ b/core/src/openedx/org/openedx/core/ui/theme/Colors.kt @@ -10,24 +10,38 @@ val light_background = Color.White val light_surface = Color(0xFFF7F7F8) val light_error = Color(0xFFFF3D71) val light_onPrimary = Color.White -val light_onSecondary = Color.Black +val light_onSecondary = Color.White val light_onBackground = Color.Black val light_onSurface = Color.Black val light_onError = Color.White +val light_onWarning = Color.White +val light_onInfo = Color.White +val light_info_variant = Color(0xFF3C68FF) val light_text_primary = Color(0xFF212121) val light_text_primary_variant = Color(0xFF3D4964) +val light_text_primary_light = light_text_primary val light_text_secondary = Color(0xFFB3B3B3) val light_text_dark = Color(0xFF19212F) val light_text_accent = Color(0xFF3C68FF) -val light_text_warning= Color(0xFF19212F) +val light_text_warning = Color(0xFF19212F) val light_text_field_background = Color(0xFFF7F7F8) val light_text_field_background_variant = Color.White val light_text_field_border = Color(0xFF97A5BB) val light_text_field_text = Color(0xFF3D4964) val light_text_field_hint = Color(0xFF97A5BB) -val light_button_background = Color(0xFF3C68FF) -val light_button_secondary_background = Color(0xFF79889F) -val light_button_text = Color.White +val light_text_hyper_link = Color(0xFF3C68FF) + +val light_primary_button_background = Color(0xFF3C68FF) +val light_primary_button_border = Color(0xFF97A5BB) +val light_primary_button_text = Color.White +val light_primary_button_bordered_text = Color(0xFF3C68FF) + +val light_secondary_button_background = light_primary_button_background +val light_secondary_button_text = light_primary_button_text +val light_secondary_button_border = light_primary_button_border +val light_secondary_button_bordered_background = Color.White +val light_secondary_button_bordered_text = light_primary_button_bordered_text + val light_card_view_background = Color(0xFFF9FAFB) val light_card_view_border = Color(0xFFCCD4E0) val light_divider = Color(0xFFCCD4E0) @@ -37,13 +51,13 @@ val light_warning = Color(0xFFFFC94D) val light_info = Color(0xFF42AAFF) val light_rate_stars = Color(0xFFFFC94D) val light_inactive_button_background = Color(0xFFCCD4E0) -val light_inactive_button_text = Color(0xFF3D4964) val light_access_green = Color(0xFF23BCA0) val light_dates_section_bar_past_due = light_warning val light_dates_section_bar_today = light_info val light_dates_section_bar_this_week = light_text_primary_variant val light_dates_section_bar_next_week = light_text_field_border val light_dates_section_bar_upcoming = Color(0xFFCCD4E0) +val light_auth_google_button_background = Color.White val light_auth_facebook_button_background = Color(0xFF0866FF) val light_auth_microsoft_button_background = Color(0xFA000000) val light_component_horizontal_progress_completed_and_selected = Color(0xFF30a171) @@ -66,40 +80,54 @@ val dark_background = Color(0xFF19212F) val dark_surface = Color(0xFF273346) val dark_error = Color(0xFFFF3D71) val dark_onPrimary = Color.Black -val dark_onSecondary = Color.Black +val dark_onSecondary = Color.White val dark_onBackground = Color.White val dark_onSurface = Color.White val dark_onError = Color.Black val dark_text_primary = Color.White -val dark_text_primary_variant = Color(0xFF79889F) +val dark_text_primary_light = dark_text_primary +val dark_text_primary_variant = Color.White val dark_text_secondary = Color(0xFFB3B3B3) val dark_text_dark = Color.White val dark_text_accent = Color(0xFF879FF5) -val dark_text_warning= Color(0xFF19212F) +val dark_text_warning = Color(0xFF19212F) val dark_text_field_background = Color(0xFF273346) val dark_text_field_background_variant = Color(0xFF273346) val dark_text_field_border = Color(0xFF4E5A70) val dark_text_field_text = Color.White val dark_text_field_hint = Color(0xFF79889F) -val dark_button_background = Color(0xFF5478F9) -val dark_button_secondary_background = Color(0xFF79889F) -val dark_button_text = Color.White +val dark_text_hyper_link = Color(0xFF5478F9) + +val dark_primary_button_background = Color(0xFF5478F9) +val dark_primary_button_text = Color.White +val dark_primary_button_border = Color(0xFF4E5A70) +val dark_primary_button_bordered_text = Color(0xFF5478F9) + +val dark_secondary_button_background = dark_primary_button_background +val dark_secondary_button_text = dark_primary_button_text +val dark_secondary_button_border = dark_primary_button_border +val dark_secondary_button_bordered_background = Color(0xFF19212F) +val dark_secondary_button_bordered_text = dark_primary_button_bordered_text + val dark_card_view_background = Color(0xFF273346) val dark_card_view_border = Color(0xFF4E5A70) val dark_divider = Color(0xFF4E5A70) val dark_certificate_foreground = Color(0xD92EB865) val dark_bottom_sheet_toggle = Color(0xFF4E5A70) val dark_warning = Color(0xFFFFC248) +val dark_onWarning = Color.White val dark_info = Color(0xFF0095FF) +val dark_info_variant = Color(0xFF5478F9) +val dark_onInfo = Color.White val dark_rate_stars = Color(0xFFFFC94D) val dark_inactive_button_background = Color(0xFFCCD4E0) -val dark_inactive_button_text = Color(0xFF3D4964) val dark_access_green = Color(0xFF23BCA0) val dark_dates_section_bar_past_due = dark_warning val dark_dates_section_bar_today = dark_info val dark_dates_section_bar_this_week = dark_text_primary_variant val dark_dates_section_bar_next_week = dark_text_field_border val dark_dates_section_bar_upcoming = Color(0xFFCCD4E0) +val dark_auth_google_button_background = Color(0xFF19212F) val dark_auth_facebook_button_background = Color(0xFF0866FF) val dark_auth_microsoft_button_background = Color(0xFA000000) val dark_component_horizontal_progress_completed_and_selected = Color(0xFF30a171) diff --git a/course/src/main/java/org/openedx/course/presentation/ChapterEndFragmentDialog.kt b/course/src/main/java/org/openedx/course/presentation/ChapterEndFragmentDialog.kt index e84766780..c416aa497 100644 --- a/course/src/main/java/org/openedx/course/presentation/ChapterEndFragmentDialog.kt +++ b/course/src/main/java/org/openedx/course/presentation/ChapterEndFragmentDialog.kt @@ -209,7 +209,7 @@ private fun ChapterEndDialogScreen( TextIcon( text = stringResource(id = R.string.course_next_section), painter = painterResource(org.openedx.core.R.drawable.core_ic_forward), - color = MaterialTheme.appColors.buttonText, + color = MaterialTheme.appColors.primaryButtonText, textStyle = MaterialTheme.appTypography.labelLarge, iconModifier = Modifier.rotate(if (isVerticalNavigation) 90f else 0f) ) @@ -219,15 +219,15 @@ private fun ChapterEndDialogScreen( Spacer(Modifier.height(16.dp)) } OpenEdXOutlinedButton( - borderColor = MaterialTheme.appColors.buttonBackground, - textColor = MaterialTheme.appColors.buttonBackground, + borderColor = MaterialTheme.appColors.primaryButtonBackground, + textColor = MaterialTheme.appColors.primaryButtonBackground, text = stringResource(id = R.string.course_back_to_outline), onClick = onBackButtonClick, content = { AutoSizeText( text = stringResource(id = R.string.course_back_to_outline), style = MaterialTheme.appTypography.bodyMedium, - color = MaterialTheme.appColors.buttonBackground + color = MaterialTheme.appColors.primaryButtonBorderedText ) } ) @@ -326,7 +326,7 @@ private fun ChapterEndDialogScreenLandscape( TextIcon( text = stringResource(id = R.string.course_next_section), painter = painterResource(org.openedx.core.R.drawable.core_ic_forward), - color = MaterialTheme.appColors.buttonText, + color = MaterialTheme.appColors.primaryButtonText, textStyle = MaterialTheme.appTypography.labelLarge ) }, @@ -335,15 +335,15 @@ private fun ChapterEndDialogScreenLandscape( Spacer(Modifier.height(16.dp)) } OpenEdXOutlinedButton( - borderColor = MaterialTheme.appColors.buttonBackground, - textColor = MaterialTheme.appColors.buttonBackground, + borderColor = MaterialTheme.appColors.primaryButtonBackground, + textColor = MaterialTheme.appColors.primaryButtonBackground, text = stringResource(id = R.string.course_back_to_outline), onClick = onBackButtonClick, content = { AutoSizeText( text = stringResource(id = R.string.course_back_to_outline), style = MaterialTheme.appTypography.bodyMedium, - color = MaterialTheme.appColors.buttonBackground + color = MaterialTheme.appColors.primaryButtonBorderedText ) } ) 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 6b9f9626d..3bdc9e622 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 @@ -440,7 +440,7 @@ private fun ResumeCourse( TextIcon( text = stringResource(id = R.string.course_resume), painter = painterResource(id = CoreR.drawable.core_ic_forward), - color = MaterialTheme.appColors.buttonText, + color = MaterialTheme.appColors.primaryButtonText, textStyle = MaterialTheme.appTypography.labelLarge ) } @@ -499,7 +499,7 @@ private fun ResumeCourseTablet( TextIcon( text = stringResource(id = R.string.course_resume), painter = painterResource(id = CoreR.drawable.core_ic_forward), - color = MaterialTheme.appColors.buttonText, + color = MaterialTheme.appColors.primaryButtonText, textStyle = MaterialTheme.appTypography.labelLarge ) } 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 50d569308..011003ede 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 @@ -339,7 +339,7 @@ fun NavigationUnitsButtons( colors = ButtonDefaults.outlinedButtonColors( backgroundColor = MaterialTheme.appColors.background ), - border = BorderStroke(1.dp, MaterialTheme.appColors.primary), + border = BorderStroke(1.dp, MaterialTheme.appColors.primaryButtonBorder), elevation = null, shape = MaterialTheme.appShapes.navigationButtonShape, onClick = onPrevClick, @@ -368,7 +368,7 @@ fun NavigationUnitsButtons( modifier = Modifier .height(42.dp), colors = ButtonDefaults.buttonColors( - backgroundColor = MaterialTheme.appColors.buttonBackground + backgroundColor = MaterialTheme.appColors.primaryButtonBackground ), elevation = null, shape = MaterialTheme.appShapes.navigationButtonShape, @@ -380,7 +380,7 @@ fun NavigationUnitsButtons( ) { Text( text = nextButtonText, - color = MaterialTheme.appColors.buttonText, + color = MaterialTheme.appColors.primaryButtonText, style = MaterialTheme.appTypography.labelLarge ) Spacer(Modifier.width(8.dp)) @@ -388,7 +388,7 @@ fun NavigationUnitsButtons( modifier = Modifier.rotate(if (isVerticalNavigation || !hasNextBlock) 0f else -90f), painter = nextButtonIcon, contentDescription = null, - tint = MaterialTheme.appColors.buttonText + tint = MaterialTheme.appColors.primaryButtonText ) } } @@ -516,7 +516,11 @@ fun VideoSubtitles( val scaffoldState = rememberScaffoldState() val subtitles = timedTextObject.captions.values.toList() Scaffold(scaffoldState = scaffoldState) { - Column(Modifier.padding(it)) { + Column( + modifier = Modifier + .padding(it) + .background(color = MaterialTheme.appColors.background) + ) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, 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 c69e26c0d..16fd90992 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 @@ -630,6 +630,7 @@ private fun AllVideosDownloadItem( } }, colors = SwitchDefaults.colors( + uncheckedThumbColor = MaterialTheme.appColors.primary, checkedThumbColor = MaterialTheme.appColors.primary, checkedTrackColor = MaterialTheme.appColors.primary ) diff --git a/course/src/main/java/org/openedx/course/presentation/unit/NotSupportedUnitFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/NotSupportedUnitFragment.kt index bdf5dcd8b..0aaae4a3c 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/NotSupportedUnitFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/NotSupportedUnitFragment.kt @@ -139,14 +139,14 @@ private fun NotSupportedUnitScreen( .height(42.dp), shape = MaterialTheme.appShapes.buttonShape, colors = ButtonDefaults.buttonColors( - backgroundColor = MaterialTheme.appColors.buttonBackground + backgroundColor = MaterialTheme.appColors.primaryButtonBackground ), onClick = { uriHandler.openUri(uri) }) { Text( text = stringResource(id = courseR.string.course_open_in_browser), - color = MaterialTheme.appColors.buttonText, + color = MaterialTheme.appColors.primaryButtonText, style = MaterialTheme.appTypography.labelLarge ) } diff --git a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt index 3a49e0e4b..392fa07fa 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt @@ -49,6 +49,7 @@ import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import kotlinx.coroutines.launch import org.koin.androidx.viewmodel.ext.android.viewModel +import org.openedx.core.extension.applyDarkModeIfEnabled import org.openedx.core.extension.isEmailValid import org.openedx.core.extension.loadUrl import org.openedx.core.system.AppCookieManager @@ -212,6 +213,8 @@ private fun HTMLContentView( ) } + val isDarkTheme = isSystemInDarkTheme() + AndroidView( modifier = Modifier .then(screenWidth) @@ -287,11 +290,13 @@ private fun HTMLContentView( setSupportZoom(true) loadsImagesAutomatically = true domStorageEnabled = true + } isVerticalScrollBarEnabled = false isHorizontalScrollBarEnabled = false loadUrl(url, coroutineScope, cookieManager) + applyDarkModeIfEnabled(isDarkTheme) } }, update = { webView -> diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/catalog/CatalogWebView.kt b/discovery/src/main/java/org/openedx/discovery/presentation/catalog/CatalogWebView.kt index 42531f8a0..373516b0a 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/catalog/CatalogWebView.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/catalog/CatalogWebView.kt @@ -3,9 +3,11 @@ package org.openedx.discovery.presentation.catalog import android.annotation.SuppressLint import android.webkit.WebResourceRequest import android.webkit.WebView +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext +import org.openedx.core.extension.applyDarkModeIfEnabled import org.openedx.discovery.presentation.catalog.WebViewLink.Authority as linkAuthority @SuppressLint("SetJavaScriptEnabled", "ComposableNaming") @@ -20,7 +22,7 @@ fun CatalogWebViewScreen( onUriClick: (String, linkAuthority) -> Unit, ): WebView { val context = LocalContext.current - + val isDarkTheme = isSystemInDarkTheme() return remember { WebView(context).apply { webViewClient = object : DefaultWebViewClient( @@ -93,6 +95,7 @@ fun CatalogWebViewScreen( isHorizontalScrollBarEnabled = false loadUrl(url) + applyDarkModeIfEnabled(isDarkTheme) } } } diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt index 813994307..0060199da 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt @@ -14,6 +14,7 @@ import android.webkit.WebResourceRequest import android.webkit.WebView import android.webkit.WebViewClient import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -80,6 +81,7 @@ import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.core.UIMessage import org.openedx.core.domain.model.Media +import org.openedx.core.extension.applyDarkModeIfEnabled import org.openedx.core.extension.isEmailValid import org.openedx.core.ui.AuthButtonsPanel import org.openedx.core.ui.HandleUIMessage @@ -625,6 +627,7 @@ private fun CourseDescription( onWebPageLoaded: () -> Unit ) { val context = LocalContext.current + val isDarkTheme = isSystemInDarkTheme() AndroidView(modifier = Modifier.then(modifier), factory = { WebView(context).apply { webViewClient = object : WebViewClient() { @@ -674,6 +677,7 @@ private fun CourseDescription( StandardCharsets.UTF_8.name(), null ) + applyDarkModeIfEnabled(isDarkTheme) } }) } diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsFragment.kt b/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsFragment.kt index 99bf4f26e..34a08ebb2 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsFragment.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsFragment.kt @@ -393,7 +393,7 @@ private fun DiscussionThreadsScreen( text = filterType.first, painter = painterResource(id = discussionR.drawable.discussion_ic_filter), textStyle = MaterialTheme.appTypography.labelMedium, - color = MaterialTheme.appColors.textAccent, + color = MaterialTheme.appColors.textPrimary, onClick = { currentSelectedList = FilterType.type expandedList = listOf( @@ -423,7 +423,7 @@ private fun DiscussionThreadsScreen( text = sortType.first, painter = painterResource(id = discussionR.drawable.discussion_ic_sort), textStyle = MaterialTheme.appTypography.labelMedium, - color = MaterialTheme.appColors.textAccent, + color = MaterialTheme.appColors.textPrimary, onClick = { currentSelectedList = SortType.type expandedList = listOf( @@ -475,7 +475,7 @@ private fun DiscussionThreadsScreen( Modifier .size(40.dp) .clip(CircleShape) - .background(MaterialTheme.appColors.primary) + .background(MaterialTheme.appColors.secondaryButtonBackground) .clickable { onCreatePostClick() }, @@ -485,7 +485,7 @@ private fun DiscussionThreadsScreen( modifier = Modifier.size(16.dp), painter = painterResource(id = discussionR.drawable.discussion_ic_add_comment), contentDescription = stringResource(id = discussionR.string.discussion_add_comment), - tint = MaterialTheme.appColors.buttonText + tint = MaterialTheme.appColors.primaryButtonText ) } } diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/ui/DiscussionUI.kt b/discussion/src/main/java/org/openedx/discussion/presentation/ui/DiscussionUI.kt index 7d2242850..cd87e0498 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/ui/DiscussionUI.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/ui/DiscussionUI.kt @@ -587,11 +587,10 @@ fun ThreadItem( thread.commentCount - 1 ), painter = painterResource(id = R.drawable.discussion_ic_responses), - color = MaterialTheme.appColors.textAccent, + color = MaterialTheme.appColors.textPrimary, textStyle = MaterialTheme.appTypography.labelLarge ) } - } diff --git a/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileFragment.kt index 907b3942a..5fc9e9a78 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileFragment.kt @@ -648,7 +648,7 @@ private fun EditProfileScreen( }, painter = painterResource(id = R.drawable.profile_ic_edit_image), contentDescription = null, - tint = Color.White + tint = MaterialTheme.appColors.onPrimary ) } Spacer(modifier = Modifier.height(20.dp)) @@ -949,10 +949,12 @@ private fun SelectableField( ) } else { TextFieldDefaults.outlinedTextFieldColors( + textColor = MaterialTheme.appColors.textFieldText, + backgroundColor = MaterialTheme.appColors.textFieldBackground, unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, + cursorColor = MaterialTheme.appColors.textFieldText, disabledBorderColor = MaterialTheme.appColors.textFieldBorder, - disabledTextColor = MaterialTheme.appColors.textPrimary, - backgroundColor = MaterialTheme.appColors.textFieldBackground, + disabledTextColor = MaterialTheme.appColors.textFieldHint, disabledPlaceholderColor = MaterialTheme.appColors.textFieldHint ) } @@ -991,7 +993,7 @@ private fun SelectableField( Text( modifier = Modifier.testTag("txt_placeholder_${name.tagId()}"), text = name, - color = MaterialTheme.appColors.textFieldHint, + color = MaterialTheme.appColors.textFieldText, style = MaterialTheme.appTypography.bodyMedium ) } @@ -1029,8 +1031,10 @@ private fun InputEditField( onValueChanged(it) }, colors = TextFieldDefaults.outlinedTextFieldColors( + textColor = MaterialTheme.appColors.textFieldText, + backgroundColor = MaterialTheme.appColors.textFieldBackground, unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, - backgroundColor = MaterialTheme.appColors.textFieldBackground + cursorColor = MaterialTheme.appColors.textFieldText, ), shape = MaterialTheme.appShapes.textFieldShape, placeholder = { @@ -1116,14 +1120,14 @@ private fun LeaveProfile( OpenEdXButton( text = stringResource(id = R.string.profile_leave), onClick = onLeaveClick, - backgroundColor = MaterialTheme.appColors.warning, + backgroundColor = MaterialTheme.appColors.primary, content = { Text( modifier = Modifier .testTag("txt_leave") .fillMaxWidth(), text = stringResource(id = R.string.profile_leave), - color = MaterialTheme.appColors.textWarning, + color = MaterialTheme.appColors.primaryButtonText, style = MaterialTheme.appTypography.labelLarge, textAlign = TextAlign.Center ) @@ -1131,7 +1135,7 @@ private fun LeaveProfile( ) Spacer(Modifier.height(24.dp)) OpenEdXOutlinedButton( - borderColor = MaterialTheme.appColors.textPrimary, + borderColor = MaterialTheme.appColors.textFieldBorder, textColor = MaterialTheme.appColors.textPrimary, text = stringResource(id = R.string.profile_keep_editing), onClick = onDismissRequest @@ -1208,20 +1212,20 @@ private fun LeaveProfileLandscape( ) { OpenEdXButton( text = stringResource(id = R.string.profile_leave), - backgroundColor = MaterialTheme.appColors.warning, + backgroundColor = MaterialTheme.appColors.primary, content = { AutoSizeText( modifier = Modifier.testTag("txt_leave_profile_dialog_leave"), text = stringResource(id = R.string.profile_leave), style = MaterialTheme.appTypography.bodyMedium, - color = MaterialTheme.appColors.textDark + color = MaterialTheme.appColors.primaryButtonText ) }, onClick = onLeaveClick ) Spacer(Modifier.height(16.dp)) OpenEdXOutlinedButton( - borderColor = MaterialTheme.appColors.textPrimary, + borderColor = MaterialTheme.appColors.textFieldBorder, textColor = MaterialTheme.appColors.textPrimary, text = stringResource(id = R.string.profile_keep_editing), onClick = onDismissRequest, diff --git a/profile/src/main/java/org/openedx/profile/presentation/manageaccount/compose/ManageAccountView.kt b/profile/src/main/java/org/openedx/profile/presentation/manageaccount/compose/ManageAccountView.kt index 42ff5afef..970ff2f91 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/manageaccount/compose/ManageAccountView.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/manageaccount/compose/ManageAccountView.kt @@ -174,7 +174,7 @@ internal fun ManageAccountView( onClick = { onAction(ManageAccountViewAction.EditAccountClick) }, - borderColor = MaterialTheme.appColors.buttonBackground, + borderColor = MaterialTheme.appColors.primaryButtonBackground, textColor = MaterialTheme.appColors.textAccent ) Spacer(modifier = Modifier.height(12.dp)) diff --git a/profile/src/main/java/org/openedx/profile/presentation/profile/compose/ProfileView.kt b/profile/src/main/java/org/openedx/profile/presentation/profile/compose/ProfileView.kt index bec24967f..411ac156d 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/profile/compose/ProfileView.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/profile/compose/ProfileView.kt @@ -149,7 +149,7 @@ internal fun ProfileView( onClick = { onAction(ProfileViewAction.EditAccountClick) }, - borderColor = MaterialTheme.appColors.buttonBackground, + borderColor = MaterialTheme.appColors.primaryButtonBackground, textColor = MaterialTheme.appColors.textAccent ) Spacer(modifier = Modifier.height(12.dp)) diff --git a/whatsnew/src/main/java/org/openedx/whatsnew/presentation/ui/WhatsNewUI.kt b/whatsnew/src/main/java/org/openedx/whatsnew/presentation/ui/WhatsNewUI.kt index a76ff9a10..7d97eee40 100644 --- a/whatsnew/src/main/java/org/openedx/whatsnew/presentation/ui/WhatsNewUI.kt +++ b/whatsnew/src/main/java/org/openedx/whatsnew/presentation/ui/WhatsNewUI.kt @@ -210,7 +210,7 @@ fun NextFinishButton( .testTag("btn_next") .height(42.dp), colors = ButtonDefaults.buttonColors( - backgroundColor = MaterialTheme.appColors.buttonBackground + backgroundColor = MaterialTheme.appColors.primaryButtonBackground ), elevation = null, shape = MaterialTheme.appShapes.navigationButtonShape, @@ -231,14 +231,14 @@ fun NextFinishButton( Text( modifier = Modifier.testTag("txt_next"), text = stringResource(id = R.string.whats_new_navigation_next), - color = MaterialTheme.appColors.buttonText, + color = MaterialTheme.appColors.primaryButtonText, style = MaterialTheme.appTypography.labelLarge ) Spacer(Modifier.width(8.dp)) Icon( painter = painterResource(id = org.openedx.core.R.drawable.core_ic_forward), contentDescription = null, - tint = MaterialTheme.appColors.buttonText + tint = MaterialTheme.appColors.primaryButtonText ) } } else { @@ -249,14 +249,14 @@ fun NextFinishButton( Text( modifier = Modifier.testTag("txt_done"), text = stringResource(id = R.string.whats_new_navigation_done), - color = MaterialTheme.appColors.buttonText, + color = MaterialTheme.appColors.primaryButtonText, style = MaterialTheme.appTypography.labelLarge ) Spacer(Modifier.width(8.dp)) Icon( painter = painterResource(id = org.openedx.core.R.drawable.core_ic_check), contentDescription = null, - tint = MaterialTheme.appColors.buttonText + tint = MaterialTheme.appColors.primaryButtonText ) } } From 0d093ea004e11462c345243935360502c82ac365 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk <141041606+PavloNetrebchuk@users.noreply.github.com> Date: Wed, 29 May 2024 13:03:12 +0300 Subject: [PATCH 05/56] feat: [FC-0047] Calendar main screen and dialogs (#322) * feat: Created calendar setting screen * feat: CalendarAccessDialog * feat: NewCalendarDialog * fix: Fixes according to PR feedback --- app/src/main/AndroidManifest.xml | 2 + .../main/java/org/openedx/app/AppRouter.kt | 9 +- .../main/java/org/openedx/app/di/AppModule.kt | 2 +- .../java/org/openedx/app/di/ScreenModule.kt | 4 +- .../java/org/openedx/core/config/Config.kt | 5 + .../core/presentation/dialog/DialogUI.kt | 56 +++ .../dialog/appreview/AppReviewUI.kt | 51 +-- .../calendarsync/CalendarSyncDialog.kt | 6 +- .../calendarsync/CalendarSyncDialogType.kt | 44 ++ .../calendarsync/CalendarSyncUIState.kt | 2 +- .../calendarsync/DialogProperties.kt | 2 +- .../{ => video}/VideoQualityFragment.kt | 2 +- .../settings/{ => video}/VideoQualityType.kt | 2 +- .../{ => video}/VideoQualityViewModel.kt | 2 +- .../openedx/core/system}/CalendarManager.kt | 7 +- .../java/org/openedx/core/ui/ComposeCommon.kt | 3 +- .../org/openedx/core/ui/ComposeExtensions.kt | 19 +- core/src/main/res/values/strings.xml | 35 ++ course/src/main/AndroidManifest.xml | 5 - .../course/presentation/CourseRouter.kt | 2 +- .../calendarsync/CalendarSyncDialogType.kt | 45 -- .../container/CourseContainerFragment.kt | 4 +- .../container/CourseContainerViewModel.kt | 15 +- .../presentation/dates/CourseDatesScreen.kt | 6 +- .../dates/CourseDatesViewModel.kt | 6 +- .../outline/CourseOutlineViewModel.kt | 2 +- .../course/presentation/ui/CourseVideosUI.kt | 2 +- course/src/main/res/values/strings.xml | 35 -- .../container/CourseContainerViewModelTest.kt | 2 +- .../dates/CourseDatesViewModelTest.kt | 2 +- .../profile/presentation/ProfileRouter.kt | 4 +- .../calendar/CalendarAccessDialogFragment.kt | 161 ++++++++ .../presentation/calendar/CalendarColor.kt | 19 + .../presentation/calendar/CalendarFragment.kt | 264 ++++++++++++ .../calendar/CalendarViewModel.kt | 14 + .../calendar/NewCalendarDialogFragment.kt | 390 ++++++++++++++++++ .../presentation/settings/SettingsFragment.kt | 11 +- .../presentation/settings/SettingsScreenUI.kt | 66 ++- .../settings/SettingsViewModel.kt | 4 + .../profile/presentation/ui/SettingsUI.kt | 17 +- .../video/VideoSettingsViewModel.kt | 2 +- profile/src/main/res/values/strings.xml | 21 + 42 files changed, 1141 insertions(+), 211 deletions(-) create mode 100644 core/src/main/java/org/openedx/core/presentation/dialog/DialogUI.kt rename {course/src/main/java/org/openedx/course/presentation => core/src/main/java/org/openedx/core/presentation/settings}/calendarsync/CalendarSyncDialog.kt (98%) create mode 100644 core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncDialogType.kt rename {course/src/main/java/org/openedx/course/presentation => core/src/main/java/org/openedx/core/presentation/settings}/calendarsync/CalendarSyncUIState.kt (89%) rename {course/src/main/java/org/openedx/course/presentation => core/src/main/java/org/openedx/core/presentation/settings}/calendarsync/DialogProperties.kt (78%) rename core/src/main/java/org/openedx/core/presentation/settings/{ => video}/VideoQualityFragment.kt (99%) rename core/src/main/java/org/openedx/core/presentation/settings/{ => video}/VideoQualityType.kt (51%) rename core/src/main/java/org/openedx/core/presentation/settings/{ => video}/VideoQualityViewModel.kt (98%) rename {course/src/main/java/org/openedx/course/presentation/calendarsync => core/src/main/java/org/openedx/core/system}/CalendarManager.kt (98%) delete mode 100644 course/src/main/AndroidManifest.xml delete mode 100644 course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncDialogType.kt create mode 100644 profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarAccessDialogFragment.kt create mode 100644 profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarColor.kt create mode 100644 profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarFragment.kt create mode 100644 profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarViewModel.kt create mode 100644 profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogFragment.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8020f6b74..3e8282acb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,6 +6,8 @@ + + diff --git a/app/src/main/java/org/openedx/app/AppRouter.kt b/app/src/main/java/org/openedx/app/AppRouter.kt index 21f3b5aee..a68b550a2 100644 --- a/app/src/main/java/org/openedx/app/AppRouter.kt +++ b/app/src/main/java/org/openedx/app/AppRouter.kt @@ -13,8 +13,8 @@ import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.presentation.global.app_upgrade.AppUpgradeRouter import org.openedx.core.presentation.global.app_upgrade.UpgradeRequiredFragment import org.openedx.core.presentation.global.webview.WebContentFragment -import org.openedx.core.presentation.settings.VideoQualityFragment -import org.openedx.core.presentation.settings.VideoQualityType +import org.openedx.core.presentation.settings.video.VideoQualityFragment +import org.openedx.core.presentation.settings.video.VideoQualityType import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.container.CourseContainerFragment import org.openedx.course.presentation.container.NoAccessCourseContainerFragment @@ -44,6 +44,7 @@ import org.openedx.discussion.presentation.threads.DiscussionThreadsFragment import org.openedx.profile.domain.model.Account import org.openedx.profile.presentation.ProfileRouter import org.openedx.profile.presentation.anothersaccount.AnothersProfileFragment +import org.openedx.profile.presentation.calendar.CalendarFragment import org.openedx.profile.presentation.delete.DeleteProfileFragment import org.openedx.profile.presentation.edit.EditProfileFragment import org.openedx.profile.presentation.manageaccount.ManageAccountFragment @@ -370,6 +371,10 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di override fun navigateToManageAccount(fm: FragmentManager) { replaceFragmentWithBackStack(fm, ManageAccountFragment()) } + + override fun navigateToCalendarSettings(fm: FragmentManager) { + replaceFragmentWithBackStack(fm, CalendarFragment()) + } //endregion private fun replaceFragmentWithBackStack(fm: FragmentManager, fragment: Fragment) { 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 16a30c0c6..529f00ac0 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -40,6 +40,7 @@ import org.openedx.core.presentation.global.AppData import org.openedx.core.presentation.global.WhatsNewGlobalManager import org.openedx.core.presentation.global.app_upgrade.AppUpgradeRouter import org.openedx.core.system.AppCookieManager +import org.openedx.core.system.CalendarManager import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.AppUpgradeNotifier @@ -50,7 +51,6 @@ import org.openedx.core.system.notifier.VideoNotifier import org.openedx.course.data.storage.CoursePreferences import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseRouter -import org.openedx.course.presentation.calendarsync.CalendarManager import org.openedx.dashboard.presentation.DashboardAnalytics import org.openedx.dashboard.presentation.DashboardRouter import org.openedx.discovery.presentation.DiscoveryAnalytics 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 c9c395a01..3c99dbc0f 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -13,7 +13,7 @@ import org.openedx.auth.presentation.signin.SignInViewModel import org.openedx.auth.presentation.signup.SignUpViewModel import org.openedx.core.Validator import org.openedx.core.presentation.dialog.selectorbottomsheet.SelectDialogViewModel -import org.openedx.core.presentation.settings.VideoQualityViewModel +import org.openedx.core.presentation.settings.video.VideoQualityViewModel import org.openedx.course.data.repository.CourseRepository import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.container.CourseContainerViewModel @@ -53,6 +53,7 @@ import org.openedx.profile.data.repository.ProfileRepository import org.openedx.profile.domain.interactor.ProfileInteractor import org.openedx.profile.domain.model.Account import org.openedx.profile.presentation.anothersaccount.AnothersProfileViewModel +import org.openedx.profile.presentation.calendar.CalendarViewModel import org.openedx.profile.presentation.delete.DeleteProfileViewModel import org.openedx.profile.presentation.edit.EditProfileViewModel import org.openedx.profile.presentation.manageaccount.ManageAccountViewModel @@ -163,6 +164,7 @@ val screenModule = module { ) } viewModel { ManageAccountViewModel(get(), get(), get(), get(), get()) } + viewModel { CalendarViewModel(get()) } single { CourseRepository(get(), get(), get(), get(), get()) } factory { CourseInteractor(get()) } 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 4b40fbc29..b0c3f211d 100644 --- a/core/src/main/java/org/openedx/core/config/Config.kt +++ b/core/src/main/java/org/openedx/core/config/Config.kt @@ -23,6 +23,10 @@ class Config(context: Context) { } } + fun getAppId(): String { + return getString(APPLICATION_ID, "") + } + fun getApiHostURL(): String { return getString(API_HOST_URL, "") } @@ -146,6 +150,7 @@ class Config(context: Context) { } companion object { + private const val APPLICATION_ID = "APPLICATION_ID" private const val API_HOST_URL = "API_HOST_URL" private const val URI_SCHEME = "URI_SCHEME" private const val OAUTH_CLIENT_ID = "OAUTH_CLIENT_ID" diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/DialogUI.kt b/core/src/main/java/org/openedx/core/presentation/dialog/DialogUI.kt new file mode 100644 index 000000000..17b1d2874 --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/dialog/DialogUI.kt @@ -0,0 +1,56 @@ +package org.openedx.core.presentation.dialog + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import org.openedx.core.ui.noRippleClickable +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes + +@Composable +fun DefaultDialogBox( + modifier: Modifier = Modifier, + onDismissClick: () -> Unit, + content: @Composable (BoxScope.() -> Unit) +) { + Surface( + modifier = modifier, + color = Color.Transparent + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 4.dp) + .noRippleClickable { + onDismissClick() + }, + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .widthIn(max = 640.dp) + .fillMaxWidth() + .clip(MaterialTheme.appShapes.cardShape) + .noRippleClickable {} + .background( + color = MaterialTheme.appColors.background, + shape = MaterialTheme.appShapes.cardShape + ) + ) { + content.invoke(this) + } + } + } +} diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/appreview/AppReviewUI.kt b/core/src/main/java/org/openedx/core/presentation/dialog/appreview/AppReviewUI.kt index b924cd543..a1df55a05 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/appreview/AppReviewUI.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/appreview/AppReviewUI.kt @@ -4,26 +4,20 @@ import android.content.res.Configuration.ORIENTATION_LANDSCAPE import android.content.res.Configuration.UI_MODE_NIGHT_NO import android.content.res.Configuration.UI_MODE_NIGHT_YES import androidx.compose.foundation.Image -import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.OutlinedTextField -import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.TextFieldDefaults import androidx.compose.material.icons.Icons @@ -40,7 +34,6 @@ 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.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale @@ -54,7 +47,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import org.openedx.core.R -import org.openedx.core.ui.noRippleClickable +import org.openedx.core.presentation.dialog.DefaultDialogBox import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes @@ -78,7 +71,7 @@ fun ThankYouDialog( DefaultDialogBox( modifier = modifier, - onDismissClock = onNotNowClick + onDismissClick = onNotNowClick ) { Column( modifier = Modifier @@ -139,7 +132,7 @@ fun FeedbackDialog( DefaultDialogBox( modifier = modifier, - onDismissClock = onNotNowClick + onDismissClick = onNotNowClick ) { Column( modifier = Modifier @@ -210,7 +203,7 @@ fun RateDialog( ) { DefaultDialogBox( modifier = modifier, - onDismissClock = onNotNowClick + onDismissClick = onNotNowClick ) { Column( modifier = Modifier @@ -252,42 +245,6 @@ fun RateDialog( } } -@Composable -fun DefaultDialogBox( - modifier: Modifier = Modifier, - onDismissClock: () -> Unit, - content: @Composable (BoxScope.() -> Unit) -) { - Surface( - modifier = modifier, - color = Color.Transparent - ) { - Box( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 4.dp) - .noRippleClickable { - onDismissClock() - }, - contentAlignment = Alignment.Center - ) { - Box( - modifier = Modifier - .widthIn(max = 640.dp) - .fillMaxWidth() - .clip(MaterialTheme.appShapes.cardShape) - .noRippleClickable {} - .background( - color = MaterialTheme.appColors.background, - shape = MaterialTheme.appShapes.cardShape - ) - ) { - content.invoke(this) - } - } - } -} - @Composable fun TransparentTextButton( text: String, diff --git a/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncDialog.kt b/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncDialog.kt similarity index 98% rename from course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncDialog.kt rename to core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncDialog.kt index a3775c99b..ac358228e 100644 --- a/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncDialog.kt +++ b/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncDialog.kt @@ -1,4 +1,4 @@ -package org.openedx.course.presentation.calendarsync +package org.openedx.core.presentation.settings.calendarsync import android.content.res.Configuration import androidx.compose.foundation.background @@ -23,13 +23,13 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog +import org.openedx.core.R import org.openedx.core.extension.takeIfNotEmpty import org.openedx.core.presentation.global.app_upgrade.TransparentTextButton import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography -import org.openedx.course.R import androidx.compose.ui.window.DialogProperties as AlertDialogProperties import org.openedx.core.R as CoreR @@ -192,7 +192,7 @@ private fun SyncDialog() { verticalArrangement = Arrangement.spacedBy(8.dp), ) { Text( - text = stringResource(id = R.string.course_title_syncing_calendar), + text = stringResource(id = R.string.core_title_syncing_calendar), color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.titleMedium, maxLines = 2, diff --git a/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncDialogType.kt b/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncDialogType.kt new file mode 100644 index 000000000..daab61fa5 --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncDialogType.kt @@ -0,0 +1,44 @@ +package org.openedx.core.presentation.settings.calendarsync + +import org.openedx.core.R + +enum class CalendarSyncDialogType( + val titleResId: Int = 0, + val messageResId: Int = 0, + val positiveButtonResId: Int = 0, + val negativeButtonResId: Int = 0, +) { + SYNC_DIALOG( + titleResId = R.string.core_title_add_course_calendar, + messageResId = R.string.core_message_add_course_calendar, + positiveButtonResId = R.string.core_ok, + negativeButtonResId = R.string.core_cancel + ), + UN_SYNC_DIALOG( + titleResId = R.string.core_title_remove_course_calendar, + messageResId = R.string.core_message_remove_course_calendar, + positiveButtonResId = R.string.core_label_remove, + negativeButtonResId = R.string.core_cancel + ), + PERMISSION_DIALOG( + titleResId = R.string.core_title_request_calendar_permission, + messageResId = R.string.core_message_request_calendar_permission, + positiveButtonResId = R.string.core_ok, + negativeButtonResId = R.string.core_label_do_not_allow + ), + EVENTS_DIALOG( + messageResId = R.string.core_message_course_calendar_added, + positiveButtonResId = R.string.core_label_view_events, + negativeButtonResId = R.string.core_label_done + ), + OUT_OF_SYNC_DIALOG( + titleResId = R.string.core_title_calendar_out_of_date, + messageResId = R.string.core_message_calendar_out_of_date, + positiveButtonResId = R.string.core_label_update_now, + negativeButtonResId = R.string.core_label_remove_course_calendar, + ), + LOADING_DIALOG( + titleResId = R.string.core_title_syncing_calendar + ), + NONE; +} diff --git a/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncUIState.kt b/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncUIState.kt similarity index 89% rename from course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncUIState.kt rename to core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncUIState.kt index 24d2212e2..e3062d970 100644 --- a/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncUIState.kt +++ b/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncUIState.kt @@ -1,4 +1,4 @@ -package org.openedx.course.presentation.calendarsync +package org.openedx.core.presentation.settings.calendarsync import org.openedx.core.domain.model.CourseDateBlock import java.util.concurrent.atomic.AtomicReference diff --git a/course/src/main/java/org/openedx/course/presentation/calendarsync/DialogProperties.kt b/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/DialogProperties.kt similarity index 78% rename from course/src/main/java/org/openedx/course/presentation/calendarsync/DialogProperties.kt rename to core/src/main/java/org/openedx/core/presentation/settings/calendarsync/DialogProperties.kt index cefded76c..cfca43193 100644 --- a/course/src/main/java/org/openedx/course/presentation/calendarsync/DialogProperties.kt +++ b/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/DialogProperties.kt @@ -1,4 +1,4 @@ -package org.openedx.course.presentation.calendarsync +package org.openedx.core.presentation.settings.calendarsync data class DialogProperties( val title: String, diff --git a/core/src/main/java/org/openedx/core/presentation/settings/VideoQualityFragment.kt b/core/src/main/java/org/openedx/core/presentation/settings/video/VideoQualityFragment.kt similarity index 99% rename from core/src/main/java/org/openedx/core/presentation/settings/VideoQualityFragment.kt rename to core/src/main/java/org/openedx/core/presentation/settings/video/VideoQualityFragment.kt index e26d882eb..edd00ce53 100644 --- a/core/src/main/java/org/openedx/core/presentation/settings/VideoQualityFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/settings/video/VideoQualityFragment.kt @@ -1,4 +1,4 @@ -package org.openedx.core.presentation.settings +package org.openedx.core.presentation.settings.video import android.content.res.Configuration import android.os.Bundle diff --git a/core/src/main/java/org/openedx/core/presentation/settings/VideoQualityType.kt b/core/src/main/java/org/openedx/core/presentation/settings/video/VideoQualityType.kt similarity index 51% rename from core/src/main/java/org/openedx/core/presentation/settings/VideoQualityType.kt rename to core/src/main/java/org/openedx/core/presentation/settings/video/VideoQualityType.kt index 4c7973d6a..c39b6d220 100644 --- a/core/src/main/java/org/openedx/core/presentation/settings/VideoQualityType.kt +++ b/core/src/main/java/org/openedx/core/presentation/settings/video/VideoQualityType.kt @@ -1,4 +1,4 @@ -package org.openedx.core.presentation.settings +package org.openedx.core.presentation.settings.video enum class VideoQualityType { Streaming, Download diff --git a/core/src/main/java/org/openedx/core/presentation/settings/VideoQualityViewModel.kt b/core/src/main/java/org/openedx/core/presentation/settings/video/VideoQualityViewModel.kt similarity index 98% rename from core/src/main/java/org/openedx/core/presentation/settings/VideoQualityViewModel.kt rename to core/src/main/java/org/openedx/core/presentation/settings/video/VideoQualityViewModel.kt index c6d5176ea..bf30bbe30 100644 --- a/core/src/main/java/org/openedx/core/presentation/settings/VideoQualityViewModel.kt +++ b/core/src/main/java/org/openedx/core/presentation/settings/video/VideoQualityViewModel.kt @@ -1,4 +1,4 @@ -package org.openedx.core.presentation.settings +package org.openedx.core.presentation.settings.video import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData diff --git a/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarManager.kt b/core/src/main/java/org/openedx/core/system/CalendarManager.kt similarity index 98% rename from course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarManager.kt rename to core/src/main/java/org/openedx/core/system/CalendarManager.kt index 54639e922..53d7a1e1f 100644 --- a/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarManager.kt +++ b/core/src/main/java/org/openedx/core/system/CalendarManager.kt @@ -1,4 +1,4 @@ -package org.openedx.course.presentation.calendarsync +package org.openedx.core.system import android.annotation.SuppressLint import android.content.ContentUris @@ -10,12 +10,11 @@ import android.database.Cursor import android.net.Uri import android.provider.CalendarContract import androidx.core.content.ContextCompat +import org.openedx.core.R import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.CourseDateBlock -import org.openedx.core.system.ResourceManager import org.openedx.core.utils.Logger import org.openedx.core.utils.toCalendar -import org.openedx.course.R import java.util.Calendar import java.util.TimeZone import java.util.concurrent.TimeUnit @@ -165,7 +164,7 @@ class CalendarManager( put(CalendarContract.Events.DTEND, endMillis) put( CalendarContract.Events.TITLE, - "${resourceManager.getString(R.string.course_assignment_due_tag)} : $courseName" + "${resourceManager.getString(R.string.core_assignment_due_tag)} : $courseName" ) put( CalendarContract.Events.DESCRIPTION, 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 212014177..1692e7a4d 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt @@ -939,6 +939,7 @@ fun TextIcon( icon: ImageVector, color: Color, textStyle: TextStyle = MaterialTheme.appTypography.bodySmall, + iconModifier: Modifier = Modifier, onClick: (() -> Unit)? = null, ) { val modifier = if (onClick == null) { @@ -953,7 +954,7 @@ fun TextIcon( ) { Text(text = text, color = color, style = textStyle) Icon( - modifier = Modifier.size((textStyle.fontSize.value + 4).dp), + modifier = iconModifier.size((textStyle.fontSize.value + 4).dp), imageVector = icon, contentDescription = null, tint = color 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 6cf198f53..1659a0417 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeExtensions.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeExtensions.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.layout import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity @@ -37,6 +38,7 @@ import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.launch @@ -192,4 +194,19 @@ fun Modifier.settingsHeaderBackground(): Modifier = composed { contentScale = ContentScale.FillWidth, alignment = Alignment.TopCenter ) -} \ No newline at end of file +} + +fun Modifier.crop( + horizontal: Dp = 0.dp, + vertical: Dp = 0.dp, +): Modifier = this.layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + fun Dp.toPxInt(): Int = this.toPx().toInt() + + layout( + placeable.width - (horizontal * 2).toPxInt(), + placeable.height - (vertical * 2).toPxInt() + ) { + placeable.placeRelative(-horizontal.toPx().toInt(), -vertical.toPx().toInt()) + } +} diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index ed4b1d99d..668c61935 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -132,6 +132,41 @@ Video download quality Manage Account + Assignment Due + Syncing calendar… + + + Sync to calendar + Automatically sync all deadlines and due dates for this course to your calendar. + + \“%s\” Would Like to Access Your Calendar + %s would like to use your calendar list to subscribe to your personalized %s calendar for this course. + Don’t allow + + Add Course Dates to Calendar + Would you like to add \“%s\” dates to your calendar? \n\nYou can edit or remove your course dates at any time from your calendar or settings. + + \“%s\” has been added to your phone\'s calendar. + View Events + Done + + Remove Course Dates from Calendar + Would you like to remove the \“%s\” dates from your calendar? + Remove + + Your course calendar is out of date + Your course dates have been shifted and your course calendar is no longer up to date with your new schedule. + Update Now + Remove Course Calendar + + Your course calendar has been added. + Your course calendar has been removed. + Your course calendar has been updated. + Error Adding Calendar, Please try later + + + + Home Videos Discussions diff --git a/course/src/main/AndroidManifest.xml b/course/src/main/AndroidManifest.xml deleted file mode 100644 index 5c18ebdbf..000000000 --- a/course/src/main/AndroidManifest.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt b/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt index b2f520679..9b34e7617 100644 --- a/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt +++ b/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt @@ -2,7 +2,7 @@ package org.openedx.course.presentation import androidx.fragment.app.FragmentManager import org.openedx.core.presentation.course.CourseViewMode -import org.openedx.core.presentation.settings.VideoQualityType +import org.openedx.core.presentation.settings.video.VideoQualityType import org.openedx.course.presentation.handouts.HandoutsType interface CourseRouter { diff --git a/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncDialogType.kt b/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncDialogType.kt deleted file mode 100644 index 57d6c0dac..000000000 --- a/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncDialogType.kt +++ /dev/null @@ -1,45 +0,0 @@ -package org.openedx.course.presentation.calendarsync - -import org.openedx.course.R -import org.openedx.core.R as CoreR - -enum class CalendarSyncDialogType( - val titleResId: Int = 0, - val messageResId: Int = 0, - val positiveButtonResId: Int = 0, - val negativeButtonResId: Int = 0, -) { - SYNC_DIALOG( - titleResId = R.string.course_title_add_course_calendar, - messageResId = R.string.course_message_add_course_calendar, - positiveButtonResId = CoreR.string.core_ok, - negativeButtonResId = CoreR.string.core_cancel - ), - UN_SYNC_DIALOG( - titleResId = R.string.course_title_remove_course_calendar, - messageResId = R.string.course_message_remove_course_calendar, - positiveButtonResId = R.string.course_label_remove, - negativeButtonResId = CoreR.string.core_cancel - ), - PERMISSION_DIALOG( - titleResId = R.string.course_title_request_calendar_permission, - messageResId = R.string.course_message_request_calendar_permission, - positiveButtonResId = CoreR.string.core_ok, - negativeButtonResId = R.string.course_label_do_not_allow - ), - EVENTS_DIALOG( - messageResId = R.string.course_message_course_calendar_added, - positiveButtonResId = R.string.course_label_view_events, - negativeButtonResId = R.string.course_label_done - ), - OUT_OF_SYNC_DIALOG( - titleResId = R.string.course_title_calendar_out_of_date, - messageResId = R.string.course_message_calendar_out_of_date, - positiveButtonResId = R.string.course_label_update_now, - negativeButtonResId = R.string.course_label_remove_course_calendar, - ), - LOADING_DIALOG( - titleResId = R.string.course_title_syncing_calendar - ), - NONE; -} 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 669b1f661..2d80608ef 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 @@ -55,6 +55,8 @@ 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 import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OfflineModeDialog import org.openedx.core.ui.RoundTabsBar @@ -65,8 +67,6 @@ import org.openedx.core.ui.theme.appColors import org.openedx.course.DatesShiftedSnackBar import org.openedx.course.R import org.openedx.course.databinding.FragmentCourseContainerBinding -import org.openedx.course.presentation.calendarsync.CalendarSyncDialog -import org.openedx.course.presentation.calendarsync.CalendarSyncDialogType import org.openedx.course.presentation.dates.CourseDatesScreen import org.openedx.course.presentation.handouts.HandoutsScreen import org.openedx.course.presentation.handouts.HandoutsType 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 8562289af..1ec787e54 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 @@ -26,6 +26,9 @@ 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 import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CalendarSyncEvent.CheckCalendarSyncEvent @@ -38,7 +41,6 @@ import org.openedx.core.system.notifier.CourseRefresh import org.openedx.core.system.notifier.CourseStructureUpdated import org.openedx.core.utils.TimeUtils import org.openedx.course.DatesShiftedSnackBar -import org.openedx.course.R import org.openedx.course.data.storage.CoursePreferences import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CalendarSyncDialog @@ -47,9 +49,6 @@ import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseAnalyticsEvent import org.openedx.course.presentation.CourseAnalyticsKey import org.openedx.course.presentation.CourseRouter -import org.openedx.course.presentation.calendarsync.CalendarManager -import org.openedx.course.presentation.calendarsync.CalendarSyncDialogType -import org.openedx.course.presentation.calendarsync.CalendarSyncUIState import java.util.Date import java.util.concurrent.atomic.AtomicReference import org.openedx.core.R as CoreR @@ -283,7 +282,7 @@ class CourseContainerViewModel( val calendarId = getCalendarId() if (calendarId == CalendarManager.CALENDAR_DOES_NOT_EXIST) { - setUiMessage(R.string.course_snackbar_course_calendar_error) + setUiMessage(CoreR.string.core_snackbar_course_calendar_error) setCalendarSyncDialogType(CalendarSyncDialogType.NONE) return @@ -314,10 +313,10 @@ class CourseContainerViewModel( if (updatedEvent) { logCalendarSyncSnackbar(CalendarSyncSnackbar.UPDATED) - setUiMessage(R.string.course_snackbar_course_calendar_updated) + setUiMessage(CoreR.string.core_snackbar_course_calendar_updated) } else if (coursePreferences.isCalendarSyncEventsDialogShown(courseName)) { logCalendarSyncSnackbar(CalendarSyncSnackbar.ADDED) - setUiMessage(R.string.course_snackbar_course_calendar_added) + setUiMessage(CoreR.string.core_snackbar_course_calendar_added) } else { coursePreferences.setCalendarSyncEventsDialogShown(courseName) setCalendarSyncDialogType(CalendarSyncDialogType.EVENTS_DIALOG) @@ -361,7 +360,7 @@ class CourseContainerViewModel( } logCalendarSyncSnackbar(CalendarSyncSnackbar.REMOVED) - setUiMessage(R.string.course_snackbar_course_calendar_removed) + setUiMessage(CoreR.string.core_snackbar_course_calendar_removed) } } 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 715584497..6e875d263 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 @@ -73,6 +73,7 @@ import org.openedx.core.extension.isNotEmptyThenLet import org.openedx.core.presentation.CoreAnalyticsScreen import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.presentation.dialog.alert.ActionDialogFragment +import org.openedx.core.presentation.settings.calendarsync.CalendarSyncUIState import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.WindowSize import org.openedx.core.ui.WindowType @@ -86,7 +87,6 @@ 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.calendarsync.CalendarSyncUIState import org.openedx.course.presentation.ui.CourseDatesBanner import org.openedx.course.presentation.ui.CourseDatesBannerTablet import java.util.concurrent.atomic.AtomicReference @@ -354,7 +354,7 @@ fun CalendarSyncCard( modifier = Modifier .padding(start = 8.dp, end = 8.dp) .weight(1f), - text = stringResource(id = R.string.course_header_sync_to_calendar), + text = stringResource(id = CoreR.string.core_header_sync_to_calendar), style = MaterialTheme.appTypography.titleMedium, color = MaterialTheme.appColors.textDark ) @@ -374,7 +374,7 @@ fun CalendarSyncCard( .fillMaxWidth() .padding(top = 8.dp) .height(40.dp), - text = stringResource(id = R.string.course_body_sync_to_calendar), + text = stringResource(id = CoreR.string.core_body_sync_to_calendar), style = MaterialTheme.appTypography.bodyMedium, color = MaterialTheme.appColors.textDark, ) 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 e5ce08ed7..5d7f94d47 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 @@ -23,6 +23,9 @@ import org.openedx.core.domain.model.CourseStructure import org.openedx.core.extension.getSequentialBlocks import org.openedx.core.extension.getVerticalBlocks 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 @@ -35,9 +38,6 @@ 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.calendarsync.CalendarManager -import org.openedx.course.presentation.calendarsync.CalendarSyncDialogType -import org.openedx.course.presentation.calendarsync.CalendarSyncUIState import org.openedx.core.R as CoreR class CourseDatesViewModel( 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 7a6e08b58..306498d89 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 @@ -25,6 +25,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.settings.calendarsync.CalendarSyncDialogType import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEvent @@ -36,7 +37,6 @@ 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.calendarsync.CalendarSyncDialogType import org.openedx.course.R as courseR class CourseOutlineViewModel( 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 16fd90992..30706cd6d 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 @@ -67,7 +67,7 @@ import org.openedx.core.domain.model.VideoSettings import org.openedx.core.extension.toFileSize import org.openedx.core.module.download.DownloadModelsSize import org.openedx.core.presentation.course.CourseViewMode -import org.openedx.core.presentation.settings.VideoQualityType +import org.openedx.core.presentation.settings.video.VideoQualityType import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.WindowSize import org.openedx.core.ui.WindowType diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index 802065471..6a974cb15 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -41,41 +41,6 @@ Course dates are not currently available. - - Sync to calendar - Automatically sync all deadlines and due dates for this course to your calendar. - - \“%s\” Would Like to Access Your Calendar - %s would like to use your calendar list to subscribe to your personalized %s calendar for this course. - Don’t allow - - Add Course Dates to Calendar - Would you like to add \“%s\” dates to your calendar? \n\nYou can edit or remove your course dates at any time from your calendar or settings. - - Syncing calendar… - - \“%s\” has been added to your phone\'s calendar. - View Events - Done - - Remove Course Dates from Calendar - Would you like to remove the \“%s\” dates from your calendar? - Remove - - Your course calendar is out of date - Your course dates have been shifted and your course calendar is no longer up to date with your new schedule. - Update Now - Remove Course Calendar - - Your course calendar has been added. - Your course calendar has been removed. - Your course calendar has been updated. - Error Adding Calendar, Please try later - - Assignment Due - - - 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 1b2cb6cca..aff60b21e 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 @@ -33,6 +33,7 @@ import org.openedx.core.domain.model.AppConfig import org.openedx.core.domain.model.CourseDatesCalendarSync import org.openedx.core.domain.model.CourseStructure import org.openedx.core.domain.model.CoursewareAccess +import org.openedx.core.system.CalendarManager import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier @@ -42,7 +43,6 @@ import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseAnalyticsEvent import org.openedx.course.presentation.CourseRouter -import org.openedx.course.presentation.calendarsync.CalendarManager import java.net.UnknownHostException import java.util.Date 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 13e78fe91..f8715e7b3 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 @@ -37,13 +37,13 @@ import org.openedx.core.domain.model.CourseDatesResult import org.openedx.core.domain.model.CourseStructure import org.openedx.core.domain.model.CoursewareAccess import org.openedx.core.domain.model.DatesSection +import org.openedx.core.system.CalendarManager import org.openedx.core.system.ResourceManager import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEvent 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.calendarsync.CalendarManager import java.net.UnknownHostException import java.util.Date diff --git a/profile/src/main/java/org/openedx/profile/presentation/ProfileRouter.kt b/profile/src/main/java/org/openedx/profile/presentation/ProfileRouter.kt index a4b194de4..fd7514bd5 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/ProfileRouter.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/ProfileRouter.kt @@ -1,7 +1,7 @@ package org.openedx.profile.presentation import androidx.fragment.app.FragmentManager -import org.openedx.core.presentation.settings.VideoQualityType +import org.openedx.core.presentation.settings.video.VideoQualityType import org.openedx.profile.domain.model.Account interface ProfileRouter { @@ -21,4 +21,6 @@ interface ProfileRouter { fun navigateToWebContent(fm: FragmentManager, title: String, url: String) fun navigateToManageAccount(fm: FragmentManager) + + fun navigateToCalendarSettings(fm: FragmentManager) } 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 new file mode 100644 index 000000000..a9094c67d --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarAccessDialogFragment.kt @@ -0,0 +1,161 @@ +package org.openedx.profile.presentation.calendar + +import android.content.Intent +import android.content.res.Configuration +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.net.Uri +import android.os.Bundle +import android.provider.Settings +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +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.size +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.fragment.app.DialogFragment +import org.koin.android.ext.android.inject +import org.openedx.core.config.Config +import org.openedx.core.presentation.dialog.DefaultDialogBox +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.OpenEdXOutlinedButton +import org.openedx.core.ui.TextIcon +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appTypography +import org.openedx.profile.R +import org.openedx.core.R as CoreR + +class CalendarAccessDialogFragment : DialogFragment() { + + private val config by inject() + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + CalendarAccessDialog( + onCancelClick = { + dismiss() + }, + onGrantCalendarAccessClick = { + val intent = Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.parse("package:" + config.getAppId()) + ) + startActivity(intent) + dismiss() + } + ) + } + } + } + + companion object { + const val DIALOG_TAG = "CalendarAccessDialogFragment" + + fun newInstance(): CalendarAccessDialogFragment { + return CalendarAccessDialogFragment() + } + } +} + +@Composable +private fun CalendarAccessDialog( + modifier: Modifier = Modifier, + onCancelClick: () -> Unit, + onGrantCalendarAccessClick: () -> Unit +) { + DefaultDialogBox( + modifier = modifier, + onDismissClick = onCancelClick + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Image( + modifier = Modifier.size(24.dp), + painter = painterResource(id = CoreR.drawable.core_ic_warning), + contentDescription = null + ) + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.profile_calendar_access_dialog_title), + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textDark + ) + } + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.profile_calendar_access_dialog_description), + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.textDark + ) + OpenEdXButton( + modifier = Modifier.fillMaxWidth(), + onClick = { + onGrantCalendarAccessClick() + }, + content = { + TextIcon( + text = stringResource(id = R.string.profile_grant_access_calendar), + icon = Icons.AutoMirrored.Filled.OpenInNew, + color = MaterialTheme.appColors.buttonText, + textStyle = MaterialTheme.appTypography.labelLarge, + iconModifier = Modifier.padding(start = 4.dp) + ) + } + ) + OpenEdXOutlinedButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = CoreR.string.core_cancel), + backgroundColor = MaterialTheme.appColors.background, + borderColor = MaterialTheme.appColors.buttonBackground, + textColor = MaterialTheme.appColors.buttonBackground, + onClick = { + onCancelClick() + } + ) + } + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun CalendarAccessDialogPreview() { + OpenEdXTheme { + CalendarAccessDialog( + onCancelClick = { }, + onGrantCalendarAccessClick = { } + ) + } +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarColor.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarColor.kt new file mode 100644 index 000000000..361fa5776 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarColor.kt @@ -0,0 +1,19 @@ +package org.openedx.profile.presentation.calendar + +import androidx.annotation.StringRes +import org.openedx.profile.R + +enum class CalendarColor( + @StringRes + val title: Int, + val color: Long +) { + ACCENT(R.string.calendar_color_accent, 0xFFD13329), + RED(R.string.calendar_color_red, 0xFFFF2967), + ORANGE(R.string.calendar_color_orange, 0xFFFF9501), + YELLOW(R.string.calendar_color_yellow, 0xFFFFCC01), + GREEN(R.string.calendar_color_green, 0xFF64DA38), + BLUE(R.string.calendar_color_blue, 0xFF1AAEF8), + PURPLE(R.string.calendar_color_purple, 0xFFCC73E1), + BROWN(R.string.calendar_color_brown, 0xFFA2845E); +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarFragment.kt new file mode 100644 index 000000000..8a8794c94 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarFragment.kt @@ -0,0 +1,264 @@ +package org.openedx.profile.presentation.calendar + +import android.content.res.Configuration +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.Card +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Autorenew +import androidx.compose.material.icons.rounded.CalendarToday +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.platform.testTag +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.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.fragment.app.Fragment +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.Toolbar +import org.openedx.core.ui.WindowSize +import org.openedx.core.ui.WindowType +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.rememberWindowSize +import org.openedx.core.ui.settingsHeaderBackground +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.profile.R +import org.openedx.core.R as CoreR + +class CalendarFragment : Fragment() { + + private val viewModel by viewModel() + + private val permissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { isGranted -> + if (!isGranted.containsValue(false)) { + val dialog = NewCalendarDialogFragment.newInstance() + dialog.show( + requireActivity().supportFragmentManager, + NewCalendarDialogFragment.DIALOG_TAG + ) + } else { + val dialog = CalendarAccessDialogFragment.newInstance() + dialog.show( + requireActivity().supportFragmentManager, + CalendarAccessDialogFragment.DIALOG_TAG + ) + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val windowSize = rememberWindowSize() + + CalendarScreen( + windowSize = windowSize, + setUpCalendarSync = { + viewModel.setUpCalendarSync(permissionLauncher) + }, + onBackClick = { + requireActivity().supportFragmentManager.popBackStack() + } + ) + } + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun CalendarScreen( + windowSize: WindowSize, + setUpCalendarSync: () -> Unit, + onBackClick: () -> Unit +) { + val scaffoldState = rememberScaffoldState() + + Scaffold( + modifier = Modifier + .fillMaxSize() + .semantics { + testTagsAsResourceId = true + }, + scaffoldState = scaffoldState + ) { paddingValues -> + + val contentWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 420.dp), + compact = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + ) + ) + } + + val topBarWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), + compact = Modifier + .fillMaxWidth() + ) + ) + } + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.TopCenter + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .settingsHeaderBackground() + .statusBarsInset(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Toolbar( + modifier = topBarWidth + .displayCutoutForLandscape(), + label = stringResource(id = R.string.profile_dates_and_calendar), + canShowBackBtn = true, + labelTint = MaterialTheme.appColors.settingsTitleContent, + iconTint = MaterialTheme.appColors.settingsTitleContent, + onBackClick = onBackClick + ) + + Box( + modifier = Modifier + .fillMaxSize() + .clip(MaterialTheme.appShapes.screenBackgroundShape) + .background(MaterialTheme.appColors.background) + .displayCutoutForLandscape(), + contentAlignment = Alignment.TopCenter + ) { + Column( + modifier = contentWidth.padding(vertical = 28.dp), + ) { + Text( + modifier = Modifier.testTag("txt_settings"), + text = stringResource(id = CoreR.string.core_settings), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textSecondary + ) + Spacer(modifier = Modifier.height(14.dp)) + Card( + shape = MaterialTheme.appShapes.cardShape, + elevation = 0.dp, + backgroundColor = MaterialTheme.appColors.cardViewBackground + ) { + Column( + modifier = Modifier + .padding(horizontal = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .padding(vertical = 28.dp), + contentAlignment = Alignment.Center + ) { + Icon( + modifier = Modifier + .fillMaxWidth() + .height(148.dp), + tint = MaterialTheme.appColors.textDark, + imageVector = Icons.Rounded.CalendarToday, + contentDescription = null + ) + Icon( + modifier = Modifier + .fillMaxWidth() + .padding(top = 30.dp) + .height(60.dp), + tint = MaterialTheme.appColors.textDark, + imageVector = Icons.Default.Autorenew, + contentDescription = null + ) + } + Text( + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + text = stringResource(id = R.string.profile_calendar_sync), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textDark + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + text = stringResource(id = R.string.profile_calendar_sync_description), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textDark + ) + Spacer(modifier = Modifier.height(16.dp)) + OpenEdXButton( + modifier = Modifier.fillMaxWidth(0.75f), + text = stringResource(id = R.string.profile_set_up_calendar_sync), + onClick = { + setUpCalendarSync() + } + ) + Spacer(modifier = Modifier.height(24.dp)) + } + } + } + } + } + } + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun CalendarScreenPreview() { + OpenEdXTheme { + CalendarScreen( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + setUpCalendarSync = {}, + onBackClick = {} + ) + } +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarViewModel.kt new file mode 100644 index 000000000..316b689b4 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarViewModel.kt @@ -0,0 +1,14 @@ +package org.openedx.profile.presentation.calendar + +import androidx.activity.result.ActivityResultLauncher +import org.openedx.core.BaseViewModel +import org.openedx.core.system.CalendarManager + +class CalendarViewModel( + private val calendarManager: CalendarManager +) : BaseViewModel() { + + fun setUpCalendarSync(permissionLauncher: ActivityResultLauncher>) { + permissionLauncher.launch(calendarManager.permissions) + } +} 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 new file mode 100644 index 000000000..bfd453f5c --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogFragment.kt @@ -0,0 +1,390 @@ +package org.openedx.profile.presentation.calendar + +import android.content.Context +import android.content.res.Configuration +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.Divider +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.material.TextFieldDefaults +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.fragment.app.DialogFragment +import org.openedx.core.presentation.dialog.DefaultDialogBox +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.OpenEdXOutlinedButton +import org.openedx.core.ui.crop +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.profile.R +import androidx.compose.ui.graphics.Color as ComposeColor +import org.openedx.core.R as CoreR + +class NewCalendarDialogFragment : DialogFragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + NewCalendarDialog( + onCancelClick = { + dismiss() + }, + onBeginSyncingClick = { calendarName, calendarColor -> + //TODO Create calendar and sync events + } + ) + } + } + } + + companion object { + const val DIALOG_TAG = "NewCalendarDialogFragment" + + fun newInstance(): NewCalendarDialogFragment { + return NewCalendarDialogFragment() + } + + fun getDefaultCalendarName(context: Context): String { + return "${context.getString(CoreR.string.app_name)} ${context.getString(R.string.profile_course_dates)}" + } + } +} + +@Composable +private fun NewCalendarDialog( + modifier: Modifier = Modifier, + onCancelClick: () -> Unit, + onBeginSyncingClick: (calendarName: String, calendarColor: CalendarColor) -> Unit +) { + val context = LocalContext.current + var calendarName by rememberSaveable { + mutableStateOf("") + } + var calendarColor by rememberSaveable { + mutableStateOf(CalendarColor.ACCENT) + } + DefaultDialogBox( + modifier = modifier, + onDismissClick = onCancelClick + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier.weight(1f), + text = stringResource(id = R.string.profile_new_calendar), + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.titleLarge + ) + Icon( + modifier = Modifier + .size(24.dp) + .clickable { + onCancelClick() + }, + imageVector = Icons.Default.Close, + contentDescription = null, + tint = MaterialTheme.appColors.primary + ) + } + CalendarNameTextField( + onValueChanged = { + calendarName = it + } + ) + ColorDropdown( + onValueChanged = { + calendarColor = it + } + ) + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.profile_new_calendar_description), + style = MaterialTheme.appTypography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.appColors.textDark + ) + OpenEdXOutlinedButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = CoreR.string.core_cancel), + backgroundColor = MaterialTheme.appColors.background, + borderColor = MaterialTheme.appColors.buttonBackground, + textColor = MaterialTheme.appColors.buttonBackground, + onClick = { + onCancelClick() + } + ) + OpenEdXButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.profile_begin_syncing), + onClick = { + onBeginSyncingClick( + calendarName.ifEmpty { NewCalendarDialogFragment.getDefaultCalendarName(context) }, + calendarColor + ) + } + ) + } + } +} + +@Composable +private fun CalendarNameTextField( + modifier: Modifier = Modifier, + onValueChanged: (String) -> Unit +) { + val focusManager = LocalFocusManager.current + var textFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf( + TextFieldValue("") + ) + } + + Column { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.profile_calendar_name), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.labelLarge + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + modifier = modifier + .fillMaxWidth() + .height(48.dp), + value = textFieldValue, + onValueChange = { + textFieldValue = it + onValueChanged(it.text.trim()) + }, + colors = TextFieldDefaults.outlinedTextFieldColors( + unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder + ), + shape = MaterialTheme.appShapes.textFieldShape, + placeholder = { + Text( + text = NewCalendarDialogFragment.getDefaultCalendarName(LocalContext.current), + color = MaterialTheme.appColors.textFieldHint, + style = MaterialTheme.appTypography.bodyMedium + ) + }, + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Next + ), + keyboardActions = KeyboardActions { + focusManager.clearFocus() + }, + textStyle = MaterialTheme.appTypography.bodyMedium, + singleLine = true + ) + } +} + +@Composable +private fun ColorDropdown( + modifier: Modifier = Modifier, + onValueChanged: (CalendarColor) -> Unit +) { + val density = LocalDensity.current + var expanded by remember { mutableStateOf(false) } + var currentValue by remember { mutableStateOf(CalendarColor.ACCENT) } + var dropdownWidth by remember { mutableStateOf(300.dp) } + val colorArrowRotation by animateFloatAsState( + targetValue = if (expanded) 180f else 0f, + label = "" + ) + + Column( + modifier = modifier + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.profile_color), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.labelLarge + ) + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .clip(MaterialTheme.appShapes.textFieldShape) + .border( + 1.dp, + MaterialTheme.appColors.textFieldBorder, + MaterialTheme.appShapes.textFieldShape + ) + .onSizeChanged { + dropdownWidth = with(density) { it.width.toDp() } + } + .clickable { + expanded = true + }, + verticalAlignment = Alignment.CenterVertically + ) { + ColorCircle( + modifier = Modifier + .padding(start = 16.dp), + color = ComposeColor(currentValue.color) + ) + Text( + modifier = Modifier + .weight(1f) + .padding(horizontal = 8.dp), + text = stringResource(id = currentValue.title), + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.bodyMedium + ) + Icon( + modifier = Modifier + .padding(end = 16.dp) + .rotate(colorArrowRotation), + imageVector = Icons.Default.ExpandMore, + tint = MaterialTheme.appColors.textDark, + contentDescription = null + ) + } + + MaterialTheme( + colors = MaterialTheme.colors.copy(surface = MaterialTheme.appColors.background), + shapes = MaterialTheme.shapes.copy(MaterialTheme.appShapes.textFieldShape) + ) { + Spacer(modifier = Modifier.padding(top = 4.dp)) + DropdownMenu( + modifier = Modifier + .crop(vertical = 8.dp) + .height(240.dp) + .width(dropdownWidth) + .border( + 1.dp, + MaterialTheme.appColors.textFieldBorder, + MaterialTheme.appShapes.textFieldShape + ) + .crop(vertical = 8.dp), + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + for ((index, calendarColor) in CalendarColor.entries.withIndex()) { + DropdownMenuItem( + modifier = Modifier + .background(MaterialTheme.appColors.background), + onClick = { + currentValue = calendarColor + expanded = false + onValueChanged(CalendarColor.entries[index]) + } + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + ColorCircle( + color = ComposeColor(calendarColor.color) + ) + Text( + text = stringResource(id = calendarColor.title), + style = MaterialTheme.appTypography.titleSmall, + color = MaterialTheme.appColors.textDark + ) + } + } + if (index < CalendarColor.entries.lastIndex) { + Divider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.appColors.divider + ) + } + } + } + } + } +} + +@Composable +private fun ColorCircle( + modifier: Modifier = Modifier, + color: ComposeColor +) { + Box( + modifier = modifier + .size(18.dp) + .clip(CircleShape) + .background(color) + ) +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun NewCalendarDialogPreview() { + OpenEdXTheme { + NewCalendarDialog( + onCancelClick = { }, + onBeginSyncingClick = { _, _ -> } + ) + } +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsFragment.kt index fbdd0b4af..7ac402330 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsFragment.kt @@ -83,11 +83,17 @@ class SettingsFragment : Fragment() { ) } - SettingsScreenAction.ManageAccount -> { + SettingsScreenAction.ManageAccountClick -> { viewModel.manageAccountClicked( requireActivity().supportFragmentManager ) } + + SettingsScreenAction.CalendarSettingsClick -> { + viewModel.calendarSettingsClicked( + requireActivity().supportFragmentManager + ) + } } } ) @@ -112,6 +118,7 @@ internal interface SettingsScreenAction { object TermsClick : SettingsScreenAction object SupportClick : SettingsScreenAction object VideoSettingsClick : SettingsScreenAction - object ManageAccount : SettingsScreenAction + object ManageAccountClick : SettingsScreenAction + object CalendarSettingsClick : SettingsScreenAction } diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt index f5c0a7bc5..419172232 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt @@ -21,7 +21,6 @@ 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.Icon import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme @@ -68,6 +67,7 @@ import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography import org.openedx.core.ui.windowSizeValue import org.openedx.profile.domain.model.Configuration +import org.openedx.profile.presentation.ui.SettingsDivider import org.openedx.profile.presentation.ui.SettingsItem import org.openedx.profile.R as profileR @@ -170,14 +170,19 @@ internal fun SettingsScreen( Spacer(Modifier.height(30.dp)) ManageAccountSection(onManageAccountClick = { - onAction(SettingsScreenAction.ManageAccount) + onAction(SettingsScreenAction.ManageAccountClick) }) Spacer(modifier = Modifier.height(24.dp)) - SettingsSection(onVideoSettingsClick = { - onAction(SettingsScreenAction.VideoSettingsClick) - }) + SettingsSection( + onVideoSettingsClick = { + onAction(SettingsScreenAction.VideoSettingsClick) + }, + onCalendarSettingsClick = { + onAction(SettingsScreenAction.CalendarSettingsClick) + } + ) Spacer(modifier = Modifier.height(24.dp)) @@ -205,7 +210,10 @@ internal fun SettingsScreen( } @Composable -private fun SettingsSection(onVideoSettingsClick: () -> Unit) { +private fun SettingsSection( + onVideoSettingsClick: () -> Unit, + onCalendarSettingsClick: () -> Unit +) { Column { Text( modifier = Modifier.testTag("txt_settings"), @@ -225,6 +233,11 @@ private fun SettingsSection(onVideoSettingsClick: () -> Unit) { text = stringResource(id = profileR.string.profile_video), onClick = onVideoSettingsClick ) + SettingsDivider() + SettingsItem( + text = stringResource(id = profileR.string.profile_dates_and_calendar), + onClick = onCalendarSettingsClick + ) } } } @@ -273,46 +286,31 @@ private fun SupportInfoSection( SettingsItem(text = stringResource(id = profileR.string.profile_contact_support)) { onAction(SettingsScreenAction.SupportClick) } - Divider( - modifier = Modifier.padding(vertical = 4.dp), - color = MaterialTheme.appColors.divider - ) + SettingsDivider() } if (uiState.configuration.agreementUrls.tosUrl.isNotBlank()) { SettingsItem(text = stringResource(id = R.string.core_terms_of_use)) { onAction(SettingsScreenAction.TermsClick) } - Divider( - modifier = Modifier.padding(vertical = 4.dp), - color = MaterialTheme.appColors.divider - ) + SettingsDivider() } if (uiState.configuration.agreementUrls.privacyPolicyUrl.isNotBlank()) { SettingsItem(text = stringResource(id = R.string.core_privacy_policy)) { onAction(SettingsScreenAction.PrivacyPolicyClick) } - Divider( - modifier = Modifier.padding(vertical = 4.dp), - color = MaterialTheme.appColors.divider - ) + SettingsDivider() } if (uiState.configuration.agreementUrls.cookiePolicyUrl.isNotBlank()) { SettingsItem(text = stringResource(id = R.string.core_cookie_policy)) { onAction(SettingsScreenAction.CookiePolicyClick) } - Divider( - modifier = Modifier.padding(vertical = 4.dp), - color = MaterialTheme.appColors.divider - ) + SettingsDivider() } if (uiState.configuration.agreementUrls.dataSellConsentUrl.isNotBlank()) { SettingsItem(text = stringResource(id = R.string.core_data_sell)) { onAction(SettingsScreenAction.DataSellClick) } - Divider( - modifier = Modifier.padding(vertical = 4.dp), - color = MaterialTheme.appColors.divider - ) + SettingsDivider() } if (uiState.configuration.faqUrl.isNotBlank()) { val uriHandler = LocalUriHandler.current @@ -323,10 +321,7 @@ private fun SupportInfoSection( uriHandler.openUri(uiState.configuration.faqUrl) onAction(SettingsScreenAction.FaqClick) } - Divider( - modifier = Modifier.padding(vertical = 4.dp), - color = MaterialTheme.appColors.divider - ) + SettingsDivider() } AppVersionItem( versionName = uiState.configuration.versionName, @@ -344,16 +339,17 @@ private fun LogoutButton(onClick: () -> Unit) { Card( modifier = Modifier .testTag("btn_logout") - .fillMaxWidth() - .clickable { - onClick() - }, + .fillMaxWidth(), shape = MaterialTheme.appShapes.cardShape, elevation = 0.dp, backgroundColor = MaterialTheme.appColors.cardViewBackground ) { Row( - modifier = Modifier.padding(20.dp), + modifier = Modifier + .clickable { + onClick() + } + .padding(20.dp), horizontalArrangement = Arrangement.SpaceBetween ) { Text( diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt index 2c7471ebd..9715eb774 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt @@ -185,6 +185,10 @@ class SettingsViewModel( router.navigateToManageAccount(fragmentManager) } + fun calendarSettingsClicked(fragmentManager: FragmentManager) { + router.navigateToCalendarSettings(fragmentManager) + } + fun restartApp(fragmentManager: FragmentManager) { router.restartApp( fragmentManager, diff --git a/profile/src/main/java/org/openedx/profile/presentation/ui/SettingsUI.kt b/profile/src/main/java/org/openedx/profile/presentation/ui/SettingsUI.kt index 6960a0864..df6c719ca 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/ui/SettingsUI.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/ui/SettingsUI.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.material.Divider import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Text @@ -38,7 +39,10 @@ fun SettingsItem( .testTag("btn_${text.tagId()}") .fillMaxWidth() .clickable { onClick() } - .padding(20.dp), + .padding( + vertical = 24.dp, + horizontal = 20.dp + ), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { @@ -59,3 +63,14 @@ fun SettingsItem( ) } } + +@Composable +fun SettingsDivider() { + Divider( + modifier = Modifier + .padding( + horizontal = 20.dp + ), + color = MaterialTheme.appColors.divider + ) +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/video/VideoSettingsViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/video/VideoSettingsViewModel.kt index b98ec8709..f0ed7622a 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/video/VideoSettingsViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/video/VideoSettingsViewModel.kt @@ -10,7 +10,7 @@ import kotlinx.coroutines.launch import org.openedx.core.BaseViewModel import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.VideoSettings -import org.openedx.core.presentation.settings.VideoQualityType +import org.openedx.core.presentation.settings.video.VideoQualityType import org.openedx.core.system.notifier.VideoNotifier import org.openedx.core.system.notifier.VideoQualityChanged import org.openedx.profile.presentation.ProfileAnalytics diff --git a/profile/src/main/res/values/strings.xml b/profile/src/main/res/values/strings.xml index efdb04c30..60f0e4060 100644 --- a/profile/src/main/res/values/strings.xml +++ b/profile/src/main/res/values/strings.xml @@ -37,7 +37,28 @@ Contact Support Support Video + Dates & Calendar Wi-fi only download Only download content when wi-fi is turned on + Calendar Sync + Set up calendar sync to show your upcoming assignments and course milestones on your calendar. New assignments and shifted course dates will sync automatically + Set Up Calendar Sync + Calendar Access + To show upcoming assignments and course milestones on your calendar, we need permission to access your calendar. + Grant Calendar Access + New Calendar + Upcoming assignments for active courses will appear on this calendar + Begin Syncing + Calendar Name + Red + Orange + Yellow + Green + Blue + Purple + Brown + Accent + Course Dates + Color From c2684f9aa6be7048316eb0222bd6f3b671854393 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 06/56] 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 | 22 + .../room/discovery/EnrolledCourseEntity.kt | 106 ++- .../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 | 14 + .../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 | 87 +- .../outline/CourseOutlineViewModel.kt | 58 +- .../section/CourseSectionFragment.kt | 52 +- .../course/presentation/ui/CourseUI.kt | 4 +- .../course/presentation/ui/CourseVideosUI.kt | 72 +- .../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 | 4 +- 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, 3380 insertions(+), 499 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/data/model/Progress.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/domain/model/Progress.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 b0c3f211d..4e39a0861 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_NESTED_LIST_ENABLED = "COURSE_NESTED_LIST_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 new file mode 100644 index 000000000..d4813c14c --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/Progress.kt @@ -0,0 +1,22 @@ +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?, +) { + fun mapToDomain() = Progress( + assignmentsCompleted = assignmentsCompleted ?: 0, + totalAssignmentsCount = totalAssignmentsCount ?: 0 + ) + + fun mapToRoomEntity() = ProgressDb( + assignmentsCompleted = assignmentsCompleted ?: 0, + totalAssignmentsCount = totalAssignmentsCount ?: 0 + ) +} 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 05aab3bdd..e019f6300 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,9 +4,19 @@ 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.* +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.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.utils.TimeUtils +import java.util.Date @Entity(tableName = "course_enrolled_table") data class EnrolledCourseEntity( @@ -25,6 +35,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 { @@ -34,7 +50,10 @@ data class EnrolledCourseEntity( mode, isActive, course.mapToDomain(), - certificate?.mapToDomain() + certificate?.mapToDomain(), + progress.mapToDomain(), + courseStatus?.mapToDomain(), + courseAssignments?.mapToDomain() ) } } @@ -79,7 +98,7 @@ data class EnrolledCourseDataDb( @ColumnInfo("videoOutline") val videoOutline: String, @ColumnInfo("isSelfPaced") - val isSelfPaced: Boolean + val isSelfPaced: Boolean, ) { fun mapToDomain(): EnrolledCourseData { return EnrolledCourseData( @@ -119,7 +138,7 @@ data class CoursewareAccessDb( @ColumnInfo("additionalContextUserMessage") val additionalContextUserMessage: String, @ColumnInfo("userFragment") - val userFragment: String + val userFragment: String, ) { fun mapToDomain(): CoursewareAccess { @@ -137,7 +156,7 @@ data class CoursewareAccessDb( data class CertificateDb( @ColumnInfo("certificateURL") - val certificateURL: String? + val certificateURL: String?, ) { fun mapToDomain() = Certificate(certificateURL) } @@ -146,9 +165,82 @@ data class CourseSharingUtmParametersDb( @ColumnInfo("facebook") val facebook: String, @ColumnInfo("twitter") - val twitter: String + val twitter: String, ) { fun mapToDomain() = CourseSharingUtmParameters( facebook, twitter ) -} \ No newline at end of file +} + +data class ProgressDb( + @ColumnInfo("assignments_completed") + val assignmentsCompleted: Int, + @ColumnInfo("total_assignments_count") + val totalAssignmentsCount: Int, +) { + companion object { + val DEFAULT_PROGRESS = ProgressDb(0, 0) + } + + fun mapToDomain() = Progress(assignmentsCompleted, totalAssignmentsCount) +} + +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() = CourseStatus( + lastVisitedModuleId, lastVisitedModulePath, lastVisitedBlockId, lastVisitedUnitDisplayName + ) +} + +data class CourseAssignmentsDb( + @ColumnInfo("futureAssignments") + val futureAssignments: List?, + @ColumnInfo("pastAssignments") + val pastAssignments: List?, +) { + 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 + ) +} 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 new file mode 100644 index 000000000..5d8ea19f8 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/Progress.kt @@ -0,0 +1,14 @@ +package org.openedx.core.domain.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class Progress( + val assignmentsCompleted: Int, + val totalAssignmentsCount: Int, +) : Parcelable { + 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 d77a1ab5e..9ccfaebef 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 668c61935..afbc28243 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. @@ -166,7 +166,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 2d80608ef..a55ca6bc9 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, "") ) } @@ -255,16 +256,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 } @@ -295,9 +302,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( @@ -342,9 +361,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)) @@ -430,7 +451,7 @@ fun DashboardPager( CourseContainerTab.HOME -> { CourseOutlineScreen( windowSize = windowSize, - courseOutlineViewModel = koinViewModel( + viewModel = koinViewModel( parameters = { parametersOf( bundle.getString(CourseContainerFragment.ARG_COURSE_ID, ""), @@ -438,7 +459,6 @@ fun DashboardPager( ) } ), - courseRouter = viewModel.courseRouter, fragmentManager = fragmentManager, onResetDatesClick = { viewModel.onRefresh(CourseContainerTab.DATES) @@ -449,7 +469,7 @@ fun DashboardPager( CourseContainerTab.VIDEOS -> { CourseVideosScreen( windowSize = windowSize, - courseVideoViewModel = koinViewModel( + viewModel = koinViewModel( parameters = { parametersOf( bundle.getString(CourseContainerFragment.ARG_COURSE_ID, ""), @@ -457,14 +477,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, ""), @@ -474,7 +493,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 1ec787e54..bbc26d535 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 5d7f94d47..2591a8f3e 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 3bdc9e622..9903578dc 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 @@ -26,6 +26,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 @@ -61,122 +62,104 @@ 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.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, - isCourseNestedListEnabled = courseOutlineViewModel.isCourseNestedListEnabled, + isCourseNestedListEnabled = viewModel.isCourseNestedListEnabled, uiMessage = uiMessage, onItemClick = { block -> - courseOutlineViewModel.sequentialClickedEvent( + viewModel.sequentialClickedEvent( block.blockId, block.displayName ) - courseRouter.navigateToCourseSubsections( + viewModel.courseRouter.navigateToCourseSubsections( fm = fragmentManager, - courseId = courseOutlineViewModel.courseId, + courseId = viewModel.courseId, subSectionId = block.id, mode = CourseViewMode.FULL ) }, onExpandClick = { block -> - if (courseOutlineViewModel.switchCourseSections(block.id)) { - courseOutlineViewModel.sequentialClickedEvent( + if (viewModel.switchCourseSections(block.id)) { + viewModel.sequentialClickedEvent( block.blockId, block.displayName ) } }, onSubSectionClick = { subSectionBlock -> - courseOutlineViewModel.courseSubSectionUnit[subSectionBlock.id]?.let { unit -> - courseOutlineViewModel.logUnitDetailViewedEvent( + 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 ) } }, onResumeClick = { componentId -> - courseOutlineViewModel.resumeSectionBlock?.let { subSection -> - courseOutlineViewModel.resumeCourseTappedEvent(subSection.id) - courseOutlineViewModel.resumeVerticalBlock?.let { unit -> - if (courseOutlineViewModel.isCourseExpandableSectionsEnabled) { - courseRouter.navigateToCourseContainer( - fm = fragmentManager, - courseId = courseOutlineViewModel.courseId, - unitId = unit.id, - componentId = componentId, - mode = CourseViewMode.FULL - ) - } else { - courseRouter.navigateToCourseSubsections( - fragmentManager, - courseId = courseOutlineViewModel.courseId, - subSectionId = subSection.id, - mode = CourseViewMode.FULL, - unitId = unit.id, - componentId = componentId - ) - } - } - } + viewModel.openBlock( + fragmentManager, + componentId + ) }, onDownloadClick = { - if (courseOutlineViewModel.isBlockDownloading(it.id)) { - courseRouter.navigateToDownloadQueue( + if (viewModel.isBlockDownloading(it.id)) { + viewModel.courseRouter.navigateToDownloadQueue( fm = fragmentManager, - courseOutlineViewModel.getDownloadableChildren(it.id) + viewModel.getDownloadableChildren(it.id) ?: arrayListOf() ) - } else if (courseOutlineViewModel.isBlockDownloaded(it.id)) { - courseOutlineViewModel.removeDownloadModels(it.id) + } else if (viewModel.isBlockDownloaded(it.id)) { + viewModel.removeDownloadModels(it.id) } else { - courseOutlineViewModel.saveDownloadModels( - context.externalCacheDir.toString() + - File.separator + - context - .getString(CoreR.string.app_name) - .replace(Regex("\\s"), "_"), it.id + viewModel.saveDownloadModels( + FileUtil(context).getExternalAppDir().path, it.id ) } }, 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 306498d89..f533410b8 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,12 +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() - val isCourseExpandableSectionsEnabled get() = config.isCourseNestedListEnabled() + private var resumeSectionBlock: Block? = null + private var resumeVerticalBlock: Block? = null + + private val isCourseExpandableSectionsEnabled get() = config.isCourseNestedListEnabled() private val courseSubSections = mutableMapOf>() private val subSectionsDownloadsCount = mutableMapOf() @@ -89,6 +96,10 @@ class CourseOutlineViewModel( updateCourseData() } } + + is CourseOpenBlock -> { + _resumeBlockId.emit(event.blockId) + } } } } @@ -277,6 +288,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, @@ -287,7 +333,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 297545117..6a1a1bf9e 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 @@ -5,14 +5,40 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +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.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material.* +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +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.Close -import androidx.compose.runtime.* +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -40,15 +66,23 @@ import org.openedx.core.domain.model.BlockCounts import org.openedx.core.extension.serializable import org.openedx.core.module.db.DownloadedState import org.openedx.core.presentation.course.CourseViewMode -import org.openedx.core.ui.* +import org.openedx.core.ui.BackBtn +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.WindowSize +import org.openedx.core.ui.WindowType +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.appShapes 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.CardArrow -import java.io.File +import org.openedx.core.R as CoreR class CourseSectionFragment : Fragment() { @@ -100,11 +134,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 ) } } @@ -276,7 +306,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 011003ede..10fa4ef84 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 @@ -121,7 +121,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 @@ -631,7 +631,7 @@ fun CourseSubSectionItem( ) { val icon = 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 iconColor = 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 30706cd6d..6e8f19610 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,100 +76,90 @@ 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, - isCourseNestedListEnabled = courseVideoViewModel.isCourseNestedListEnabled, + courseTitle = viewModel.courseTitle, + isCourseNestedListEnabled = viewModel.isCourseNestedListEnabled, 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 ) } }, onDownloadClick = { - if (courseVideoViewModel.isBlockDownloading(it.id)) { - courseRouter.navigateToDownloadQueue( + if (viewModel.isBlockDownloading(it.id)) { + viewModel.courseRouter.navigateToDownloadQueue( fm = fragmentManager, - courseVideoViewModel.getDownloadableChildren(it.id) + viewModel.getDownloadableChildren(it.id) ?: arrayListOf() ) - } else if (courseVideoViewModel.isBlockDownloaded(it.id)) { - courseVideoViewModel.removeDownloadModels(it.id) + } else if (viewModel.isBlockDownloaded(it.id)) { + viewModel.removeDownloadModels(it.id) } else { - courseVideoViewModel.saveDownloadModels( - context.externalCacheDir.toString() + - File.separator + - context - .getString(org.openedx.core.R.string.app_name) - .replace(Regex("\\s"), "_"), it.id + viewModel.saveDownloadModels( + FileUtil(context).getExternalAppDir().path, it.id ) } }, 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 f5e9be934..b8f2e8fb1 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 6a974cb15..3d6e13094 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 aff60b21e..c20bb07be 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 @@ -154,6 +154,7 @@ class CourseContainerViewModelTest { "", "", "", + "", config, interactor, calendarManager, @@ -187,6 +188,7 @@ class CourseContainerViewModelTest { "", "", "", + "", config, interactor, calendarManager, @@ -220,6 +222,7 @@ class CourseContainerViewModelTest { "", "", "", + "", config, interactor, calendarManager, @@ -252,6 +255,7 @@ class CourseContainerViewModelTest { "", "", "", + "", config, interactor, calendarManager, @@ -287,6 +291,7 @@ class CourseContainerViewModelTest { "", "", "", + "", config, interactor, calendarManager, @@ -317,6 +322,7 @@ class CourseContainerViewModelTest { "", "", "", + "", config, interactor, calendarManager, @@ -347,6 +353,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 f8715e7b3..5e2ed50a4 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" @@ -170,7 +172,8 @@ class CourseDatesViewModelTest { resourceManager, corePreferences, analytics, - config + config, + courseRouter ) coEvery { interactor.getCourseDates(any()) } throws UnknownHostException() val message = async { @@ -198,7 +201,8 @@ class CourseDatesViewModelTest { resourceManager, corePreferences, analytics, - config + config, + courseRouter ) coEvery { interactor.getCourseDates(any()) } throws Exception() val message = async { @@ -226,7 +230,8 @@ class CourseDatesViewModelTest { resourceManager, corePreferences, analytics, - config + config, + courseRouter ) coEvery { interactor.getCourseDates(any()) } returns mockedCourseDatesResult val message = async { @@ -254,7 +259,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 c2b2cff57..45abb10ee 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 @@ -56,6 +56,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 @@ -77,6 +78,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" @@ -233,6 +235,7 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + courseRouter, coreAnalytics, downloadDao, workerController, @@ -267,6 +270,7 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + courseRouter, coreAnalytics, downloadDao, workerController @@ -311,6 +315,7 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + courseRouter, coreAnalytics, downloadDao, workerController @@ -358,6 +363,7 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + courseRouter, coreAnalytics, downloadDao, workerController @@ -404,6 +410,7 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + courseRouter, coreAnalytics, downloadDao, workerController @@ -438,6 +445,7 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + courseRouter, coreAnalytics, downloadDao, workerController @@ -486,6 +494,7 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + courseRouter, coreAnalytics, downloadDao, workerController @@ -529,6 +538,7 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + courseRouter, coreAnalytics, downloadDao, workerController @@ -564,6 +574,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 a2dae8b2e..7962011db 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 @@ -53,6 +53,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) @@ -73,6 +74,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" @@ -197,9 +199,10 @@ class CourseVideoViewModelTest { courseNotifier, videoNotifier, analytics, + courseRouter, coreAnalytics, downloadDao, - workerController + workerController, ) viewModel.getVideos() @@ -228,6 +231,7 @@ class CourseVideoViewModelTest { courseNotifier, videoNotifier, analytics, + courseRouter, coreAnalytics, downloadDao, workerController @@ -267,6 +271,7 @@ class CourseVideoViewModelTest { courseNotifier, videoNotifier, analytics, + courseRouter, coreAnalytics, downloadDao, workerController @@ -308,6 +313,7 @@ class CourseVideoViewModelTest { courseNotifier, videoNotifier, analytics, + courseRouter, coreAnalytics, downloadDao, workerController @@ -344,6 +350,7 @@ class CourseVideoViewModelTest { courseNotifier, videoNotifier, analytics, + courseRouter, coreAnalytics, downloadDao, workerController @@ -384,6 +391,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 e1582bfcf..139652ca6 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 @@ -78,4 +81,3 @@ SOCIAL_AUTH_ENABLED: false #Course navigation feature flags COURSE_NESTED_LIST_ENABLED: false COURSE_UNIT_PROGRESS_ENABLED: false - diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml index f7afc7bed..139652ca6 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 f7afc7bed..139652ca6 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() } From 3df3c05be0755ed9312b7bde7386d131643986f6 Mon Sep 17 00:00:00 2001 From: Omer Habib <30689349+omerhabib26@users.noreply.github.com> Date: Fri, 31 May 2024 16:24:24 +0500 Subject: [PATCH 07/56] fix: update config parsing structure (#319) fix: update config parsing structure - Update dictionary for ProgramConfig - Update UI related feature flags under a single Key - fix image load in course fix: LEARNER-9891 * fix: Updated minor fix --- .../java/org/openedx/core/config/Config.kt | 45 ++++++++----------- .../org/openedx/core/config/ProgramConfig.kt | 2 +- .../java/org/openedx/core/config/UIConfig.kt | 10 +++++ .../dates/CourseDatesViewModel.kt | 2 +- .../outline/CourseOutlineViewModel.kt | 4 +- .../course/presentation/ui/CourseUI.kt | 26 +++++------ .../container/CourseUnitContainerViewModel.kt | 4 +- .../unit/html/HtmlUnitViewModel.kt | 2 +- .../videos/CourseVideoViewModel.kt | 2 +- .../outline/CourseOutlineViewModelTest.kt | 12 ++--- .../videos/CourseVideoViewModelTest.kt | 14 +++--- .../presentation/DashboardListFragment.kt | 2 +- default_config/dev/config.yaml | 10 +++-- default_config/prod/config.yaml | 9 ++-- default_config/stage/config.yaml | 9 ++-- .../discovery/presentation/ui/DiscoveryUI.kt | 4 +- 16 files changed, 81 insertions(+), 76 deletions(-) create mode 100644 core/src/main/java/org/openedx/core/config/UIConfig.kt 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 4e39a0861..57f91ef88 100644 --- a/core/src/main/java/org/openedx/core/config/Config.kt +++ b/core/src/main/java/org/openedx/core/config/Config.kt @@ -10,17 +10,13 @@ import java.io.InputStreamReader class Config(context: Context) { - private var configProperties: JsonObject - - init { - configProperties = try { - val inputStream = context.assets.open("config/config.json") - val parser = JsonParser() - val config = parser.parse(InputStreamReader(inputStream)) - config.asJsonObject - } catch (e: Exception) { - JsonObject() - } + private var configProperties: JsonObject = try { + val inputStream = context.assets.open("config/config.json") + val parser = JsonParser() + val config = parser.parse(InputStreamReader(inputStream)) + config.asJsonObject + } catch (e: Exception) { + JsonObject() } fun getAppId(): String { @@ -28,31 +24,31 @@ class Config(context: Context) { } fun getApiHostURL(): String { - return getString(API_HOST_URL, "") + return getString(API_HOST_URL) } fun getUriScheme(): String { - return getString(URI_SCHEME, "") + return getString(URI_SCHEME) } fun getOAuthClientId(): String { - return getString(OAUTH_CLIENT_ID, "") + return getString(OAUTH_CLIENT_ID) } fun getAccessTokenType(): String { - return getString(TOKEN_TYPE, "") + return getString(TOKEN_TYPE) } fun getFaqUrl(): String { - return getString(FAQ_URL, "") + return getString(FAQ_URL) } fun getFeedbackEmailAddress(): String { - return getString(FEEDBACK_EMAIL_ADDRESS, "") + return getString(FEEDBACK_EMAIL_ADDRESS) } fun getPlatformName(): String { - return getString(PLATFORM_NAME, "") + return getString(PLATFORM_NAME) } fun getAgreement(locale: String): AgreementUrls { @@ -111,15 +107,11 @@ class Config(context: Context) { return getBoolean(PRE_LOGIN_EXPERIENCE_ENABLED, true) } - fun isCourseNestedListEnabled(): Boolean { - return getBoolean(COURSE_NESTED_LIST_ENABLED, false) - } - - fun isCourseUnitProgressEnabled(): Boolean { - return getBoolean(COURSE_UNIT_PROGRESS_ENABLED, false) + fun getCourseUIConfig(): UIConfig { + return getObjectOrNewInstance(UI_COMPONENTS, UIConfig::class.java) } - private fun getString(key: String, defaultValue: String): String { + private fun getString(key: String, defaultValue: String = ""): String { val element = getObject(key) return if (element != null) { element.asString @@ -175,8 +167,7 @@ class Config(context: Context) { private const val PROGRAM = "PROGRAM" private const val DASHBOARD = "DASHBOARD" private const val BRANCH = "BRANCH" - private const val COURSE_NESTED_LIST_ENABLED = "COURSE_NESTED_LIST_ENABLED" - private const val COURSE_UNIT_PROGRESS_ENABLED = "COURSE_UNIT_PROGRESS_ENABLED" + private const val UI_COMPONENTS = "UI_COMPONENTS" private const val PLATFORM_NAME = "PLATFORM_NAME" } diff --git a/core/src/main/java/org/openedx/core/config/ProgramConfig.kt b/core/src/main/java/org/openedx/core/config/ProgramConfig.kt index c553f8997..ce34365ec 100644 --- a/core/src/main/java/org/openedx/core/config/ProgramConfig.kt +++ b/core/src/main/java/org/openedx/core/config/ProgramConfig.kt @@ -16,6 +16,6 @@ data class ProgramConfig( data class ProgramWebViewConfig( @SerializedName("BASE_URL") val programUrl: String = "", - @SerializedName("PROGRAM_DETAIL_URL_TEMPLATE") + @SerializedName("PROGRAM_DETAIL_TEMPLATE") val programDetailUrlTemplate: String = "", ) diff --git a/core/src/main/java/org/openedx/core/config/UIConfig.kt b/core/src/main/java/org/openedx/core/config/UIConfig.kt new file mode 100644 index 000000000..1f8443d27 --- /dev/null +++ b/core/src/main/java/org/openedx/core/config/UIConfig.kt @@ -0,0 +1,10 @@ +package org.openedx.core.config + +import com.google.gson.annotations.SerializedName + +data class UIConfig( + @SerializedName("COURSE_NESTED_LIST_ENABLED") + val isCourseNestedListEnabled: Boolean = false, + @SerializedName("COURSE_UNIT_PROGRESS_ENABLED") + val isCourseUnitProgressEnabled: Boolean = false, +) 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 2591a8f3e..a6a78cb72 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 @@ -77,7 +77,7 @@ class CourseDatesViewModel( private var courseBannerType: CourseBannerType = CourseBannerType.BLANK private var courseStructure: CourseStructure? = null - val isCourseExpandableSectionsEnabled get() = config.isCourseNestedListEnabled() + val isCourseExpandableSectionsEnabled get() = config.getCourseUIConfig().isCourseNestedListEnabled init { viewModelScope.launch { 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 f533410b8..11ec94d95 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 @@ -64,7 +64,7 @@ class CourseOutlineViewModel( workerController, coreAnalytics ) { - val isCourseNestedListEnabled get() = config.isCourseNestedListEnabled() + val isCourseNestedListEnabled get() = config.getCourseUIConfig().isCourseNestedListEnabled private val _uiState = MutableStateFlow(CourseOutlineUIState.Loading) val uiState: StateFlow @@ -81,7 +81,7 @@ class CourseOutlineViewModel( private var resumeSectionBlock: Block? = null private var resumeVerticalBlock: Block? = null - private val isCourseExpandableSectionsEnabled get() = config.isCourseNestedListEnabled() + private val isCourseExpandableSectionsEnabled get() = config.getCourseUIConfig().isCourseNestedListEnabled private val courseSubSections = mutableMapOf>() private val subSectionsDownloadsCount = mutableMapOf() 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 10fa4ef84..6a89c1dc2 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 @@ -103,7 +103,7 @@ fun CourseSectionCard( block: Block, downloadedState: DownloadedState?, onItemClick: (Block) -> Unit, - onDownloadClick: (Block) -> Unit + onDownloadClick: (Block) -> Unit, ) { val iconModifier = Modifier.size(24.dp) @@ -204,7 +204,7 @@ fun OfflineQueueCard( downloadModel: DownloadModel, progressValue: Long, progressSize: Long, - onDownloadClick: (DownloadModel) -> Unit + onDownloadClick: (DownloadModel) -> Unit, ) { val iconModifier = Modifier.size(24.dp) @@ -272,7 +272,7 @@ fun OfflineQueueCard( @Composable fun CardArrow( - degrees: Float + degrees: Float, ) { Icon( imageVector = Icons.Filled.ChevronRight, @@ -304,7 +304,7 @@ fun NavigationUnitsButtons( hasNextBlock: Boolean, isVerticalNavigation: Boolean, onPrevClick: () -> Unit, - onNextClick: () -> Unit + onNextClick: () -> Unit, ) { val nextButtonIcon = if (hasNextBlock) { painterResource(id = coreR.drawable.core_ic_down) @@ -403,7 +403,7 @@ fun HorizontalPageIndicator( completedAndSelectedColor: Color = Color.Green, completedColor: Color = Color.Green, selectedColor: Color = Color.White, - defaultColor: Color = Color.Gray + defaultColor: Color = Color.Gray, ) { Row( horizontalArrangement = Arrangement.spacedBy(1.dp), @@ -468,7 +468,7 @@ fun Indicator( defaultColor: Color, defaultRadius: Dp, selectedSize: Dp, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { val size by animateDpAsState( targetValue = if (isSelected) selectedSize else defaultRadius, @@ -497,7 +497,7 @@ fun VideoSubtitles( showSubtitleLanguage: Boolean, currentIndex: Int, onTranscriptClick: (Caption) -> Unit, - onSettingsClick: () -> Unit + onSettingsClick: () -> Unit, ) { timedTextObject?.let { val autoScrollDelay = 3000L @@ -577,7 +577,7 @@ fun CourseExpandableChapterCard( modifier: Modifier, block: Block, onItemClick: (Block) -> Unit, - arrowDegrees: Float = 0f + arrowDegrees: Float = 0f, ) { Column(modifier = Modifier .clickable { onItemClick(block) } @@ -627,7 +627,7 @@ fun CourseSubSectionItem( downloadedState: DownloadedState?, downloadsCount: Int, onClick: (Block) -> Unit, - onDownloadClick: (Block) -> Unit + onDownloadClick: (Block) -> Unit, ) { val icon = if (block.isCompleted()) painterResource(R.drawable.course_ic_task_alt) else painterResource( @@ -729,7 +729,7 @@ fun CourseSubSectionItem( @Composable fun CourseUnitToolbar( title: String, - onBackClick: () -> Unit + onBackClick: () -> Unit, ) { OpenEdXTheme { Box( @@ -759,7 +759,7 @@ fun SubSectionUnitsTitle( unitName: String, unitsCount: Int, unitsListShowed: Boolean, - onUnitsClick: () -> Unit + onUnitsClick: () -> Unit, ) { val textStyle = MaterialTheme.appTypography.titleMedium val hasUnits = unitsCount > 0 @@ -805,7 +805,7 @@ fun SubSectionUnitsTitle( fun SubSectionUnitsList( unitBlocks: List, selectedUnitIndex: Int = 0, - onUnitClick: (index: Int, unit: Block) -> Unit + onUnitClick: (index: Int, unit: Block) -> Unit, ) { Card( modifier = Modifier @@ -1050,7 +1050,7 @@ fun CourseMessage( icon: Painter, message: String, action: String? = null, - onActionClick: () -> Unit = {} + onActionClick: () -> Unit = {}, ) { Column { Row( diff --git a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt index f479f08c0..61fc896bf 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt @@ -37,9 +37,9 @@ class CourseUnitContainerViewModel( private val blocks = ArrayList() - val isCourseExpandableSectionsEnabled get() = config.isCourseNestedListEnabled() + val isCourseExpandableSectionsEnabled get() = config.getCourseUIConfig().isCourseNestedListEnabled - val isCourseUnitProgressEnabled get() = config.isCourseUnitProgressEnabled() + val isCourseUnitProgressEnabled get() = config.getCourseUIConfig().isCourseUnitProgressEnabled private var currentIndex = 0 private var currentVerticalIndex = 0 diff --git a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitViewModel.kt index c65fcb33e..9d52c979b 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitViewModel.kt @@ -24,7 +24,7 @@ class HtmlUnitViewModel( val injectJSList = _injectJSList.asStateFlow() val isOnline get() = networkConnection.isOnline() - val isCourseUnitProgressEnabled get() = config.isCourseUnitProgressEnabled() + val isCourseUnitProgressEnabled get() = config.getCourseUIConfig().isCourseUnitProgressEnabled val apiHostURL get() = config.getApiHostURL() val cookieManager get() = edxCookieManager 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 b8f2e8fb1..2cf3d8797 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 @@ -53,7 +53,7 @@ class CourseVideoViewModel( coreAnalytics ) { - val isCourseNestedListEnabled get() = config.isCourseNestedListEnabled() + val isCourseNestedListEnabled get() = config.getCourseUIConfig().isCourseNestedListEnabled private val _uiState = MutableStateFlow(CourseVideosUIState.Loading) val uiState: StateFlow 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 45abb10ee..941fbfdac 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 @@ -303,7 +303,7 @@ class CourseOutlineViewModelTest { ) } coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") - every { config.isCourseNestedListEnabled() } returns false + every { config.getCourseUIConfig().isCourseNestedListEnabled } returns false val viewModel = CourseOutlineViewModel( "", @@ -351,7 +351,7 @@ class CourseOutlineViewModelTest { ) } coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") - every { config.isCourseNestedListEnabled() } returns false + every { config.getCourseUIConfig().isCourseNestedListEnabled } returns false val viewModel = CourseOutlineViewModel( "", @@ -398,7 +398,7 @@ class CourseOutlineViewModelTest { ) } coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") - every { config.isCourseNestedListEnabled() } returns false + every { config.getCourseUIConfig().isCourseNestedListEnabled } returns false val viewModel = CourseOutlineViewModel( "", @@ -482,7 +482,7 @@ class CourseOutlineViewModelTest { coEvery { workerController.saveModels(any()) } returns Unit coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") coEvery { downloadDao.readAllData() } returns flow { emit(emptyList()) } - every { config.isCourseNestedListEnabled() } returns false + every { config.getCourseUIConfig().isCourseNestedListEnabled } returns false val viewModel = CourseOutlineViewModel( "", @@ -525,7 +525,7 @@ class CourseOutlineViewModelTest { every { networkConnection.isOnline() } returns true coEvery { workerController.saveModels(any()) } returns Unit coEvery { downloadDao.readAllData() } returns flow { emit(emptyList()) } - every { config.isCourseNestedListEnabled() } returns false + every { config.getCourseUIConfig().isCourseNestedListEnabled } returns false every { coreAnalytics.logEvent(any(), any()) } returns Unit val viewModel = CourseOutlineViewModel( @@ -562,7 +562,7 @@ class CourseOutlineViewModelTest { every { networkConnection.isOnline() } returns false coEvery { workerController.saveModels(any()) } returns Unit coEvery { downloadDao.readAllData() } returns flow { emit(emptyList()) } - every { config.isCourseNestedListEnabled() } returns false + every { config.getCourseUIConfig().isCourseNestedListEnabled } returns false val viewModel = CourseOutlineViewModel( "", 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 7962011db..a15de0583 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 @@ -183,7 +183,7 @@ class CourseVideoViewModelTest { @Test fun `getVideos empty list`() = runTest { - every { config.isCourseNestedListEnabled() } returns false + every { config.getCourseUIConfig().isCourseNestedListEnabled } returns false coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure.copy(blockData = emptyList()) every { downloadDao.readAllData() } returns flow { emit(emptyList()) } @@ -215,7 +215,7 @@ class CourseVideoViewModelTest { @Test fun `getVideos success`() = runTest { - every { config.isCourseNestedListEnabled() } returns false + every { config.getCourseUIConfig().isCourseNestedListEnabled } returns false coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure every { downloadDao.readAllData() } returns flow { emit(emptyList()) } every { preferencesManager.videoSettings } returns VideoSettings.default @@ -248,7 +248,7 @@ class CourseVideoViewModelTest { @Test fun `updateVideos success`() = runTest { - every { config.isCourseNestedListEnabled() } returns false + every { config.getCourseUIConfig().isCourseNestedListEnabled } returns false coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure coEvery { courseNotifier.notifier } returns flow { emit(CourseStructureUpdated("")) @@ -291,7 +291,7 @@ class CourseVideoViewModelTest { @Test fun `setIsUpdating success`() = runTest { - every { config.isCourseNestedListEnabled() } returns false + every { config.getCourseUIConfig().isCourseNestedListEnabled } returns false every { preferencesManager.videoSettings } returns VideoSettings.default coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure coEvery { downloadDao.readAllData() } returns flow { emit(listOf(downloadModelEntity)) } @@ -300,7 +300,7 @@ class CourseVideoViewModelTest { @Test fun `saveDownloadModels test`() = runTest(UnconfinedTestDispatcher()) { - every { config.isCourseNestedListEnabled() } returns false + every { config.getCourseUIConfig().isCourseNestedListEnabled } returns false every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( "", @@ -337,7 +337,7 @@ class CourseVideoViewModelTest { @Test fun `saveDownloadModels only wifi download, with connection`() = runTest(UnconfinedTestDispatcher()) { - every { config.isCourseNestedListEnabled() } returns false + every { config.getCourseUIConfig().isCourseNestedListEnabled } returns false every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( "", @@ -378,7 +378,7 @@ class CourseVideoViewModelTest { @Test fun `saveDownloadModels only wifi download, without connection`() = runTest(UnconfinedTestDispatcher()) { - every { config.isCourseNestedListEnabled() } returns false + every { config.getCourseUIConfig().isCourseNestedListEnabled } returns false every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( "", diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt index 597958e51..127164cc2 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt @@ -391,7 +391,7 @@ private fun CourseItem( ) ) } - val imageUrl = apiHostUrl.dropLast(1) + enrolledCourse.course.courseImage + val imageUrl = apiHostUrl + enrolledCourse.course.courseImage val context = LocalContext.current Surface( modifier = Modifier diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index 139652ca6..8c08df7f6 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -25,8 +25,8 @@ DISCOVERY: PROGRAM: TYPE: 'native' WEBVIEW: - PROGRAM_URL: '' - PROGRAM_DETAIL_URL_TEMPLATE: '' + BASE_URL: '' + PROGRAM_DETAIL_TEMPLATE: '' DASHBOARD: TYPE: 'gallery' @@ -79,5 +79,7 @@ WHATS_NEW_ENABLED: false #feature flag enable Social Login buttons SOCIAL_AUTH_ENABLED: false #Course navigation feature flags -COURSE_NESTED_LIST_ENABLED: false -COURSE_UNIT_PROGRESS_ENABLED: false +UI_COMPONENTS: + COURSE_NESTED_LIST_ENABLED: false + COURSE_UNIT_PROGRESS_ENABLED: false + diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml index 139652ca6..8b1dc8f07 100644 --- a/default_config/prod/config.yaml +++ b/default_config/prod/config.yaml @@ -25,8 +25,8 @@ DISCOVERY: PROGRAM: TYPE: 'native' WEBVIEW: - PROGRAM_URL: '' - PROGRAM_DETAIL_URL_TEMPLATE: '' + BASE_URL: '' + PROGRAM_DETAIL_TEMPLATE: '' DASHBOARD: TYPE: 'gallery' @@ -79,5 +79,6 @@ WHATS_NEW_ENABLED: false #feature flag enable Social Login buttons SOCIAL_AUTH_ENABLED: false #Course navigation feature flags -COURSE_NESTED_LIST_ENABLED: false -COURSE_UNIT_PROGRESS_ENABLED: false +UI_COMPONENTS: + COURSE_NESTED_LIST_ENABLED: false + COURSE_UNIT_PROGRESS_ENABLED: false diff --git a/default_config/stage/config.yaml b/default_config/stage/config.yaml index 139652ca6..8b1dc8f07 100644 --- a/default_config/stage/config.yaml +++ b/default_config/stage/config.yaml @@ -25,8 +25,8 @@ DISCOVERY: PROGRAM: TYPE: 'native' WEBVIEW: - PROGRAM_URL: '' - PROGRAM_DETAIL_URL_TEMPLATE: '' + BASE_URL: '' + PROGRAM_DETAIL_TEMPLATE: '' DASHBOARD: TYPE: 'gallery' @@ -79,5 +79,6 @@ WHATS_NEW_ENABLED: false #feature flag enable Social Login buttons SOCIAL_AUTH_ENABLED: false #Course navigation feature flags -COURSE_NESTED_LIST_ENABLED: false -COURSE_UNIT_PROGRESS_ENABLED: false +UI_COMPONENTS: + COURSE_NESTED_LIST_ENABLED: false + COURSE_UNIT_PROGRESS_ENABLED: false diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/ui/DiscoveryUI.kt b/discovery/src/main/java/org/openedx/discovery/presentation/ui/DiscoveryUI.kt index e1b6645ea..30c2a63d2 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/ui/DiscoveryUI.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/ui/DiscoveryUI.kt @@ -70,7 +70,7 @@ fun ImageHeader( val imageUrl = if (courseImage?.isLinkValid() == true) { courseImage } else { - apiHostUrl.dropLast(1) + courseImage + apiHostUrl + courseImage } Box(modifier = modifier, contentAlignment = Alignment.Center) { AsyncImage( @@ -108,7 +108,7 @@ fun DiscoveryCourseItem( ) } - val imageUrl = apiHostUrl.dropLast(1) + course.media.courseImage?.uri + val imageUrl = apiHostUrl + course.media.courseImage?.uri Surface( modifier = Modifier .testTag("btn_course_card") From 3e9556036ec520965ce6344fe096684e9ab4bea8 Mon Sep 17 00:00:00 2001 From: Omer Habib <30689349+omerhabib26@users.noreply.github.com> Date: Fri, 31 May 2024 16:24:56 +0500 Subject: [PATCH 08/56] feat: delete old videos Directory (#326) feat: delete old videos Directory - Delete all the videos and folders of old app fix: LEARNER-9950 --- .../main/java/org/openedx/app/AppViewModel.kt | 11 +++++ .../app/data/storage/PreferencesManager.kt | 7 +++ .../java/org/openedx/app/di/ScreenModule.kt | 2 +- .../test/java/org/openedx/AppViewModelTest.kt | 35 +++++++++++++-- .../core/data/storage/CorePreferences.kt | 1 + .../java/org/openedx/core/utils/FileUtil.kt | 43 ++++++++++++++++++- .../presentation/DashboardListFragment.kt | 2 +- .../presentation/DashboardListViewModel.kt | 2 +- 8 files changed, 96 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/org/openedx/app/AppViewModel.kt b/app/src/main/java/org/openedx/app/AppViewModel.kt index 1febbd15a..c18e48026 100644 --- a/app/src/main/java/org/openedx/app/AppViewModel.kt +++ b/app/src/main/java/org/openedx/app/AppViewModel.kt @@ -13,6 +13,7 @@ import org.openedx.core.BaseViewModel import org.openedx.core.SingleEventLiveData import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.utils.FileUtil class AppViewModel( private val config: Config, @@ -21,6 +22,7 @@ class AppViewModel( private val preferencesManager: CorePreferences, private val dispatcher: CoroutineDispatcher, private val analytics: AppAnalytics, + private val fileUtil: FileUtil, ) : BaseViewModel() { private val _logoutUser = SingleEventLiveData() @@ -32,10 +34,14 @@ class AppViewModel( private var logoutHandledAt: Long = 0 val isBranchEnabled get() = config.getBranchConfig().enabled + private val canResetAppDirectory get() = preferencesManager.canResetAppDirectory override fun onCreate(owner: LifecycleOwner) { super.onCreate(owner) setUserId() + if (canResetAppDirectory) { + resetAppDirectory() + } viewModelScope.launch { notifier.notifier.collect { event -> if (event is LogoutEvent && System.currentTimeMillis() - logoutHandledAt > 5000) { @@ -60,6 +66,11 @@ class AppViewModel( ) } + private fun resetAppDirectory() { + fileUtil.deleteOldAppDirectory() + preferencesManager.canResetAppDirectory = false + } + private fun setUserId() { preferencesManager.user?.let { analytics.setUserIdForSession(it.id) diff --git a/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt b/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt index 603876d54..e0b65af14 100644 --- a/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt +++ b/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt @@ -152,6 +152,12 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences } get() = getBoolean(APP_WAS_POSITIVE_RATED) + override var canResetAppDirectory: Boolean + set(value) { + saveBoolean(RESET_APP_DIRECTORY, value) + } + get() = getBoolean(RESET_APP_DIRECTORY, true) + override fun setCalendarSyncEventsDialogShown(courseName: String) { saveBoolean(courseName.replaceSpace("_"), true) } @@ -172,5 +178,6 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences private const val VIDEO_SETTINGS_STREAMING_QUALITY = "video_settings_streaming_quality" private const val VIDEO_SETTINGS_DOWNLOAD_QUALITY = "video_settings_download_quality" private const val APP_CONFIG = "app_config" + private const val RESET_APP_DIRECTORY = "reset_app_directory" } } 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 cd3615e26..393b16248 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -67,7 +67,7 @@ import org.openedx.whatsnew.presentation.whatsnew.WhatsNewViewModel val screenModule = module { - viewModel { AppViewModel(get(), get(), get(), get(), get(named("IODispatcher")), get()) } + viewModel { AppViewModel(get(), get(), get(), get(), get(named("IODispatcher")), get(), get()) } viewModel { MainViewModel(get(), get(), get()) } factory { AuthRepository(get(), get(), get()) } diff --git a/app/src/test/java/org/openedx/AppViewModelTest.kt b/app/src/test/java/org/openedx/AppViewModelTest.kt index 40b3e813d..c81c9c2e5 100644 --- a/app/src/test/java/org/openedx/AppViewModelTest.kt +++ b/app/src/test/java/org/openedx/AppViewModelTest.kt @@ -28,6 +28,7 @@ import org.openedx.app.system.notifier.AppNotifier import org.openedx.app.system.notifier.LogoutEvent import org.openedx.core.config.Config import org.openedx.core.data.model.User +import org.openedx.core.utils.FileUtil @ExperimentalCoroutinesApi class AppViewModelTest { @@ -42,6 +43,7 @@ class AppViewModelTest { private val room = mockk() private val preferencesManager = mockk() private val analytics = mockk() + private val fileUtil = mockk() private val user = User(0, "", "", "") @@ -60,8 +62,17 @@ class AppViewModelTest { every { analytics.setUserIdForSession(any()) } returns Unit every { preferencesManager.user } returns user every { notifier.notifier } returns flow { } + every { preferencesManager.canResetAppDirectory } returns false val viewModel = - AppViewModel(config, notifier, room, preferencesManager, dispatcher, analytics) + AppViewModel( + config, + notifier, + room, + preferencesManager, + dispatcher, + analytics, + fileUtil + ) val mockLifeCycleOwner: LifecycleOwner = mockk() val lifecycleRegistry = LifecycleRegistry(mockLifeCycleOwner) @@ -82,8 +93,17 @@ class AppViewModelTest { every { preferencesManager.user } returns user every { room.clearAllTables() } returns Unit every { analytics.logoutEvent(true) } returns Unit + every { preferencesManager.canResetAppDirectory } returns false val viewModel = - AppViewModel(config, notifier, room, preferencesManager, dispatcher, analytics) + AppViewModel( + config, + notifier, + room, + preferencesManager, + dispatcher, + analytics, + fileUtil + ) val mockLifeCycleOwner: LifecycleOwner = mockk() val lifecycleRegistry = LifecycleRegistry(mockLifeCycleOwner) @@ -106,8 +126,17 @@ class AppViewModelTest { every { preferencesManager.user } returns user every { room.clearAllTables() } returns Unit every { analytics.logoutEvent(true) } returns Unit + every { preferencesManager.canResetAppDirectory } returns false val viewModel = - AppViewModel(config, notifier, room, preferencesManager, dispatcher, analytics) + AppViewModel( + config, + notifier, + room, + preferencesManager, + dispatcher, + analytics, + fileUtil + ) val mockLifeCycleOwner: LifecycleOwner = mockk() val lifecycleRegistry = LifecycleRegistry(mockLifeCycleOwner) diff --git a/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt b/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt index 48999ab4e..f9cacbd04 100644 --- a/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt +++ b/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt @@ -11,6 +11,7 @@ interface CorePreferences { var user: User? var videoSettings: VideoSettings var appConfig: AppConfig + var canResetAppDirectory: Boolean fun clear() } 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 2f5c2b2e5..a59317193 100644 --- a/core/src/main/java/org/openedx/core/utils/FileUtil.kt +++ b/core/src/main/java/org/openedx/core/utils/FileUtil.kt @@ -4,6 +4,7 @@ import android.content.Context import com.google.gson.Gson import com.google.gson.GsonBuilder import java.io.File +import java.util.Collections class FileUtil(val context: Context) { @@ -15,7 +16,10 @@ class FileUtil(val context: Context) { return file } - inline fun saveObjectToFile(obj: T, fileName: String = "${T::class.java.simpleName}.json") { + 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) @@ -31,6 +35,43 @@ class FileUtil(val context: Context) { null } } + + /** + * Deletes all the files and directories in the app's external storage directory. + */ + fun deleteOldAppDirectory() { + val externalFilesDir = context.getExternalFilesDir(null) + val externalAppDir = File(externalFilesDir?.parentFile, Directories.VIDEOS.name) + if (externalAppDir.isDirectory) { + deleteRecursive(externalAppDir, Collections.emptyList()) + } + } + + /** + * Deletes a file or directory and all its content recursively. + * + * @param fileOrDirectory The file or directory that needs to be deleted. + * @param exceptions Names of the files or directories that need to be skipped while deletion. + */ + private fun deleteRecursive( + fileOrDirectory: File, + exceptions: List, + ) { + if (exceptions.contains(fileOrDirectory.name)) return + + if (fileOrDirectory.isDirectory) { + val filesList = fileOrDirectory.listFiles() + if (filesList != null) { + for (child in filesList) { + deleteRecursive(child, exceptions) + } + } + } + + // Don't break the recursion upon encountering an error + // noinspection ResultOfMethodCallIgnored + fileOrDirectory.delete() + } } enum class Directories { diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt index 127164cc2..0a7f59c93 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt @@ -381,7 +381,7 @@ private fun CourseItem( apiHostUrl: String, enrolledCourse: EnrolledCourse, windowSize: WindowSize, - onClick: (EnrolledCourse) -> Unit + onClick: (EnrolledCourse) -> Unit, ) { val imageWidth by remember(key1 = windowSize) { mutableStateOf( diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListViewModel.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListViewModel.kt index 812e52f2e..82814561a 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListViewModel.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListViewModel.kt @@ -27,7 +27,7 @@ class DashboardListViewModel( private val resourceManager: ResourceManager, private val discoveryNotifier: DiscoveryNotifier, private val analytics: DashboardAnalytics, - private val appUpgradeNotifier: AppUpgradeNotifier + private val appUpgradeNotifier: AppUpgradeNotifier, ) : BaseViewModel() { private val coursesList = mutableListOf() From 22ee1768769d9eb335cc249f385126c1d2a05fac Mon Sep 17 00:00:00 2001 From: Omer Habib <30689349+omerhabib26@users.noreply.github.com> Date: Thu, 6 Jun 2024 16:11:48 +0500 Subject: [PATCH 09/56] fix: Accessibility issue on courseDashboard (#327) - added content description for views fix: LEARNER-10021 --- .../container/CollapsingLayout.kt | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) 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 64ba858d8..08f6cf96a 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 @@ -53,6 +53,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Density @@ -64,6 +65,7 @@ import androidx.compose.ui.unit.dp import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import org.openedx.core.R import org.openedx.core.ui.RoundTabsBar import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.rememberWindowSize @@ -104,7 +106,8 @@ internal fun CollapsingLayout( val factor = if (rawFactor.isNaN() || rawFactor < 0) 0f else rawFactor val blurImagePadding = 40.dp val blurImagePaddingPx = with(localDensity) { blurImagePadding.toPx() } - val toolbarOffset = (offset.value + backgroundImageHeight.floatValue - blurImagePaddingPx).roundToInt() + val toolbarOffset = + (offset.value + backgroundImageHeight.floatValue - blurImagePaddingPx).roundToInt() val imageStartY = (backgroundImageHeight.floatValue - blurImagePaddingPx) * 0.5f val imageOffsetY = -(offset.value + imageStartY) val toolbarBackgroundOffset = if (toolbarOffset >= 0) { @@ -393,7 +396,12 @@ private fun CollapsingLayoutTablet( Box( modifier = Modifier - .offset { IntOffset(x = 0, y = (backgroundImageHeight.value + expandedTopHeight.value).roundToInt()) } + .offset { + IntOffset( + x = 0, + y = (backgroundImageHeight.value + expandedTopHeight.value).roundToInt() + ) + } .onSizeChanged { size -> navigationHeight.value = size.height.toFloat() }, @@ -516,7 +524,7 @@ private fun CollapsingLayoutMobile( }, imageVector = Icons.AutoMirrored.Filled.ArrowBack, tint = MaterialTheme.appColors.textPrimary, - contentDescription = null + contentDescription = stringResource(id = R.string.core_accessibility_btn_back) ) Spacer(modifier = Modifier.width(8.dp)) Box( @@ -679,7 +687,7 @@ private fun CollapsingLayoutMobile( }, imageVector = Icons.AutoMirrored.Filled.ArrowBack, tint = MaterialTheme.appColors.textPrimary, - contentDescription = null + contentDescription = stringResource(id = R.string.core_accessibility_btn_back) ) Spacer(modifier = Modifier.width(8.dp)) Box( @@ -721,8 +729,14 @@ private fun CollapsingLayoutMobile( @OptIn(ExperimentalFoundationApi::class) @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) -@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, device = "spec:parent=pixel_5,orientation=landscape") -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = "spec:parent=pixel_5,orientation=landscape") +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_NO, + device = "spec:parent=pixel_5,orientation=landscape" +) +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES, + device = "spec:parent=pixel_5,orientation=landscape" +) @Preview(device = Devices.NEXUS_9, uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(device = Devices.NEXUS_9, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable @@ -758,7 +772,7 @@ private fun CollapsingLayoutPreview() { suspend fun PointerInputScope.routePointerChangesTo( onDown: (PointerInputChange) -> Unit = {}, - onUp: (PointerInputChange) -> Unit = {} + onUp: (PointerInputChange) -> Unit = {}, ) { awaitEachGesture { do { @@ -776,7 +790,7 @@ suspend fun PointerInputScope.routePointerChangesTo( @Immutable data class PixelAlignment( val offsetX: Float, - val offsetY: Float + val offsetY: Float, ) : Alignment { override fun align(size: IntSize, space: IntSize, layoutDirection: LayoutDirection): IntOffset { From 1bc1d4c97c496bce20780f5e2d3e2aaf0ab8fbd2 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk <141041606+PavloNetrebchuk@users.noreply.github.com> Date: Thu, 6 Jun 2024 15:01:26 +0300 Subject: [PATCH 10/56] feat: [FC-0047] Course progress and collapsing sections (#323) * feat: Course Home progress bar * feat: Collapsing course sections * feat: New download icons * feat: show CourseContainerFragment if COURSE_NESTED_LIST_ENABLED false * fix: course progress bar updating * feat: Renamed COURSE_NESTED_LIST_ENABLE feature flag * feat: Course home. Moved certificate access. * chore: enhance app theme capability for prod edX theme/branding (#262) chore: enhance app theme capability for prod edX theme/branding - Integrate Program config updates - theming/branding code improvements for light and dark modes - Force dark mode for the WebView (beta version) - No major change in the Open edX theme fixes: LEARNER-9783 * feat: [FC-0047] Calendar main screen and dialogs (#322) * feat: Created calendar setting screen * feat: CalendarAccessDialog * feat: NewCalendarDialog * fix: Fixes according to PR feedback * fix: DiscussionTopicsViewModelTest.kt jUnit test * fix: assignment dates * 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 * fix: Assignment date string * fix: Lint error * fix: Assignment date string * fix: Fixes according to PR feedback * fix: Fixes according to designer feedback * fix: Fixes according to PR feedback --------- Co-authored-by: Volodymyr Chekyrta Co-authored-by: Farhan Arshad <43750646+farhan-arshad-dev@users.noreply.github.com> --- Documentation/ConfigurationManagement.md | 2 +- .../java/org/openedx/core/config/UIConfig.kt | 4 +- .../core/data/model/AssignmentProgress.kt | 26 ++ .../java/org/openedx/core/data/model/Block.kt | 35 +- .../core/data/model/CourseStructureModel.kt | 11 +- .../openedx/core/data/model/room/BlockDb.kt | 70 ++-- .../data/model/room/CourseStructureEntity.kt | 8 +- .../core/domain/model/AssignmentProgress.kt | 7 + .../org/openedx/core/domain/model/Block.kt | 5 +- .../core/domain/model/CourseStructure.kt | 3 +- .../org/openedx/core/domain/model/Progress.kt | 9 + .../org/openedx/core/ui/theme/AppColors.kt | 7 +- .../java/org/openedx/core/ui/theme/Theme.kt | 14 +- .../java/org/openedx/core/utils/TimeUtils.kt | 40 +++ core/src/main/res/values/strings.xml | 19 ++ .../org/openedx/core/ui/theme/Colors.kt | 10 +- .../container/CourseContainerFragment.kt | 5 + .../container/CourseContainerViewModel.kt | 2 +- .../dates/CourseDatesViewModel.kt | 2 +- .../outline/CourseOutlineScreen.kt | 234 +++++++------- .../outline/CourseOutlineViewModel.kt | 21 +- .../section/CourseSectionFragment.kt | 8 +- .../course/presentation/ui/CourseUI.kt | 305 +++++++++++------- .../course/presentation/ui/CourseVideosUI.kt | 147 +++------ .../container/CourseUnitContainerViewModel.kt | 2 +- .../videos/CourseVideoViewModel.kt | 35 +- .../res/drawable/course_download_waiting.png | Bin 0 -> 1945 bytes .../res/drawable/course_ic_start_download.xml | 28 +- course/src/main/res/values/strings.xml | 10 + .../container/CourseContainerViewModelTest.kt | 20 +- .../dates/CourseDatesViewModelTest.kt | 1 + .../outline/CourseOutlineViewModelTest.kt | 34 +- .../section/CourseSectionViewModelTest.kt | 21 +- .../CourseUnitContainerViewModelTest.kt | 26 +- .../videos/CourseVideoViewModelTest.kt | 36 ++- .../presentation/DashboardGalleryView.kt | 13 +- dashboard/src/main/res/values/strings.xml | 12 +- default_config/dev/config.yaml | 1 - .../topics/DiscussionTopicsViewModelTest.kt | 22 +- .../presentation/settings/SettingsScreenUI.kt | 2 +- 40 files changed, 770 insertions(+), 487 deletions(-) create mode 100644 core/src/main/java/org/openedx/core/data/model/AssignmentProgress.kt create mode 100644 core/src/main/java/org/openedx/core/domain/model/AssignmentProgress.kt create mode 100644 course/src/main/res/drawable/course_download_waiting.png diff --git a/Documentation/ConfigurationManagement.md b/Documentation/ConfigurationManagement.md index b1e21a50b..c3786b1d6 100644 --- a/Documentation/ConfigurationManagement.md +++ b/Documentation/ConfigurationManagement.md @@ -88,7 +88,7 @@ android: - **PRE_LOGIN_EXPERIENCE_ENABLED:** Enables the pre login courses discovery experience. - **WHATS_NEW_ENABLED:** Enables the "What's New" feature to present the latest changes to the user. - **SOCIAL_AUTH_ENABLED:** Enables SSO buttons on the SignIn and SignUp screens. -- **COURSE_NESTED_LIST_ENABLED:** Enables an alternative visual representation for the course structure. +- **COURSE_DROPDOWN_NAVIGATION_ENABLED:** Enables an alternative navigation through units. - **COURSE_UNIT_PROGRESS_ENABLED:** Enables the display of the unit progress within the courseware. ## Future Support diff --git a/core/src/main/java/org/openedx/core/config/UIConfig.kt b/core/src/main/java/org/openedx/core/config/UIConfig.kt index 1f8443d27..86c5d6b2b 100644 --- a/core/src/main/java/org/openedx/core/config/UIConfig.kt +++ b/core/src/main/java/org/openedx/core/config/UIConfig.kt @@ -3,8 +3,8 @@ package org.openedx.core.config import com.google.gson.annotations.SerializedName data class UIConfig( - @SerializedName("COURSE_NESTED_LIST_ENABLED") - val isCourseNestedListEnabled: Boolean = false, + @SerializedName("COURSE_DROPDOWN_NAVIGATION_ENABLED") + val isCourseDropdownNavigationEnabled: Boolean = false, @SerializedName("COURSE_UNIT_PROGRESS_ENABLED") val isCourseUnitProgressEnabled: Boolean = false, ) diff --git a/core/src/main/java/org/openedx/core/data/model/AssignmentProgress.kt b/core/src/main/java/org/openedx/core/data/model/AssignmentProgress.kt new file mode 100644 index 000000000..2ac10cb18 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/AssignmentProgress.kt @@ -0,0 +1,26 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.data.model.room.AssignmentProgressDb +import org.openedx.core.domain.model.AssignmentProgress + +data class AssignmentProgress( + @SerializedName("assignment_type") + val assignmentType: String?, + @SerializedName("num_points_earned") + val numPointsEarned: Float?, + @SerializedName("num_points_possible") + val numPointsPossible: Float?, +) { + fun mapToDomain() = AssignmentProgress( + assignmentType = assignmentType ?: "", + numPointsEarned = numPointsEarned ?: 0f, + numPointsPossible = numPointsPossible ?: 0f + ) + + fun mapToRoomEntity() = AssignmentProgressDb( + assignmentType = assignmentType, + numPointsEarned = numPointsEarned, + numPointsPossible = numPointsPossible + ) +} diff --git a/core/src/main/java/org/openedx/core/data/model/Block.kt b/core/src/main/java/org/openedx/core/data/model/Block.kt index 9c07367ac..b5581209f 100644 --- a/core/src/main/java/org/openedx/core/data/model/Block.kt +++ b/core/src/main/java/org/openedx/core/data/model/Block.kt @@ -2,7 +2,12 @@ package org.openedx.core.data.model import com.google.gson.annotations.SerializedName import org.openedx.core.BlockType -import org.openedx.core.domain.model.Block +import org.openedx.core.utils.TimeUtils +import org.openedx.core.domain.model.Block as DomainBlock +import org.openedx.core.domain.model.BlockCounts as DomainBlockCounts +import org.openedx.core.domain.model.EncodedVideos as DomainEncodedVideos +import org.openedx.core.domain.model.StudentViewData as DomainStudentViewData +import org.openedx.core.domain.model.VideoInfo as DomainVideoInfo data class Block( @SerializedName("id") @@ -33,8 +38,12 @@ data class Block( val completion: Double?, @SerializedName("contains_gated_content") val containsGatedContent: Boolean?, + @SerializedName("assignment_progress") + val assignmentProgress: AssignmentProgress?, + @SerializedName("due") + val due: String? ) { - fun mapToDomain(blockData: Map): Block { + fun mapToDomain(blockData: Map): DomainBlock { val blockType = BlockType.getBlockType(type ?: "") val descendantsType = if (blockType == BlockType.VERTICAL) { val types = descendants?.map { descendant -> @@ -46,7 +55,7 @@ data class Block( blockType } - return org.openedx.core.domain.model.Block( + return DomainBlock( id = id ?: "", blockId = blockId ?: "", lmsWebUrl = lmsWebUrl ?: "", @@ -61,7 +70,9 @@ data class Block( studentViewMultiDevice = studentViewMultiDevice ?: false, blockCounts = blockCounts?.mapToDomain()!!, completion = completion ?: 0.0, - containsGatedContent = containsGatedContent ?: false + containsGatedContent = containsGatedContent ?: false, + assignmentProgress = assignmentProgress?.mapToDomain(), + due = TimeUtils.iso8601ToDate(due ?: ""), ) } } @@ -80,8 +91,8 @@ data class StudentViewData( @SerializedName("topic_id") val topicId: String? ) { - fun mapToDomain(): org.openedx.core.domain.model.StudentViewData { - return org.openedx.core.domain.model.StudentViewData( + fun mapToDomain(): DomainStudentViewData { + return DomainStudentViewData( onlyOnWeb = onlyOnWeb ?: false, duration = duration ?: "", transcripts = transcripts, @@ -106,8 +117,8 @@ data class EncodedVideos( var mobileLow: VideoInfo? ) { - fun mapToDomain(): org.openedx.core.domain.model.EncodedVideos { - return org.openedx.core.domain.model.EncodedVideos( + fun mapToDomain(): DomainEncodedVideos { + return DomainEncodedVideos( youtube = videoInfo?.mapToDomain(), hls = hls?.mapToDomain(), fallback = fallback?.mapToDomain(), @@ -124,8 +135,8 @@ data class VideoInfo( @SerializedName("file_size") var fileSize: Int? ) { - fun mapToDomain(): org.openedx.core.domain.model.VideoInfo { - return org.openedx.core.domain.model.VideoInfo( + fun mapToDomain(): DomainVideoInfo { + return DomainVideoInfo( url = url ?: "", fileSize = fileSize ?: 0 ) @@ -136,8 +147,8 @@ data class BlockCounts( @SerializedName("video") var video: Int? ) { - fun mapToDomain(): org.openedx.core.domain.model.BlockCounts { - return org.openedx.core.domain.model.BlockCounts( + fun mapToDomain(): DomainBlockCounts { + return DomainBlockCounts( video = video ?: 0 ) } diff --git a/core/src/main/java/org/openedx/core/data/model/CourseStructureModel.kt b/core/src/main/java/org/openedx/core/data/model/CourseStructureModel.kt index 9f22a14a0..d09411d14 100644 --- a/core/src/main/java/org/openedx/core/data/model/CourseStructureModel.kt +++ b/core/src/main/java/org/openedx/core/data/model/CourseStructureModel.kt @@ -4,6 +4,7 @@ import com.google.gson.annotations.SerializedName import org.openedx.core.data.model.room.BlockDb import org.openedx.core.data.model.room.CourseStructureEntity import org.openedx.core.data.model.room.MediaDb +import org.openedx.core.data.model.room.discovery.ProgressDb import org.openedx.core.domain.model.CourseStructure import org.openedx.core.utils.TimeUtils @@ -35,7 +36,9 @@ data class CourseStructureModel( @SerializedName("certificate") val certificate: Certificate?, @SerializedName("is_self_paced") - var isSelfPaced: Boolean? + var isSelfPaced: Boolean?, + @SerializedName("course_progress") + val progress: Progress?, ) { fun mapToDomain(): CourseStructure { return CourseStructure( @@ -54,7 +57,8 @@ data class CourseStructureModel( coursewareAccess = coursewareAccess?.mapToDomain(), media = media?.mapToDomain(), certificate = certificate?.mapToDomain(), - isSelfPaced = isSelfPaced ?: false + isSelfPaced = isSelfPaced ?: false, + progress = progress?.mapToDomain() ) } @@ -73,7 +77,8 @@ data class CourseStructureModel( coursewareAccess = coursewareAccess?.mapToRoomEntity(), media = MediaDb.createFrom(media), certificate = certificate?.mapToRoomEntity(), - isSelfPaced = isSelfPaced ?: false + isSelfPaced = isSelfPaced ?: false, + progress = progress?.mapToRoomEntity() ?: ProgressDb.DEFAULT_PROGRESS ) } } diff --git a/core/src/main/java/org/openedx/core/data/model/room/BlockDb.kt b/core/src/main/java/org/openedx/core/data/model/room/BlockDb.kt index b1e9a53cf..737437dd0 100644 --- a/core/src/main/java/org/openedx/core/data/model/room/BlockDb.kt +++ b/core/src/main/java/org/openedx/core/data/model/room/BlockDb.kt @@ -3,7 +3,18 @@ package org.openedx.core.data.model.room import androidx.room.ColumnInfo import androidx.room.Embedded import org.openedx.core.BlockType -import org.openedx.core.domain.model.* +import org.openedx.core.data.model.Block +import org.openedx.core.data.model.BlockCounts +import org.openedx.core.data.model.EncodedVideos +import org.openedx.core.data.model.StudentViewData +import org.openedx.core.data.model.VideoInfo +import org.openedx.core.utils.TimeUtils +import org.openedx.core.domain.model.AssignmentProgress as DomainAssignmentProgress +import org.openedx.core.domain.model.Block as DomainBlock +import org.openedx.core.domain.model.BlockCounts as DomainBlockCounts +import org.openedx.core.domain.model.EncodedVideos as DomainEncodedVideos +import org.openedx.core.domain.model.StudentViewData as DomainStudentViewData +import org.openedx.core.domain.model.VideoInfo as DomainVideoInfo data class BlockDb( @ColumnInfo("id") @@ -33,9 +44,13 @@ data class BlockDb( @ColumnInfo("completion") val completion: Double, @ColumnInfo("contains_gated_content") - val containsGatedContent: Boolean + val containsGatedContent: Boolean, + @Embedded + val assignmentProgress: AssignmentProgressDb?, + @ColumnInfo("due") + val due: String? ) { - fun mapToDomain(blocks: List): Block { + fun mapToDomain(blocks: List): DomainBlock { val blockType = BlockType.getBlockType(type) val descendantsType = if (blockType == BlockType.VERTICAL) { val types = descendants.map { descendant -> @@ -47,7 +62,7 @@ data class BlockDb( blockType } - return Block( + return DomainBlock( id = id, blockId = blockId, lmsWebUrl = lmsWebUrl, @@ -62,14 +77,16 @@ data class BlockDb( descendants = descendants, descendantsType = descendantsType, completion = completion, - containsGatedContent = containsGatedContent + containsGatedContent = containsGatedContent, + assignmentProgress = assignmentProgress?.mapToDomain(), + due = TimeUtils.iso8601ToDate(due ?: ""), ) } companion object { fun createFrom( - block: org.openedx.core.data.model.Block + block: Block ): BlockDb { with(block) { return BlockDb( @@ -86,7 +103,9 @@ data class BlockDb( studentViewMultiDevice = studentViewMultiDevice ?: false, blockCounts = BlockCountsDb.createFrom(blockCounts), completion = completion ?: 0.0, - containsGatedContent = containsGatedContent ?: false + containsGatedContent = containsGatedContent ?: false, + assignmentProgress = assignmentProgress?.mapToRoomEntity(), + due = due ) } } @@ -105,8 +124,8 @@ data class StudentViewDataDb( @Embedded val encodedVideos: EncodedVideosDb? ) { - fun mapToDomain(): StudentViewData { - return StudentViewData( + fun mapToDomain(): DomainStudentViewData { + return DomainStudentViewData( onlyOnWeb, duration, transcripts, @@ -117,7 +136,7 @@ data class StudentViewDataDb( companion object { - fun createFrom(studentViewData: org.openedx.core.data.model.StudentViewData?): StudentViewDataDb { + fun createFrom(studentViewData: StudentViewData?): StudentViewDataDb { return StudentViewDataDb( onlyOnWeb = studentViewData?.onlyOnWeb ?: false, duration = studentViewData?.duration.toString(), @@ -144,9 +163,9 @@ data class EncodedVideosDb( @ColumnInfo("mobileLow") var mobileLow: VideoInfoDb? ) { - fun mapToDomain(): EncodedVideos { - return EncodedVideos( - youtube?.mapToDomain(), + fun mapToDomain(): DomainEncodedVideos { + return DomainEncodedVideos( + youtube = youtube?.mapToDomain(), hls = hls?.mapToDomain(), fallback = fallback?.mapToDomain(), desktopMp4 = desktopMp4?.mapToDomain(), @@ -156,7 +175,7 @@ data class EncodedVideosDb( } companion object { - fun createFrom(encodedVideos: org.openedx.core.data.model.EncodedVideos?): EncodedVideosDb { + fun createFrom(encodedVideos: EncodedVideos?): EncodedVideosDb { return EncodedVideosDb( youtube = VideoInfoDb.createFrom(encodedVideos?.videoInfo), hls = VideoInfoDb.createFrom(encodedVideos?.hls), @@ -176,10 +195,10 @@ data class VideoInfoDb( @ColumnInfo("fileSize") val fileSize: Int ) { - fun mapToDomain() = VideoInfo(url, fileSize) + fun mapToDomain() = DomainVideoInfo(url, fileSize) companion object { - fun createFrom(videoInfo: org.openedx.core.data.model.VideoInfo?): VideoInfoDb? { + fun createFrom(videoInfo: VideoInfo?): VideoInfoDb? { if (videoInfo == null) return null return VideoInfoDb( videoInfo.url ?: "", @@ -193,11 +212,26 @@ data class BlockCountsDb( @ColumnInfo("video") val video: Int ) { - fun mapToDomain() = BlockCounts(video) + fun mapToDomain() = DomainBlockCounts(video) companion object { - fun createFrom(blocksCounts: org.openedx.core.data.model.BlockCounts?): BlockCountsDb { + fun createFrom(blocksCounts: BlockCounts?): BlockCountsDb { return BlockCountsDb(blocksCounts?.video ?: 0) } } } + +data class AssignmentProgressDb( + @ColumnInfo("assignment_type") + val assignmentType: String?, + @ColumnInfo("num_points_earned") + val numPointsEarned: Float?, + @ColumnInfo("num_points_possible") + val numPointsPossible: Float?, +) { + fun mapToDomain() = DomainAssignmentProgress( + assignmentType = assignmentType ?: "", + numPointsEarned = numPointsEarned ?: 0f, + numPointsPossible = numPointsPossible ?: 0f + ) +} diff --git a/core/src/main/java/org/openedx/core/data/model/room/CourseStructureEntity.kt b/core/src/main/java/org/openedx/core/data/model/room/CourseStructureEntity.kt index 90352d821..49862d683 100644 --- a/core/src/main/java/org/openedx/core/data/model/room/CourseStructureEntity.kt +++ b/core/src/main/java/org/openedx/core/data/model/room/CourseStructureEntity.kt @@ -6,6 +6,7 @@ import androidx.room.Entity import androidx.room.PrimaryKey import org.openedx.core.data.model.room.discovery.CertificateDb import org.openedx.core.data.model.room.discovery.CoursewareAccessDb +import org.openedx.core.data.model.room.discovery.ProgressDb import org.openedx.core.domain.model.CourseStructure import org.openedx.core.utils.TimeUtils @@ -39,7 +40,9 @@ data class CourseStructureEntity( @Embedded val certificate: CertificateDb?, @ColumnInfo("isSelfPaced") - val isSelfPaced: Boolean + val isSelfPaced: Boolean, + @Embedded + val progress: ProgressDb, ) { fun mapToDomain(): CourseStructure { @@ -57,7 +60,8 @@ data class CourseStructureEntity( coursewareAccess?.mapToDomain(), media?.mapToDomain(), certificate?.mapToDomain(), - isSelfPaced + isSelfPaced, + progress.mapToDomain() ) } diff --git a/core/src/main/java/org/openedx/core/domain/model/AssignmentProgress.kt b/core/src/main/java/org/openedx/core/domain/model/AssignmentProgress.kt new file mode 100644 index 000000000..659665bfe --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/AssignmentProgress.kt @@ -0,0 +1,7 @@ +package org.openedx.core.domain.model + +data class AssignmentProgress( + val assignmentType: String, + val numPointsEarned: Float, + val numPointsPossible: Float +) diff --git a/core/src/main/java/org/openedx/core/domain/model/Block.kt b/core/src/main/java/org/openedx/core/domain/model/Block.kt index 2f1766ecb..460f283ba 100644 --- a/core/src/main/java/org/openedx/core/domain/model/Block.kt +++ b/core/src/main/java/org/openedx/core/domain/model/Block.kt @@ -7,6 +7,7 @@ import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.db.FileType import org.openedx.core.utils.VideoUtil +import java.util.Date data class Block( @@ -25,7 +26,9 @@ data class Block( val descendantsType: BlockType, val completion: Double, val containsGatedContent: Boolean = false, - val downloadModel: DownloadModel? = null + val downloadModel: DownloadModel? = null, + val assignmentProgress: AssignmentProgress?, + val due: Date? ) { val isDownloadable: Boolean get() { diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseStructure.kt b/core/src/main/java/org/openedx/core/domain/model/CourseStructure.kt index bdb3820de..4ba3a8419 100644 --- a/core/src/main/java/org/openedx/core/domain/model/CourseStructure.kt +++ b/core/src/main/java/org/openedx/core/domain/model/CourseStructure.kt @@ -16,5 +16,6 @@ data class CourseStructure( val coursewareAccess: CoursewareAccess?, val media: Media?, val certificate: Certificate?, - val isSelfPaced: Boolean + val isSelfPaced: Boolean, + val progress: Progress?, ) 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 5d8ea19f8..800a9c292 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 @@ -1,6 +1,7 @@ package org.openedx.core.domain.model import android.os.Parcelable +import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize @Parcelize @@ -8,6 +9,14 @@ data class Progress( val assignmentsCompleted: Int, val totalAssignmentsCount: Int, ) : Parcelable { + + @IgnoredOnParcel + val value: Float = try { + assignmentsCompleted.toFloat() / totalAssignmentsCount.toFloat() + } catch (_: ArithmeticException) { + 0f + } + companion object { val DEFAULT_PROGRESS = Progress(0, 0) } diff --git a/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt b/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt index 968fd9fe3..625c52b27 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt @@ -50,7 +50,7 @@ data class AppColors( val inactiveButtonBackground: Color, val inactiveButtonText: Color, - val accessGreen: Color, + val successGreen: Color, val datesSectionBarPastDue: Color, val datesSectionBarToday: Color, @@ -73,7 +73,10 @@ data class AppColors( val courseHomeHeaderShade: Color, val courseHomeBackBtnBackground: Color, - val settingsTitleContent: Color + val settingsTitleContent: Color, + + val progressBarColor: Color, + val progressBarBackgroundColor: Color ) { val primary: Color get() = material.primary val primaryVariant: Color get() = material.primaryVariant diff --git a/core/src/main/java/org/openedx/core/ui/theme/Theme.kt b/core/src/main/java/org/openedx/core/ui/theme/Theme.kt index 291192c1c..88c973105 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/Theme.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/Theme.kt @@ -68,7 +68,7 @@ private val DarkColorPalette = AppColors( inactiveButtonBackground = dark_inactive_button_background, inactiveButtonText = dark_primary_button_text, - accessGreen = dark_access_green, + successGreen = dark_success_green, datesSectionBarPastDue = dark_dates_section_bar_past_due, datesSectionBarToday = dark_dates_section_bar_today, @@ -91,7 +91,10 @@ private val DarkColorPalette = AppColors( courseHomeHeaderShade = dark_course_home_header_shade, courseHomeBackBtnBackground = dark_course_home_back_btn_background, - settingsTitleContent = dark_settings_title_content + settingsTitleContent = dark_settings_title_content, + + progressBarColor = dark_progress_bar_color, + progressBarBackgroundColor = dark_progress_bar_background_color ) private val LightColorPalette = AppColors( @@ -152,7 +155,7 @@ private val LightColorPalette = AppColors( inactiveButtonBackground = light_inactive_button_background, inactiveButtonText = light_primary_button_text, - accessGreen = light_access_green, + successGreen = light_success_green, datesSectionBarPastDue = light_dates_section_bar_past_due, datesSectionBarToday = light_dates_section_bar_today, @@ -175,7 +178,10 @@ private val LightColorPalette = AppColors( courseHomeHeaderShade = light_course_home_header_shade, courseHomeBackBtnBackground = light_course_home_back_btn_background, - settingsTitleContent = light_settings_title_content + settingsTitleContent = light_settings_title_content, + + progressBarColor = light_progress_bar_color, + progressBarBackgroundColor = light_progress_bar_background_color ) val MaterialTheme.appColors: AppColors 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 9ccfaebef..5327b8cf5 100644 --- a/core/src/main/java/org/openedx/core/utils/TimeUtils.kt +++ b/core/src/main/java/org/openedx/core/utils/TimeUtils.kt @@ -242,6 +242,46 @@ object TimeUtils { } } + fun getAssignmentFormattedDate(context: Context, date: Date): String { + val inputDate = Calendar.getInstance().also { + it.time = date + it.clearTimeComponents() + } + val daysDifference = getDayDifference(inputDate) + + return when { + daysDifference == 0 -> { + context.getString(R.string.core_date_format_assignment_due_today) + } + + daysDifference == 1 -> { + context.getString(R.string.core_date_format_assignment_due_tomorrow) + } + + daysDifference == -1 -> { + context.getString(R.string.core_date_format_assignment_due_yesterday) + } + + daysDifference <= -2 -> { + val numberOfDays = ceil(-daysDifference.toDouble()).toInt() + context.resources.getQuantityString( + R.plurals.core_date_format_assignment_due_days_ago, + numberOfDays, + numberOfDays + ) + } + + else -> { + val numberOfDays = ceil(daysDifference.toDouble()).toInt() + context.resources.getQuantityString( + R.plurals.core_date_format_assignment_due_in, + numberOfDays, + numberOfDays + ) + } + } + } + /** * Returns the number of days difference between the given date and the current date. */ diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index afbc28243..580d262ac 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -96,6 +96,25 @@ Tomorrow Yesterday %1$s days ago + Due Today + Due Tomorrow + Due Yesterday + + Due %1$d days ago + Due %1$d day ago + Due %1$d days ago + Due %1$d days ago + Due %1$d days ago + Due %1$d days ago + + + Due in %1$d days + Due in %1$d day + Due in %1$d days + Due in %1$d days + Due in %1$d days + Due in %1$d days + %d Item Hidden %d Items Hidden diff --git a/core/src/openedx/org/openedx/core/ui/theme/Colors.kt b/core/src/openedx/org/openedx/core/ui/theme/Colors.kt index 0bb1114c8..855d557d3 100644 --- a/core/src/openedx/org/openedx/core/ui/theme/Colors.kt +++ b/core/src/openedx/org/openedx/core/ui/theme/Colors.kt @@ -51,7 +51,7 @@ val light_warning = Color(0xFFFFC94D) val light_info = Color(0xFF42AAFF) val light_rate_stars = Color(0xFFFFC94D) val light_inactive_button_background = Color(0xFFCCD4E0) -val light_access_green = Color(0xFF23BCA0) +val light_success_green = Color(0xFF198571) val light_dates_section_bar_past_due = light_warning val light_dates_section_bar_today = light_info val light_dates_section_bar_this_week = light_text_primary_variant @@ -70,9 +70,11 @@ val light_tab_selected_btn_content = Color.White val light_course_home_header_shade = Color(0xFFBABABA) val light_course_home_back_btn_background = Color.White val light_settings_title_content = Color.White +val light_progress_bar_color = light_primary +val light_progress_bar_background_color = Color(0xFF97A5BB) -val dark_primary = Color(0xFF5478F9) +val dark_primary = Color(0xFF3F68F8) val dark_primary_variant = Color(0xFF3700B3) val dark_secondary = Color(0xFF03DAC6) val dark_secondary_variant = Color(0xFF373E4F) @@ -121,7 +123,7 @@ val dark_info_variant = Color(0xFF5478F9) val dark_onInfo = Color.White val dark_rate_stars = Color(0xFFFFC94D) val dark_inactive_button_background = Color(0xFFCCD4E0) -val dark_access_green = Color(0xFF23BCA0) +val dark_success_green = Color(0xFF198571) val dark_dates_section_bar_past_due = dark_warning val dark_dates_section_bar_today = dark_info val dark_dates_section_bar_this_week = dark_text_primary_variant @@ -140,3 +142,5 @@ val dark_tab_selected_btn_content = Color.White val dark_course_home_header_shade = Color(0xFF999999) val dark_course_home_back_btn_background = Color.Black val dark_settings_title_content = Color.White +val dark_progress_bar_color = light_primary +val dark_progress_bar_background_color = Color(0xFF8E9BAE) 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 a55ca6bc9..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 @@ -113,6 +113,11 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { observe() } + override fun onResume() { + super.onResume() + viewModel.updateData() + } + override fun onDestroyView() { snackBar?.dismiss() super.onDestroyView() 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 bbc26d535..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 @@ -169,7 +169,7 @@ class CourseContainerViewModel( _showProgress.value = true viewModelScope.launch { try { - val courseStructure = interactor.getCourseStructure(courseId) + val courseStructure = interactor.getCourseStructure(courseId, true) courseName = courseStructure.name _organization = courseStructure.org _isSelfPaced = courseStructure.isSelfPaced 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 a6a78cb72..4d6236b67 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 @@ -77,7 +77,7 @@ class CourseDatesViewModel( private var courseBannerType: CourseBannerType = CourseBannerType.BLANK private var courseStructure: CourseStructure? = null - val isCourseExpandableSectionsEnabled get() = config.getCourseUIConfig().isCourseNestedListEnabled + val isCourseExpandableSectionsEnabled get() = config.getCourseUIConfig().isCourseDropdownNavigationEnabled init { viewModelScope.launch { 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 9903578dc..1a29819f2 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 @@ -2,7 +2,6 @@ package org.openedx.course.presentation.outline import android.content.res.Configuration.UI_MODE_NIGHT_NO import android.content.res.Configuration.UI_MODE_NIGHT_YES -import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -17,9 +16,9 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.Divider +import androidx.compose.foundation.shape.CircleShape 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 @@ -33,9 +32,11 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.AndroidUriHandler import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Devices @@ -45,11 +46,13 @@ import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentManager import org.openedx.core.BlockType import org.openedx.core.UIMessage +import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts import org.openedx.core.domain.model.CourseDatesBannerInfo import org.openedx.core.domain.model.CourseStructure import org.openedx.core.domain.model.CoursewareAccess +import org.openedx.core.domain.model.Progress import org.openedx.core.extension.takeIfNotEmpty import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.ui.HandleUIMessage @@ -66,10 +69,8 @@ import org.openedx.core.utils.FileUtil import org.openedx.course.R import org.openedx.course.presentation.ui.CourseDatesBanner import org.openedx.course.presentation.ui.CourseDatesBannerTablet -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 org.openedx.course.presentation.ui.CourseSection import java.util.Date import org.openedx.core.R as CoreR @@ -94,20 +95,7 @@ fun CourseOutlineScreen( CourseOutlineUI( windowSize = windowSize, uiState = uiState, - isCourseNestedListEnabled = viewModel.isCourseNestedListEnabled, uiMessage = uiMessage, - onItemClick = { block -> - viewModel.sequentialClickedEvent( - block.blockId, - block.displayName - ) - viewModel.courseRouter.navigateToCourseSubsections( - fm = fragmentManager, - courseId = viewModel.courseId, - subSectionId = block.id, - mode = CourseViewMode.FULL - ) - }, onExpandClick = { block -> if (viewModel.switchCourseSections(block.id)) { viewModel.sequentialClickedEvent( @@ -117,15 +105,28 @@ fun CourseOutlineScreen( } }, onSubSectionClick = { subSectionBlock -> - viewModel.courseSubSectionUnit[subSectionBlock.id]?.let { unit -> - viewModel.logUnitDetailViewedEvent( - unit.blockId, - unit.displayName + if (viewModel.isCourseNestedListEnabled) { + viewModel.courseSubSectionUnit[subSectionBlock.id]?.let { unit -> + viewModel.logUnitDetailViewedEvent( + unit.blockId, + unit.displayName + ) + viewModel.courseRouter.navigateToCourseContainer( + fragmentManager, + courseId = viewModel.courseId, + unitId = unit.id, + mode = CourseViewMode.FULL + ) + } + } else { + viewModel.sequentialClickedEvent( + subSectionBlock.blockId, + subSectionBlock.displayName ) - viewModel.courseRouter.navigateToCourseContainer( - fragmentManager, + viewModel.courseRouter.navigateToCourseSubsections( + fm = fragmentManager, courseId = viewModel.courseId, - unitId = unit.id, + subSectionId = subSectionBlock.id, mode = CourseViewMode.FULL ) } @@ -136,19 +137,21 @@ fun CourseOutlineScreen( componentId ) }, - onDownloadClick = { - if (viewModel.isBlockDownloading(it.id)) { - viewModel.courseRouter.navigateToDownloadQueue( - fm = fragmentManager, - viewModel.getDownloadableChildren(it.id) - ?: arrayListOf() - ) - } else if (viewModel.isBlockDownloaded(it.id)) { - viewModel.removeDownloadModels(it.id) - } else { - viewModel.saveDownloadModels( - FileUtil(context).getExternalAppDir().path, it.id - ) + onDownloadClick = { blocksIds -> + blocksIds.forEach { blockId -> + if (viewModel.isBlockDownloading(blockId)) { + viewModel.courseRouter.navigateToDownloadQueue( + fm = fragmentManager, + viewModel.getDownloadableChildren(blockId) + ?: arrayListOf() + ) + } else if (viewModel.isBlockDownloaded(blockId)) { + viewModel.removeDownloadModels(blockId) + } else { + viewModel.saveDownloadModels( + FileUtil(context).getExternalAppDir().path, blockId + ) + } } }, onResetDatesClick = { @@ -170,13 +173,11 @@ fun CourseOutlineScreen( private fun CourseOutlineUI( windowSize: WindowSize, uiState: CourseOutlineUIState, - isCourseNestedListEnabled: Boolean, uiMessage: UIMessage?, - onItemClick: (Block) -> Unit, onExpandClick: (Block) -> Unit, onSubSectionClick: (Block) -> Unit, onResumeClick: (String) -> Unit, - onDownloadClick: (Block) -> Unit, + onDownloadClick: (blockIds: List) -> Unit, onResetDatesClick: () -> Unit, onCertificateClick: (String) -> Unit, ) { @@ -278,6 +279,19 @@ private fun CourseOutlineUI( } } + + val progress = uiState.courseStructure.progress + if (progress != null && progress.totalAssignmentsCount > 0) { + item { + CourseProgress( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp, start = 24.dp, end = 24.dp), + progress = progress + ) + } + } + if (uiState.resumeComponent != null) { item { Box(listPadding) { @@ -298,75 +312,26 @@ private fun CourseOutlineUI( } } - if (isCourseNestedListEnabled) { - uiState.courseStructure.blockData.forEach { section -> - val courseSubSections = - uiState.courseSubSections[section.id] - val courseSectionsState = - uiState.courseSectionsState[section.id] - - item { - Column { - CourseExpandableChapterCard( - modifier = listPadding, - block = section, - onItemClick = onExpandClick, - arrowDegrees = if (courseSectionsState == true) -90f else 90f - ) - Divider() - } - } - - courseSubSections?.forEach { subSectionBlock -> - item { - Column { - AnimatedVisibility( - visible = courseSectionsState == true - ) { - Column { - val downloadsCount = - uiState.subSectionsDownloadsCount[subSectionBlock.id] - ?: 0 - - CourseSubSectionItem( - modifier = listPadding, - block = subSectionBlock, - downloadedState = uiState.downloadedState[subSectionBlock.id], - downloadsCount = downloadsCount, - onClick = onSubSectionClick, - onDownloadClick = onDownloadClick - ) - Divider() - } - } - } - } - } - } - return@LazyColumn + item { + Spacer(modifier = Modifier.height(12.dp)) } + uiState.courseStructure.blockData.forEach { section -> + val courseSubSections = + uiState.courseSubSections[section.id] + val courseSectionsState = + uiState.courseSectionsState[section.id] - items(uiState.courseStructure.blockData) { block -> - Column(listPadding) { - if (block.type == BlockType.CHAPTER) { - Text( - modifier = Modifier.padding( - top = 36.dp, - bottom = 8.dp - ), - text = block.displayName, - style = MaterialTheme.appTypography.titleMedium, - color = MaterialTheme.appColors.textPrimaryVariant - ) - } else { - CourseSectionCard( - block = block, - downloadedState = uiState.downloadedState[block.id], - onItemClick = onItemClick, - onDownloadClick = onDownloadClick - ) - Divider() - } + item { + CourseSection( + modifier = listPadding.padding(vertical = 4.dp), + block = section, + onItemClick = onExpandClick, + courseSectionsState = courseSectionsState, + courseSubSections = courseSubSections, + downloadedStateMap = uiState.downloadedState, + onSubSectionClick = onSubSectionClick, + onDownloadClick = onDownloadClick + ) } } } @@ -490,6 +455,37 @@ private fun ResumeCourseTablet( } } +@Composable +private fun CourseProgress( + modifier: Modifier = Modifier, + progress: Progress +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(10.dp) + .clip(CircleShape), + progress = progress.value, + color = MaterialTheme.appColors.progressBarColor, + backgroundColor = MaterialTheme.appColors.progressBarBackgroundColor + ) + Text( + text = pluralStringResource( + R.plurals.course_assignments_complete, + progress.assignmentsCompleted, + progress.assignmentsCompleted, + progress.totalAssignmentsCount + ), + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.labelSmall + ) + } +} + fun getUnitBlockIcon(block: Block): Int { return when (block.type) { BlockType.VIDEO -> R.drawable.ic_course_video @@ -521,9 +517,7 @@ private fun CourseOutlineScreenPreview() { hasEnded = false ) ), - isCourseNestedListEnabled = true, uiMessage = null, - onItemClick = {}, onExpandClick = {}, onSubSectionClick = {}, onResumeClick = {}, @@ -556,9 +550,7 @@ private fun CourseOutlineScreenTabletPreview() { hasEnded = false ) ), - isCourseNestedListEnabled = true, uiMessage = null, - onItemClick = {}, onExpandClick = {}, onSubSectionClick = {}, onResumeClick = {}, @@ -578,6 +570,11 @@ private fun ResumeCoursePreview() { } } +private val mockAssignmentProgress = AssignmentProgress( + assignmentType = "Home", + numPointsEarned = 1f, + numPointsPossible = 3f +) private val mockChapterBlock = Block( id = "id", blockId = "blockId", @@ -593,7 +590,9 @@ private val mockChapterBlock = Block( descendants = emptyList(), descendantsType = BlockType.CHAPTER, completion = 0.0, - containsGatedContent = false + containsGatedContent = false, + assignmentProgress = mockAssignmentProgress, + due = Date() ) private val mockSequentialBlock = Block( id = "id", @@ -610,7 +609,9 @@ private val mockSequentialBlock = Block( descendants = emptyList(), descendantsType = BlockType.CHAPTER, completion = 0.0, - containsGatedContent = false + containsGatedContent = false, + assignmentProgress = mockAssignmentProgress, + due = Date() ) private val mockCourseStructure = CourseStructure( @@ -634,5 +635,6 @@ private val mockCourseStructure = CourseStructure( ), media = null, certificate = null, - isSelfPaced = false + isSelfPaced = false, + progress = Progress(1, 3) ) 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 11ec94d95..602c7d2fa 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 @@ -64,7 +64,7 @@ class CourseOutlineViewModel( workerController, coreAnalytics ) { - val isCourseNestedListEnabled get() = config.getCourseUIConfig().isCourseNestedListEnabled + val isCourseNestedListEnabled get() = config.getCourseUIConfig().isCourseDropdownNavigationEnabled private val _uiState = MutableStateFlow(CourseOutlineUIState.Loading) val uiState: StateFlow @@ -81,7 +81,7 @@ class CourseOutlineViewModel( private var resumeSectionBlock: Block? = null private var resumeVerticalBlock: Block? = null - private val isCourseExpandableSectionsEnabled get() = config.getCourseUIConfig().isCourseNestedListEnabled + private val isCourseExpandableSectionsEnabled get() = config.getCourseUIConfig().isCourseDropdownNavigationEnabled private val courseSubSections = mutableMapOf>() private val subSectionsDownloadsCount = mutableMapOf() @@ -239,17 +239,12 @@ class CourseOutlineViewModel( resultBlocks.add(block) block.descendants.forEach { descendant -> blocks.find { it.id == descendant }?.let { sequentialBlock -> - if (isCourseNestedListEnabled) { - courseSubSections.getOrPut(block.id) { mutableListOf() } - .add(sequentialBlock) - courseSubSectionUnit[sequentialBlock.id] = - sequentialBlock.getFirstDescendantBlock(blocks) - subSectionsDownloadsCount[sequentialBlock.id] = - sequentialBlock.getDownloadsCount(blocks) - - } else { - resultBlocks.add(sequentialBlock) - } + courseSubSections.getOrPut(block.id) { mutableListOf() } + .add(sequentialBlock) + courseSubSectionUnit[sequentialBlock.id] = + sequentialBlock.getFirstDescendantBlock(blocks) + subSectionsDownloadsCount[sequentialBlock.id] = + sequentialBlock.getDownloadsCount(blocks) addDownloadableChildrenForSequentialBlock(sequentialBlock) } } 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 6a1a1bf9e..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 @@ -61,6 +61,7 @@ import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.core.BlockType import org.openedx.core.UIMessage +import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts import org.openedx.core.extension.serializable @@ -78,10 +79,13 @@ 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() { @@ -476,5 +480,7 @@ private val mockBlock = Block( descendants = emptyList(), descendantsType = BlockType.HTML, completion = 0.0, - containsGatedContent = false + containsGatedContent = false, + assignmentProgress = AssignmentProgress("", 1f, 2f), + due = Date() ) 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 6a89c1dc2..27da57afb 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 @@ -1,8 +1,10 @@ package org.openedx.course.presentation.ui import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image @@ -44,12 +46,15 @@ import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.CloudDone import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -58,7 +63,9 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics @@ -72,6 +79,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import org.jsoup.Jsoup import org.openedx.core.BlockType +import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts import org.openedx.core.domain.model.CourseDatesBannerInfo @@ -90,6 +98,7 @@ 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.course.R import org.openedx.course.presentation.dates.mockedCourseBannerInfo import org.openedx.course.presentation.outline.getUnitBlockIcon @@ -276,7 +285,7 @@ fun CardArrow( ) { Icon( imageVector = Icons.Filled.ChevronRight, - tint = MaterialTheme.appColors.primary, + tint = MaterialTheme.appColors.textDark, contentDescription = "Expandable Arrow", modifier = Modifier.rotate(degrees), ) @@ -573,81 +582,181 @@ fun VideoSubtitles( } @Composable -fun CourseExpandableChapterCard( - modifier: Modifier, +fun CourseSection( + modifier: Modifier = Modifier, block: Block, onItemClick: (Block) -> Unit, + courseSectionsState: Boolean?, + courseSubSections: List?, + downloadedStateMap: Map, + onSubSectionClick: (Block) -> Unit, + onDownloadClick: (blocksIds: List) -> Unit, +) { + val arrowRotation by animateFloatAsState( + targetValue = if (courseSectionsState == true) -90f else 90f, label = "" + ) + val sectionIds = courseSubSections?.map { it.id }.orEmpty() + val filteredStatuses = downloadedStateMap.filterKeys { it in sectionIds }.values + val downloadedState = when { + filteredStatuses.isEmpty() -> null + filteredStatuses.all { it.isDownloaded } -> DownloadedState.DOWNLOADED + filteredStatuses.any { it.isWaitingOrDownloading } -> DownloadedState.DOWNLOADING + else -> DownloadedState.NOT_DOWNLOADED + } + + Column(modifier = modifier + .clip(MaterialTheme.appShapes.cardShape) + .noRippleClickable { onItemClick(block) } + .background(MaterialTheme.appColors.cardViewBackground) + .border( + 1.dp, + MaterialTheme.appColors.cardViewBorder, + MaterialTheme.appShapes.cardShape + ) + ) { + CourseExpandableChapterCard( + block = block, + arrowDegrees = arrowRotation, + downloadedState = downloadedState, + onDownloadClick = { + onDownloadClick(downloadedStateMap.keys.toList()) + } + ) + courseSubSections?.forEach { subSectionBlock -> + AnimatedVisibility( + visible = courseSectionsState == true + ) { + CourseSubSectionItem( + block = subSectionBlock, + onClick = onSubSectionClick + ) + } + } + } +} + +@Composable +fun CourseExpandableChapterCard( + modifier: Modifier = Modifier, + block: Block, arrowDegrees: Float = 0f, + downloadedState: DownloadedState?, + onDownloadClick: () -> Unit, ) { - Column(modifier = Modifier - .clickable { onItemClick(block) } - .background(if (block.isCompleted()) MaterialTheme.appColors.surface else Color.Transparent) + val iconModifier = Modifier.size(24.dp) + Row( + modifier + .fillMaxWidth() + .height(48.dp) + .padding(vertical = 8.dp) + .padding(start = 16.dp, end = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) ) { + CardArrow(degrees = arrowDegrees) + if (block.isCompleted()) { + val completedIconPainter = painterResource(R.drawable.course_ic_task_alt) + val completedIconColor = MaterialTheme.appColors.successGreen + val completedIconDescription = stringResource(id = R.string.course_accessibility_section_completed) + + Icon( + painter = completedIconPainter, + contentDescription = completedIconDescription, + tint = completedIconColor + ) + } + Text( + modifier = Modifier.weight(1f), + text = block.displayName, + style = MaterialTheme.appTypography.titleSmall, + color = MaterialTheme.appColors.textPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) Row( - modifier - .fillMaxWidth() - .height(60.dp) - .padding( - vertical = 8.dp - ), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween + modifier = Modifier.fillMaxHeight(), + verticalAlignment = Alignment.CenterVertically ) { - if (block.isCompleted()) { - val completedIconPainter = painterResource(R.drawable.course_ic_task_alt) - val completedIconColor = MaterialTheme.appColors.primary - val completedIconDescription = - stringResource(id = R.string.course_accessibility_section_completed) - - Icon( - painter = completedIconPainter, - contentDescription = completedIconDescription, - tint = completedIconColor - ) - Spacer(modifier = Modifier.width(16.dp)) + if (downloadedState == DownloadedState.DOWNLOADED || downloadedState == DownloadedState.NOT_DOWNLOADED) { + val downloadIconPainter = + if (downloadedState == DownloadedState.DOWNLOADED) { + rememberVectorPainter(Icons.Default.CloudDone) + } else { + painterResource(id = R.drawable.course_ic_start_download) + } + val downloadIconDescription = + if (downloadedState == DownloadedState.DOWNLOADED) { + stringResource(id = R.string.course_accessibility_remove_course_section) + } else { + stringResource(id = R.string.course_accessibility_download_course_section) + } + val downloadIconTint = + if (downloadedState == DownloadedState.DOWNLOADED) { + MaterialTheme.appColors.successGreen + } else { + MaterialTheme.appColors.textAccent + } + IconButton(modifier = iconModifier, + onClick = { onDownloadClick() }) { + Icon( + painter = downloadIconPainter, + contentDescription = downloadIconDescription, + tint = downloadIconTint + ) + } + } else if (downloadedState != null) { + Box(contentAlignment = Alignment.Center) { + if (downloadedState == DownloadedState.DOWNLOADING) { + CircularProgressIndicator( + modifier = Modifier.size(28.dp), + backgroundColor = Color.LightGray, + strokeWidth = 2.dp, + color = MaterialTheme.appColors.primary + ) + } else if (downloadedState == DownloadedState.WAITING) { + Icon( + painter = painterResource(id = R.drawable.course_download_waiting), + contentDescription = stringResource(id = R.string.course_accessibility_stop_downloading_course_section), + tint = MaterialTheme.appColors.error + ) + } + IconButton( + modifier = iconModifier.padding(2.dp), + onClick = { onDownloadClick() }) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(id = R.string.course_accessibility_stop_downloading_course_section), + tint = MaterialTheme.appColors.error + ) + } + } } - Text( - modifier = Modifier.weight(1f), - text = block.displayName, - style = MaterialTheme.appTypography.titleSmall, - color = MaterialTheme.appColors.textPrimary, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Spacer(modifier = Modifier.width(16.dp)) - CardArrow(degrees = arrowDegrees) } } } @Composable fun CourseSubSectionItem( - modifier: Modifier, + modifier: Modifier = Modifier, block: Block, - downloadedState: DownloadedState?, - downloadsCount: Int, onClick: (Block) -> Unit, - onDownloadClick: (Block) -> Unit, ) { + val context = LocalContext.current val icon = - if (block.isCompleted()) painterResource(R.drawable.course_ic_task_alt) else painterResource( - coreR.drawable.ic_core_chapter_icon - ) + if (block.isCompleted()) painterResource(R.drawable.course_ic_task_alt) else painterResource(coreR.drawable.ic_core_chapter_icon) val iconColor = - if (block.isCompleted()) MaterialTheme.appColors.primary else MaterialTheme.appColors.onSurface - - val iconModifier = Modifier.size(24.dp) - - Column(Modifier - .clickable { onClick(block) } - .background(if (block.isCompleted()) MaterialTheme.appColors.surface else Color.Transparent) + if (block.isCompleted()) MaterialTheme.appColors.successGreen else MaterialTheme.appColors.onSurface + val due by rememberSaveable { + mutableStateOf(block.due?.let { TimeUtils.getAssignmentFormattedDate(context, it) }) + } + val isAssignmentEnable = !block.isCompleted() && block.assignmentProgress != null && !due.isNullOrEmpty() + Column( + modifier = modifier + .fillMaxWidth() + .clickable { onClick(block) } + .padding(horizontal = 16.dp, vertical = 12.dp) ) { Row( - modifier - .fillMaxWidth() - .height(60.dp) - .padding(vertical = 16.dp) - .padding(start = 20.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { @@ -656,7 +765,7 @@ fun CourseSubSectionItem( contentDescription = null, tint = iconColor ) - Spacer(modifier = Modifier.width(16.dp)) + Spacer(modifier = Modifier.width(4.dp)) Text( modifier = Modifier.weight(1f), text = block.displayName, @@ -666,63 +775,31 @@ fun CourseSubSectionItem( maxLines = 1 ) Spacer(modifier = Modifier.width(16.dp)) - Row( - modifier = Modifier.fillMaxHeight(), - horizontalArrangement = Arrangement.spacedBy(if (downloadsCount > 0) 8.dp else 24.dp), - verticalAlignment = Alignment.CenterVertically - ) { - if (downloadedState == DownloadedState.DOWNLOADED || downloadedState == DownloadedState.NOT_DOWNLOADED) { - val downloadIconPainter = if (downloadedState == DownloadedState.DOWNLOADED) { - painterResource(id = R.drawable.course_ic_remove_download) - } else { - painterResource(id = R.drawable.course_ic_start_download) - } - val downloadIconDescription = - if (downloadedState == DownloadedState.DOWNLOADED) { - stringResource(id = R.string.course_accessibility_remove_course_section) - } else { - stringResource(id = R.string.course_accessibility_download_course_section) - } - IconButton(modifier = iconModifier, - onClick = { onDownloadClick(block) }) { - Icon( - painter = downloadIconPainter, - contentDescription = downloadIconDescription, - tint = MaterialTheme.appColors.textPrimary - ) - } - } else if (downloadedState != null) { - Box(contentAlignment = Alignment.Center) { - if (downloadedState == DownloadedState.DOWNLOADING || downloadedState == DownloadedState.WAITING) { - CircularProgressIndicator( - modifier = Modifier.size(28.dp), - backgroundColor = Color.LightGray, - strokeWidth = 2.dp, - color = MaterialTheme.appColors.primary - ) - } - IconButton( - modifier = iconModifier.padding(2.dp), - onClick = { onDownloadClick(block) }) { - Text( - modifier = Modifier - .padding(bottom = 4.dp), - text = "i", - style = MaterialTheme.appTypography.titleMedium, - color = MaterialTheme.appColors.primary - ) - } - } - } - if (downloadsCount > 0) { - Text( - text = downloadsCount.toString(), - style = MaterialTheme.appTypography.titleSmall, - color = MaterialTheme.appColors.textPrimary - ) - } + if (isAssignmentEnable) { + Icon( + imageVector = Icons.Filled.ChevronRight, + tint = MaterialTheme.appColors.onSurface, + contentDescription = null + ) } } + + if (isAssignmentEnable) { + val assignmentString = + stringResource( + R.string.course_subsection_assignment_info, + block.assignmentProgress?.assignmentType ?: "", + due ?: "", + block.assignmentProgress?.numPointsEarned?.toInt() ?: 0, + block.assignmentProgress?.numPointsPossible?.toInt() ?: 0 + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = assignmentString, + style = MaterialTheme.appTypography.bodySmall, + color = MaterialTheme.appColors.textPrimary + ) + } } } @@ -1146,7 +1223,7 @@ private fun NavigationUnitsButtonsWithNextPreview() { @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -private fun CourseChapterItemPreview() { +private fun CourseSectionCardPreview() { OpenEdXTheme { Surface(color = MaterialTheme.appColors.background) { CourseSectionCard( @@ -1246,5 +1323,7 @@ private val mockChapterBlock = Block( descendants = emptyList(), descendantsType = BlockType.CHAPTER, completion = 0.0, - containsGatedContent = false + containsGatedContent = false, + assignmentProgress = AssignmentProgress("", 1f, 2f), + due = Date() ) 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 6e8f19610..7beb0f91d 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 @@ -1,7 +1,6 @@ package org.openedx.course.presentation.ui import android.content.res.Configuration -import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween @@ -19,7 +18,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.AlertDialog @@ -59,10 +57,12 @@ import androidx.fragment.app.FragmentManager import org.openedx.core.AppDataConstants import org.openedx.core.BlockType import org.openedx.core.UIMessage +import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts import org.openedx.core.domain.model.CourseStructure import org.openedx.core.domain.model.CoursewareAccess +import org.openedx.core.domain.model.Progress import org.openedx.core.domain.model.VideoSettings import org.openedx.core.extension.toFileSize import org.openedx.core.module.download.DownloadModelsSize @@ -98,7 +98,6 @@ fun CourseVideosScreen( uiState = uiState, uiMessage = uiMessage, courseTitle = viewModel.courseTitle, - isCourseNestedListEnabled = viewModel.isCourseNestedListEnabled, videoSettings = videoSettings, onItemClick = { block -> viewModel.courseRouter.navigateToCourseSubsections( @@ -125,20 +124,12 @@ fun CourseVideosScreen( ) } }, - onDownloadClick = { - if (viewModel.isBlockDownloading(it.id)) { - viewModel.courseRouter.navigateToDownloadQueue( - fm = fragmentManager, - viewModel.getDownloadableChildren(it.id) - ?: arrayListOf() - ) - } else if (viewModel.isBlockDownloaded(it.id)) { - viewModel.removeDownloadModels(it.id) - } else { - viewModel.saveDownloadModels( - FileUtil(context).getExternalAppDir().path, it.id - ) - } + onDownloadClick = { blocksIds -> + viewModel.downloadBlocks( + blocksIds = blocksIds, + fragmentManager = fragmentManager, + context = context + ) }, onDownloadAllClick = { isAllBlocksDownloadedOrDownloading -> viewModel.logBulkDownloadToggleEvent(!isAllBlocksDownloadedOrDownloading) @@ -174,12 +165,11 @@ private fun CourseVideosUI( uiState: CourseVideosUIState, uiMessage: UIMessage?, courseTitle: String, - isCourseNestedListEnabled: Boolean, videoSettings: VideoSettings, onItemClick: (Block) -> Unit, onExpandClick: (Block) -> Unit, onSubSectionClick: (Block) -> Unit, - onDownloadClick: (Block) -> Unit, + onDownloadClick: (blocksIds: List) -> Unit, onDownloadAllClick: (Boolean) -> Unit, onDownloadQueueClick: () -> Unit, onVideoDownloadQualityClick: () -> Unit @@ -294,88 +284,26 @@ private fun CourseVideosUI( } } - if (isCourseNestedListEnabled) { - uiState.courseStructure.blockData.forEach { section -> - val courseSubSections = uiState.courseSubSections[section.id] - val courseSectionsState = uiState.courseSectionsState[section.id] - - item { - Column { - CourseExpandableChapterCard( - modifier = listPadding, - block = section, - onItemClick = onExpandClick, - arrowDegrees = if (courseSectionsState == true) -90f else 90f - ) - Divider() - } - } - - courseSubSections?.forEach { subSectionBlock -> - item { - Column { - AnimatedVisibility( - visible = courseSectionsState == true - ) { - Column { - val downloadsCount = - uiState.subSectionsDownloadsCount[subSectionBlock.id] - ?: 0 - - CourseSubSectionItem( - modifier = listPadding, - block = subSectionBlock, - downloadedState = uiState.downloadedState[subSectionBlock.id], - downloadsCount = downloadsCount, - onClick = onSubSectionClick, - onDownloadClick = { block -> - if (uiState.downloadedState[block.id]?.isDownloaded == true) { - deleteDownloadBlock = - block - - } else { - onDownloadClick(block) - } - } - ) - Divider() - } - } - } - } - } - } - return@LazyColumn + item { + Spacer(modifier = Modifier.height(12.dp)) } + uiState.courseStructure.blockData.forEach { section -> + val courseSubSections = + uiState.courseSubSections[section.id] + val courseSectionsState = + uiState.courseSectionsState[section.id] - items(uiState.courseStructure.blockData) { block -> - Column(listPadding) { - if (block.type == BlockType.CHAPTER) { - Text( - modifier = Modifier.padding( - top = 36.dp, - bottom = 8.dp - ), - text = block.displayName, - style = MaterialTheme.appTypography.titleMedium, - color = MaterialTheme.appColors.textPrimaryVariant - ) - } else { - CourseSectionCard( - block = block, - downloadedState = uiState.downloadedState[block.id], - onItemClick = onItemClick, - onDownloadClick = { block -> - if (uiState.downloadedState[block.id]?.isDownloaded == true) { - deleteDownloadBlock = block - - } else { - onDownloadClick(block) - } - } - ) - Divider() - } + item { + CourseSection( + modifier = listPadding.padding(vertical = 4.dp), + block = section, + onItemClick = onExpandClick, + courseSectionsState = courseSectionsState, + courseSubSections = courseSubSections, + downloadedStateMap = uiState.downloadedState, + onSubSectionClick = onSubSectionClick, + onDownloadClick = onDownloadClick + ) } } } @@ -497,7 +425,7 @@ private fun CourseVideosUI( TextButton( onClick = { deleteDownloadBlock?.let { block -> - onDownloadClick(block) + onDownloadClick(listOf(block.id)) } deleteDownloadBlock = null } @@ -708,7 +636,6 @@ private fun CourseVideosScreenPreview() { ) ), courseTitle = "", - isCourseNestedListEnabled = false, onItemClick = { }, onExpandClick = { }, onSubSectionClick = { }, @@ -733,7 +660,6 @@ private fun CourseVideosScreenEmptyPreview() { "This course does not include any videos." ), courseTitle = "", - isCourseNestedListEnabled = false, onItemClick = { }, onExpandClick = { }, onSubSectionClick = { }, @@ -769,7 +695,6 @@ private fun CourseVideosScreenTabletPreview() { ) ), courseTitle = "", - isCourseNestedListEnabled = false, onItemClick = { }, onExpandClick = { }, onSubSectionClick = { }, @@ -782,6 +707,11 @@ private fun CourseVideosScreenTabletPreview() { } } +private val mockAssignmentProgress = AssignmentProgress( + assignmentType = "Home", + numPointsEarned = 1f, + numPointsPossible = 3f +) private val mockChapterBlock = Block( id = "id", @@ -798,7 +728,9 @@ private val mockChapterBlock = Block( descendants = emptyList(), descendantsType = BlockType.CHAPTER, completion = 0.0, - containsGatedContent = false + containsGatedContent = false, + assignmentProgress = mockAssignmentProgress, + due = Date() ) private val mockSequentialBlock = Block( @@ -816,7 +748,9 @@ private val mockSequentialBlock = Block( descendants = emptyList(), descendantsType = BlockType.SEQUENTIAL, completion = 0.0, - containsGatedContent = false + containsGatedContent = false, + assignmentProgress = mockAssignmentProgress, + due = Date() ) private val mockCourseStructure = CourseStructure( @@ -840,5 +774,6 @@ private val mockCourseStructure = CourseStructure( ), media = null, certificate = null, - isSelfPaced = false + isSelfPaced = false, + progress = Progress(1, 3) ) diff --git a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt index 61fc896bf..af4d839e7 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt @@ -37,7 +37,7 @@ class CourseUnitContainerViewModel( private val blocks = ArrayList() - val isCourseExpandableSectionsEnabled get() = config.getCourseUIConfig().isCourseNestedListEnabled + val isCourseExpandableSectionsEnabled get() = config.getCourseUIConfig().isCourseDropdownNavigationEnabled val isCourseUnitProgressEnabled get() = config.getCourseUIConfig().isCourseUnitProgressEnabled 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 2cf3d8797..49f3b6120 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 @@ -1,5 +1,7 @@ package org.openedx.course.presentation.videos +import android.content.Context +import androidx.fragment.app.FragmentManager import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -25,6 +27,7 @@ import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseStructureUpdated import org.openedx.core.system.notifier.VideoNotifier import org.openedx.core.system.notifier.VideoQualityChanged +import org.openedx.core.utils.FileUtil import org.openedx.course.R import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics @@ -53,7 +56,7 @@ class CourseVideoViewModel( coreAnalytics ) { - val isCourseNestedListEnabled get() = config.getCourseUIConfig().isCourseNestedListEnabled + val isCourseNestedListEnabled get() = config.getCourseUIConfig().isCourseDropdownNavigationEnabled private val _uiState = MutableStateFlow(CourseVideosUIState.Loading) val uiState: StateFlow @@ -199,15 +202,10 @@ class CourseVideoViewModel( resultBlocks.add(block) block.descendants.forEach { descendant -> blocks.find { it.id == descendant }?.let { - if (isCourseNestedListEnabled) { - courseSubSections.getOrPut(block.id) { mutableListOf() } - .add(it) - courseSubSectionUnit[it.id] = it.getFirstDescendantBlock(blocks) - subSectionsDownloadsCount[it.id] = it.getDownloadsCount(blocks) - - } else { - resultBlocks.add(it) - } + courseSubSections.getOrPut(block.id) { mutableListOf() } + .add(it) + courseSubSectionUnit[it.id] = it.getFirstDescendantBlock(blocks) + subSectionsDownloadsCount[it.id] = it.getDownloadsCount(blocks) addDownloadableChildrenForSequentialBlock(it) } } @@ -215,4 +213,21 @@ class CourseVideoViewModel( } return resultBlocks.toList() } + + fun downloadBlocks(blocksIds: List, fragmentManager: FragmentManager, context: Context) { + blocksIds.forEach { blockId -> + if (isBlockDownloading(blockId)) { + courseRouter.navigateToDownloadQueue( + fm = fragmentManager, + getDownloadableChildren(blockId) ?: arrayListOf() + ) + } else if (isBlockDownloaded(blockId)) { + removeDownloadModels(blockId) + } else { + saveDownloadModels( + FileUtil(context).getExternalAppDir().path, blockId + ) + } + } + } } diff --git a/course/src/main/res/drawable/course_download_waiting.png b/course/src/main/res/drawable/course_download_waiting.png new file mode 100644 index 0000000000000000000000000000000000000000..c4a04af69630a5f614eb1b71780d4554038f2043 GIT binary patch literal 1945 zcmV;K2WI$*P)upfI!Tjv0M#aRK#3J5CzRsgI3x&nt4Bw0b+*J4iG66mAl9k}N} z9vWNI`;8?lO#E%-@Z&4#3UAI>-VUe&D4k&MJ`6h!JC9`M>?@`A9{k+unVwTj9$V`0l|Q1C z`kDEViI3#Dx3>C=LMa409S$3lFSIX%z>L8%)9K8#D<3^m8OI?{DRX_uBMXGcsI!4T zL<};)vL0WutNpo{m0?{^a~G#Sg+-0dp?&R*~+OYjcs|pZzq`i3lHb&A!;hc zT_N1C|7o~O1*33+1!!2(o2RG4rYXaBQ;1^0=|8%zwkZ#UV`c5fwNW_1f~C;2#pbmdBQ5x&2eiV^&Y3>`wfJwC(MdXKu&9 zRilRNefS!bvgw0U_0PPXeC^~YMg`7@US&3YaKQct@9_QE=ylq2iCH$rZ}*nEeSfRD zLj?hKxvpwq7Geon+P(txz5mfW#8@aNIM2gqKsewD1skof^5+k>T5oL6_x~Oquv^~0 zodh_v>}yS#;V5pYjQ~Pdpv_OxeO>Q3!p)H7?!he=}T21Uva*n6dcK03_ZWIDz)J?E3G%QF+frk`~~ZA*3o zGw(Y4!A${uGp&vYirPZ7ZkD<-Zu)AJnkz)RIJb1B0?5=il|IH0nfEYR|1V|vy)abZ zIj-8tvvGb4*!Eyx5%Y>87nhuA9=IDex-7&BVIbN#O@m@WE`r4y#rI_*RtWT87}%){ ze0*d5mjiU;&yT}4Eh*6VDl@6o26RY}m}$)c8kp#CWeZ&eVC&8n>ktX&!r z!Q^;gx3H9??yV9;;{V;Ad$}x~Sb;s76&fTTm*v=_6^9sFuAC)hW_h5ArcIb;Dy)w` zVvz}r3`MGys|;{Se9CG&-ngJ6oAISajT$v-tO;13$d&UDK3I_ELr)p_Gps9@fWyqi zchqor=FkFLrwI0UeDeQdSh0!C4kEB9ubZ|)D`G}fq5qehKYgIPrlF0o0wp-=s~si@@){T6l%lV?vvurTUOEJV9X&C4Qi z7tiW6yQ~P@poVjaOaoTj6OrpU^dXA^8{ph4f?0zM5oMFl^h`cp(h9>EhNE;OUK>$z z^6NH|E{{e^y|q;)1)kEX!|W!ZfmqD&d(4naM`TB1m0h6XPBr>>=9Y-qxKbw4y+$4aP{Ou*HO%#GF_G-__}+<`mGv zf>5uPB`l^W%xoO|B6KRex7BPdEKs{_&gf7^2y~4sfXnu}x}E0_FokE&7d~|qpDaTZ zTN3EAtN0LOxK96b5e&yCL{S7X%Pfj*iHH^toxuy=^Ey0dCYZh(XXZE?P35rBWr>$e z24`tbC}TpDZq|A-#Tp?-T4c0mDV2CrR(rqQZ4Ib`z@CLIgzWce;P~@KWX;n>Q_9)M z8oQ0YBKkI#G|)E+lyMD12z}n%;I14>NB6f{Rz?uN@*oWKvZ;N)208%#+<7X09{Qp; z^+i`h^gj&WNU!c-8Rp$eauX~o0{wW$kku>7MsO2tBvH!UJ2R)WeE05z<&yqcFx6kf zsjq_>UWTTnoGbm()V*w0g$G86E+^(ZZmpATpl9;)KmdZsPaShRx(_`n7^U1$w;(F6 zXG)(;>}B9@YD(X5Q`PF7G8j(b1cUdFN&E;fxXz{z=Uh|z?9MT3TcyZ_g%j-lFRdeJ zDU(-J&7iFALTP?kX;lTSi7XyW0(o?{lV4}UP~HBz^QtRo?}?sEd - - - - - - + diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index 3d6e13094..2fcb1d950 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -62,5 +62,15 @@ Turning off the switch will stop downloading and delete all downloaded videos for \"%s\"? Are you sure you want to delete all video(s) for \"%s\"? Are you sure you want to delete video(s) for \"%s\"? + %1$s - %2$s - %3$d / %4$d + + + %1$s of %2$s assignments complete + %1$s of %2$s assignment complete + %1$s of %2$s assignments complete + %1$s of %2$s assignments complete + %1$s of %2$s assignments complete + %1$s of %2$s assignments complete + 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 c20bb07be..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 @@ -108,7 +108,8 @@ class CourseContainerViewModelTest { ), media = null, certificate = null, - isSelfPaced = false + isSelfPaced = false, + progress = null ) private val courseStructureModel = CourseStructureModel( @@ -125,7 +126,8 @@ class CourseContainerViewModelTest { coursewareAccess = null, media = null, certificate = null, - isSelfPaced = false + isSelfPaced = false, + progress = null ) @Before @@ -168,12 +170,12 @@ class CourseContainerViewModelTest { courseRouter ) every { networkConnection.isOnline() } returns true - coEvery { interactor.getCourseStructure(any()) } throws UnknownHostException() + coEvery { interactor.getCourseStructure(any(), any()) } throws UnknownHostException() every { analytics.logEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } returns Unit viewModel.preloadCourseStructure() advanceUntilIdle() - coVerify(exactly = 1) { interactor.getCourseStructure(any()) } + coVerify(exactly = 1) { interactor.getCourseStructure(any(), any()) } verify(exactly = 1) { analytics.logEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } val message = viewModel.errorMessage.value @@ -202,12 +204,12 @@ class CourseContainerViewModelTest { courseRouter ) every { networkConnection.isOnline() } returns true - coEvery { interactor.getCourseStructure(any()) } throws Exception() + coEvery { interactor.getCourseStructure(any(), any()) } throws Exception() every { analytics.logEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } returns Unit viewModel.preloadCourseStructure() advanceUntilIdle() - coVerify(exactly = 1) { interactor.getCourseStructure(any()) } + coVerify(exactly = 1) { interactor.getCourseStructure(any(), any()) } verify(exactly = 1) { analytics.logEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } val message = viewModel.errorMessage.value @@ -236,12 +238,12 @@ class CourseContainerViewModelTest { courseRouter ) every { networkConnection.isOnline() } returns true - coEvery { interactor.getCourseStructure(any()) } returns courseStructure + coEvery { interactor.getCourseStructure(any(), any()) } returns courseStructure every { analytics.logEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } returns Unit viewModel.preloadCourseStructure() advanceUntilIdle() - coVerify(exactly = 1) { interactor.getCourseStructure(any()) } + coVerify(exactly = 1) { interactor.getCourseStructure(any(), any()) } verify(exactly = 1) { analytics.logEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } assert(viewModel.errorMessage.value == null) @@ -269,7 +271,7 @@ class CourseContainerViewModelTest { courseRouter ) every { networkConnection.isOnline() } returns false - coEvery { interactor.getCourseStructure(any()) } returns courseStructure + coEvery { interactor.getCourseStructure(any(), any()) } returns courseStructure every { analytics.logEvent(any(), any()) } returns Unit coEvery { courseApi.getCourseStructure(any(), any(), any(), any()) 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 5e2ed50a4..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 @@ -136,6 +136,7 @@ class CourseDatesViewModelTest { media = null, certificate = null, isSelfPaced = true, + progress = null ) @Before 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 941fbfdac..aad650b28 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 @@ -33,6 +33,7 @@ import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.data.model.DateType import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts import org.openedx.core.domain.model.CourseComponentStatus @@ -84,6 +85,12 @@ class CourseOutlineViewModelTest { private val somethingWrong = "Something went wrong" private val cantDownload = "You can download content only from Wi-fi" + private val assignmentProgress = AssignmentProgress( + assignmentType = "Homework", + numPointsEarned = 1f, + numPointsPossible = 3f + ) + private val blocks = listOf( Block( id = "id", @@ -99,7 +106,9 @@ class CourseOutlineViewModelTest { blockCounts = BlockCounts(0), descendants = listOf("1", "id1"), descendantsType = BlockType.HTML, - completion = 0.0 + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date() ), Block( id = "id1", @@ -115,7 +124,9 @@ class CourseOutlineViewModelTest { blockCounts = BlockCounts(0), descendants = listOf("id2"), descendantsType = BlockType.HTML, - completion = 0.0 + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date() ), Block( id = "id2", @@ -131,7 +142,9 @@ class CourseOutlineViewModelTest { blockCounts = BlockCounts(0), descendants = emptyList(), descendantsType = BlockType.HTML, - completion = 0.0 + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date() ) ) @@ -156,7 +169,8 @@ class CourseOutlineViewModelTest { ), media = null, certificate = null, - isSelfPaced = false + isSelfPaced = false, + progress = null ) private val dateBlock = CourseDateBlock( @@ -303,7 +317,7 @@ class CourseOutlineViewModelTest { ) } coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") - every { config.getCourseUIConfig().isCourseNestedListEnabled } returns false + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false val viewModel = CourseOutlineViewModel( "", @@ -351,7 +365,7 @@ class CourseOutlineViewModelTest { ) } coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") - every { config.getCourseUIConfig().isCourseNestedListEnabled } returns false + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false val viewModel = CourseOutlineViewModel( "", @@ -398,7 +412,7 @@ class CourseOutlineViewModelTest { ) } coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") - every { config.getCourseUIConfig().isCourseNestedListEnabled } returns false + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false val viewModel = CourseOutlineViewModel( "", @@ -482,7 +496,7 @@ class CourseOutlineViewModelTest { coEvery { workerController.saveModels(any()) } returns Unit coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") coEvery { downloadDao.readAllData() } returns flow { emit(emptyList()) } - every { config.getCourseUIConfig().isCourseNestedListEnabled } returns false + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false val viewModel = CourseOutlineViewModel( "", @@ -525,7 +539,7 @@ class CourseOutlineViewModelTest { every { networkConnection.isOnline() } returns true coEvery { workerController.saveModels(any()) } returns Unit coEvery { downloadDao.readAllData() } returns flow { emit(emptyList()) } - every { config.getCourseUIConfig().isCourseNestedListEnabled } returns false + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false every { coreAnalytics.logEvent(any(), any()) } returns Unit val viewModel = CourseOutlineViewModel( @@ -562,7 +576,7 @@ class CourseOutlineViewModelTest { every { networkConnection.isOnline() } returns false coEvery { workerController.saveModels(any()) } returns Unit coEvery { downloadDao.readAllData() } returns flow { emit(emptyList()) } - every { config.getCourseUIConfig().isCourseNestedListEnabled } returns false + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false val viewModel = CourseOutlineViewModel( "", diff --git a/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt index 0a398371b..01c685c48 100644 --- a/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt @@ -27,6 +27,7 @@ import org.openedx.core.BlockType import org.openedx.core.R import org.openedx.core.UIMessage import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts import org.openedx.core.domain.model.CourseStructure @@ -69,6 +70,11 @@ class CourseSectionViewModelTest { private val somethingWrong = "Something went wrong" private val cantDownload = "You can download content only from Wi-fi" + private val assignmentProgress = AssignmentProgress( + assignmentType = "Homework", + numPointsEarned = 1f, + numPointsPossible = 3f + ) private val blocks = listOf( Block( @@ -85,7 +91,9 @@ class CourseSectionViewModelTest { blockCounts = BlockCounts(0), descendants = listOf("1", "id1"), descendantsType = BlockType.HTML, - completion = 0.0 + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date() ), Block( id = "id1", @@ -101,7 +109,9 @@ class CourseSectionViewModelTest { blockCounts = BlockCounts(0), descendants = listOf("id2"), descendantsType = BlockType.HTML, - completion = 0.0 + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date() ), Block( id = "id2", @@ -117,7 +127,9 @@ class CourseSectionViewModelTest { blockCounts = BlockCounts(0), descendants = emptyList(), descendantsType = BlockType.HTML, - completion = 0.0 + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date() ) ) @@ -142,7 +154,8 @@ class CourseSectionViewModelTest { ), media = null, certificate = null, - isSelfPaced = false + isSelfPaced = false, + progress = null ) private val downloadModel = DownloadModel( diff --git a/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt index 1e5354a95..166d7751e 100644 --- a/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt @@ -20,6 +20,7 @@ import org.junit.Test import org.junit.rules.TestRule import org.openedx.core.BlockType import org.openedx.core.config.Config +import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts import org.openedx.core.domain.model.CourseStructure @@ -44,6 +45,12 @@ class CourseUnitContainerViewModelTest { private val notifier = mockk() private val analytics = mockk() + private val assignmentProgress = AssignmentProgress( + assignmentType = "Homework", + numPointsEarned = 1f, + numPointsPossible = 3f + ) + private val blocks = listOf( Block( id = "id", @@ -59,7 +66,9 @@ class CourseUnitContainerViewModelTest { blockCounts = BlockCounts(0), descendants = listOf("id2", "id1"), descendantsType = BlockType.HTML, - completion = 0.0 + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date() ), Block( id = "id1", @@ -75,7 +84,9 @@ class CourseUnitContainerViewModelTest { blockCounts = BlockCounts(0), descendants = listOf("id2", "id"), descendantsType = BlockType.HTML, - completion = 0.0 + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date() ), Block( id = "id2", @@ -91,7 +102,9 @@ class CourseUnitContainerViewModelTest { blockCounts = BlockCounts(0), descendants = emptyList(), descendantsType = BlockType.HTML, - completion = 0.0 + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date() ), Block( id = "id3", @@ -107,7 +120,9 @@ class CourseUnitContainerViewModelTest { blockCounts = BlockCounts(0), descendants = emptyList(), descendantsType = BlockType.HTML, - completion = 0.0 + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date() ) ) @@ -133,7 +148,8 @@ class CourseUnitContainerViewModelTest { ), media = null, certificate = null, - isSelfPaced = false + isSelfPaced = false, + progress = null ) @Before 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 a15de0583..9bb8d0f5f 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 @@ -32,6 +32,7 @@ import org.openedx.core.BlockType import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts import org.openedx.core.domain.model.CourseStructure @@ -78,6 +79,12 @@ class CourseVideoViewModelTest { private val cantDownload = "You can download content only from Wi-fi" + private val assignmentProgress = AssignmentProgress( + assignmentType = "Homework", + numPointsEarned = 1f, + numPointsPossible = 3f + ) + private val blocks = listOf( Block( id = "id", @@ -93,7 +100,9 @@ class CourseVideoViewModelTest { blockCounts = BlockCounts(0), descendants = listOf("1", "id1"), descendantsType = BlockType.HTML, - completion = 0.0 + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date() ), Block( id = "id1", @@ -109,7 +118,9 @@ class CourseVideoViewModelTest { blockCounts = BlockCounts(0), descendants = listOf("id2"), descendantsType = BlockType.HTML, - completion = 0.0 + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date() ), Block( id = "id2", @@ -125,7 +136,9 @@ class CourseVideoViewModelTest { blockCounts = BlockCounts(0), descendants = emptyList(), descendantsType = BlockType.HTML, - completion = 0.0 + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date() ) ) @@ -150,7 +163,8 @@ class CourseVideoViewModelTest { ), media = null, certificate = null, - isSelfPaced = false + isSelfPaced = false, + progress = null ) private val downloadModelEntity = @@ -183,7 +197,7 @@ class CourseVideoViewModelTest { @Test fun `getVideos empty list`() = runTest { - every { config.getCourseUIConfig().isCourseNestedListEnabled } returns false + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure.copy(blockData = emptyList()) every { downloadDao.readAllData() } returns flow { emit(emptyList()) } @@ -215,7 +229,7 @@ class CourseVideoViewModelTest { @Test fun `getVideos success`() = runTest { - every { config.getCourseUIConfig().isCourseNestedListEnabled } returns false + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure every { downloadDao.readAllData() } returns flow { emit(emptyList()) } every { preferencesManager.videoSettings } returns VideoSettings.default @@ -248,7 +262,7 @@ class CourseVideoViewModelTest { @Test fun `updateVideos success`() = runTest { - every { config.getCourseUIConfig().isCourseNestedListEnabled } returns false + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure coEvery { courseNotifier.notifier } returns flow { emit(CourseStructureUpdated("")) @@ -291,7 +305,7 @@ class CourseVideoViewModelTest { @Test fun `setIsUpdating success`() = runTest { - every { config.getCourseUIConfig().isCourseNestedListEnabled } returns false + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false every { preferencesManager.videoSettings } returns VideoSettings.default coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure coEvery { downloadDao.readAllData() } returns flow { emit(listOf(downloadModelEntity)) } @@ -300,7 +314,7 @@ class CourseVideoViewModelTest { @Test fun `saveDownloadModels test`() = runTest(UnconfinedTestDispatcher()) { - every { config.getCourseUIConfig().isCourseNestedListEnabled } returns false + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( "", @@ -337,7 +351,7 @@ class CourseVideoViewModelTest { @Test fun `saveDownloadModels only wifi download, with connection`() = runTest(UnconfinedTestDispatcher()) { - every { config.getCourseUIConfig().isCourseNestedListEnabled } returns false + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( "", @@ -378,7 +392,7 @@ class CourseVideoViewModelTest { @Test fun `saveDownloadModels only wifi download, without connection`() = runTest(UnconfinedTestDispatcher()) { - every { config.getCourseUIConfig().isCourseNestedListEnabled } returns false + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( "", diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt index c4ea029b9..74515e0c1 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt @@ -55,6 +55,7 @@ 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.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -564,7 +565,11 @@ private fun PrimaryCourseCard( }, painter = rememberVectorPainter(Icons.Default.Warning), title = title, - info = stringResource(R.string.dashboard_past_due_assignment, pastAssignments.size) + info = pluralStringResource( + R.plurals.dashboard_past_due_assignment, + pastAssignments.size, + pastAssignments.size + ) ) } val futureAssignments = primaryCourse.courseAssignments?.futureAssignments @@ -583,9 +588,9 @@ private fun PrimaryCourseCard( painter = painterResource(id = CoreR.drawable.ic_core_chapter_icon), title = title, info = stringResource( - R.string.dashboard_assignment_due_in_days, + R.string.dashboard_assignment_due, nearestAssignment.assignmentType ?: "", - TimeUtils.getCourseFormattedDate(context, nearestAssignment.date) + TimeUtils.getAssignmentFormattedDate(context, nearestAssignment.date) ) ) } @@ -769,7 +774,7 @@ private fun NoCoursesInfo( 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")!!, + date = TimeUtils.iso8601ToDate("2024-05-31T15:08:07Z")!!, assignmentType = "Homework" ) private val mockCourseAssignments = diff --git a/dashboard/src/main/res/values/strings.xml b/dashboard/src/main/res/values/strings.xml index 4ca0c4fce..23a33fb50 100644 --- a/dashboard/src/main/res/values/strings.xml +++ b/dashboard/src/main/res/values/strings.xml @@ -9,10 +9,9 @@ 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 + %1$s %2$s All In Progress Completed @@ -22,4 +21,13 @@ You are not currently enrolled in any courses, would you like to explore the course catalog? Find a Course No %1$s Courses + + + %1$d Past Due Assignments + %1$d Past Due Assignment + %1$d Past Due Assignments + %1$d Past Due Assignments + %1$d Past Due Assignments + %1$d Past Due Assignments + diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index 8c08df7f6..8b1dc8f07 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -82,4 +82,3 @@ SOCIAL_AUTH_ENABLED: false UI_COMPONENTS: COURSE_NESTED_LIST_ENABLED: false COURSE_UNIT_PROGRESS_ENABLED: false - diff --git a/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt b/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt index b74cc6644..9fc56f6af 100644 --- a/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt +++ b/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt @@ -26,6 +26,7 @@ import org.junit.rules.TestRule import org.openedx.core.BlockType import org.openedx.core.R import org.openedx.core.UIMessage +import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts import org.openedx.core.domain.model.CourseStructure @@ -56,6 +57,12 @@ class DiscussionTopicsViewModelTest { private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" + private val assignmentProgress = AssignmentProgress( + assignmentType = "Homework", + numPointsEarned = 1f, + numPointsPossible = 3f + ) + private val blocks = listOf( Block( id = "id", @@ -71,7 +78,9 @@ class DiscussionTopicsViewModelTest { blockCounts = BlockCounts(0), descendants = listOf("1", "id1"), descendantsType = BlockType.HTML, - completion = 0.0 + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date() ), Block( id = "id1", @@ -87,7 +96,9 @@ class DiscussionTopicsViewModelTest { blockCounts = BlockCounts(0), descendants = listOf("id2"), descendantsType = BlockType.HTML, - completion = 0.0 + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date() ), Block( id = "id2", @@ -103,7 +114,9 @@ class DiscussionTopicsViewModelTest { blockCounts = BlockCounts(0), descendants = emptyList(), descendantsType = BlockType.HTML, - completion = 0.0 + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date() ) ) private val courseStructure = CourseStructure( @@ -127,7 +140,8 @@ class DiscussionTopicsViewModelTest { ), media = null, certificate = null, - isSelfPaced = false + isSelfPaced = false, + progress = null ) @Before diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt index 419172232..f94064d30 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt @@ -518,7 +518,7 @@ private fun AppVersionItemAppToDate(versionName: String) { ), painter = painterResource(id = R.drawable.core_ic_check), contentDescription = null, - tint = MaterialTheme.appColors.accessGreen + tint = MaterialTheme.appColors.successGreen ) Text( modifier = Modifier.testTag("txt_up_to_date"), From 09b422af27e1d1fab19241153656d0c12f4659a8 Mon Sep 17 00:00:00 2001 From: Omer Habib <30689349+omerhabib26@users.noreply.github.com> Date: Fri, 7 Jun 2024 14:01:10 +0500 Subject: [PATCH 11/56] fix: enable Programs view for new dashboard navigation (#328) - Update/optimise ProgramFragment - enable ProgramFragment for both dashboards(list/gallery) - hide dropdown on Learn Tab if programs not available fix: LEARNER-10035 --- .../main/java/org/openedx/app/AppRouter.kt | 43 +++++++++++------- .../main/java/org/openedx/app/MainFragment.kt | 10 ++--- .../java/org/openedx/app/MainViewModel.kt | 4 +- .../java/org/openedx/DashboardNavigator.kt | 4 +- .../presentation/DashboardListFragment.kt | 44 ++----------------- .../dashboard/presentation/DashboardRouter.kt | 2 +- .../learn/presentation/LearnFragment.kt | 24 +++++----- .../learn/presentation/LearnViewModel.kt | 8 +++- dashboard/src/main/res/values-uk/strings.xml | 2 - dashboard/src/main/res/values/strings.xml | 2 - .../presentation/program/ProgramFragment.kt | 38 +++++++++------- 11 files changed, 79 insertions(+), 102 deletions(-) diff --git a/app/src/main/java/org/openedx/app/AppRouter.kt b/app/src/main/java/org/openedx/app/AppRouter.kt index 17b47d11d..fe4394cde 100644 --- a/app/src/main/java/org/openedx/app/AppRouter.kt +++ b/app/src/main/java/org/openedx/app/AppRouter.kt @@ -128,8 +128,8 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di replaceFragmentWithBackStack(fm, AllEnrolledCoursesFragment()) } - override fun getProgramFragmentInstance(): Fragment { - return ProgramFragment(myPrograms = true, isNestedFragment = true) + override fun getProgramFragment(): Fragment { + return ProgramFragment.newInstance(isNestedFragment = true) } override fun navigateToCourseInfo( @@ -144,7 +144,7 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di fm: FragmentManager, courseId: String, courseTitle: String, - enrollmentMode: String + enrollmentMode: String, ) { replaceFragmentWithBackStack( fm, @@ -161,21 +161,30 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di courseTitle: String, enrollmentMode: String, openTab: String, - resumeBlockId: String + resumeBlockId: String, ) { replaceFragmentWithBackStack( fm, - CourseContainerFragment.newInstance(courseId, courseTitle, enrollmentMode, openTab, resumeBlockId) + CourseContainerFragment.newInstance( + courseId, + courseTitle, + enrollmentMode, + openTab, + resumeBlockId + ) ) } override fun navigateToEnrolledProgramInfo(fm: FragmentManager, pathId: String) { - replaceFragmentWithBackStack(fm, ProgramFragment.newInstance(pathId)) + replaceFragmentWithBackStack( + fm, + ProgramFragment.newInstance(pathId = pathId, isNestedFragment = false) + ) } override fun navigateToNoAccess( fm: FragmentManager, - title: String + title: String, ) { replaceFragment(fm, NoAccessCourseContainerFragment.newInstance(title)) } @@ -189,7 +198,7 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di subSectionId: String, unitId: String, componentId: String, - mode: CourseViewMode + mode: CourseViewMode, ) { replaceFragmentWithBackStack( fm, @@ -208,7 +217,7 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di courseId: String, unitId: String, componentId: String, - mode: CourseViewMode + mode: CourseViewMode, ) { replaceFragmentWithBackStack( fm, @@ -226,7 +235,7 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di courseId: String, unitId: String, componentId: String, - mode: CourseViewMode + mode: CourseViewMode, ) { replaceFragment( fm, @@ -246,7 +255,7 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di videoTime: Long, blockId: String, courseId: String, - isPlaying: Boolean + isPlaying: Boolean, ) { replaceFragmentWithBackStack( fm, @@ -260,7 +269,7 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di videoTime: Long, blockId: String, courseId: String, - isPlaying: Boolean + isPlaying: Boolean, ) { replaceFragmentWithBackStack( fm, @@ -278,7 +287,7 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di fm: FragmentManager, courseId: String, title: String, - type: HandoutsType + type: HandoutsType, ) { replaceFragmentWithBackStack( fm, @@ -294,7 +303,7 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di courseId: String, topicId: String, title: String, - viewType: FragmentViewType + viewType: FragmentViewType, ) { replaceFragmentWithBackStack( fm, @@ -312,7 +321,7 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di override fun navigateToDiscussionResponses( fm: FragmentManager, comment: DiscussionComment, - isClosed: Boolean + isClosed: Boolean, ) { replaceFragmentWithBackStack( fm, @@ -340,7 +349,7 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di override fun navigateToAnothersProfile( fm: FragmentManager, - username: String + username: String, ) { replaceFragmentWithBackStack( fm, @@ -410,7 +419,7 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di private fun replaceFragment( fm: FragmentManager, fragment: Fragment, - transaction: Int = FragmentTransaction.TRANSIT_NONE + transaction: Int = FragmentTransaction.TRANSIT_NONE, ) { fm.beginTransaction() .setTransition(transaction) diff --git a/app/src/main/java/org/openedx/app/MainFragment.kt b/app/src/main/java/org/openedx/app/MainFragment.kt index fc4fb1b22..7087fee8f 100644 --- a/app/src/main/java/org/openedx/app/MainFragment.kt +++ b/app/src/main/java/org/openedx/app/MainFragment.kt @@ -11,13 +11,12 @@ 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.DashboardNavigator import org.openedx.app.databinding.FragmentMainBinding 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.discovery.presentation.DiscoveryNavigator import org.openedx.discovery.presentation.DiscoveryRouter +import org.openedx.learn.presentation.LearnFragment import org.openedx.profile.presentation.profile.ProfileFragment class MainFragment : Fragment(R.layout.fragment_main) { @@ -97,12 +96,9 @@ class MainFragment : Fragment(R.layout.fragment_main) { binding.viewPager.orientation = ViewPager2.ORIENTATION_HORIZONTAL binding.viewPager.offscreenPageLimit = 4 - val discoveryFragment = DiscoveryNavigator(viewModel.isDiscoveryTypeWebView).getDiscoveryFragment() - val dashboardFragment = DashboardNavigator(viewModel.dashboardType).getDashboardFragment() - adapter = NavigationFragmentAdapter(this).apply { - addFragment(dashboardFragment) - addFragment(discoveryFragment) + addFragment(LearnFragment()) + addFragment(viewModel.getDiscoveryFragment) 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 eed901039..f3d62c04f 100644 --- a/app/src/main/java/org/openedx/app/MainViewModel.kt +++ b/app/src/main/java/org/openedx/app/MainViewModel.kt @@ -14,6 +14,8 @@ import org.openedx.core.BaseViewModel import org.openedx.core.config.Config import org.openedx.core.system.notifier.DiscoveryNotifier import org.openedx.core.system.notifier.NavigationToDiscovery +import org.openedx.dashboard.presentation.DashboardRouter +import org.openedx.discovery.presentation.DiscoveryNavigator class MainViewModel( private val config: Config, @@ -30,7 +32,7 @@ class MainViewModel( get() = _navigateToDiscovery.asSharedFlow() val isDiscoveryTypeWebView get() = config.getDiscoveryConfig().isViewTypeWebView() - val dashboardType get() = config.getDashboardConfig().getType() + val getDiscoveryFragment get() = DiscoveryNavigator(isDiscoveryTypeWebView).getDiscoveryFragment() override fun onCreate(owner: LifecycleOwner) { super.onCreate(owner) diff --git a/dashboard/src/main/java/org/openedx/DashboardNavigator.kt b/dashboard/src/main/java/org/openedx/DashboardNavigator.kt index 9e5f4c900..1705860b6 100644 --- a/dashboard/src/main/java/org/openedx/DashboardNavigator.kt +++ b/dashboard/src/main/java/org/openedx/DashboardNavigator.kt @@ -2,15 +2,15 @@ package org.openedx import androidx.fragment.app.Fragment import org.openedx.core.config.DashboardConfig +import org.openedx.courses.presentation.DashboardGalleryFragment 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() + DashboardConfig.DashboardType.GALLERY -> DashboardGalleryFragment() else -> DashboardListFragment() } } diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt index 0a7f59c93..31aaa2e3c 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt @@ -42,6 +42,7 @@ import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable @@ -84,13 +85,11 @@ import org.openedx.core.presentation.global.app_upgrade.AppUpgradeRecommendedBox import org.openedx.core.system.notifier.AppUpgradeEvent import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OfflineModeDialog -import org.openedx.core.ui.Toolbar import org.openedx.core.ui.WindowSize import org.openedx.core.ui.WindowType 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 @@ -158,9 +157,6 @@ class DashboardListFragment : Fragment() { AppUpdateState.openPlayMarket(requireContext()) }, ), - onSettingsClick = { - router.navigateToSettings(requireActivity().supportFragmentManager) - } ) } } @@ -180,7 +176,6 @@ internal fun DashboardListView( onReloadClick: () -> Unit, onSwipeRefresh: () -> Unit, paginationCallback: () -> Unit, - onSettingsClick: () -> Unit, onItemClick: (EnrolledCourse) -> Unit, appUpgradeParameters: AppUpdateState.AppUpgradeParameters, ) { @@ -193,7 +188,7 @@ internal fun DashboardListView( } val scrollState = rememberLazyListState() val firstVisibleIndex = remember { - mutableStateOf(scrollState.firstVisibleItemIndex) + mutableIntStateOf(scrollState.firstVisibleItemIndex) } Scaffold( @@ -244,15 +239,9 @@ internal fun DashboardListView( Column( modifier = Modifier .padding(paddingValues) - .statusBarsInset() .displayCutoutForLandscape(), horizontalAlignment = Alignment.CenterHorizontally ) { - Toolbar( - label = stringResource(id = R.string.dashboard_title), - canShowSettingsIcon = true, - onSettingsClick = onSettingsClick - ) Surface( color = MaterialTheme.appColors.background, @@ -285,12 +274,6 @@ internal fun DashboardListView( state = scrollState, contentPadding = contentPaddings, content = { - item() { - Column { - Header() - Spacer(modifier = Modifier.height(16.dp)) - } - } items(state.courses) { course -> CourseItem( apiHostUrl, @@ -329,7 +312,6 @@ internal fun DashboardListView( .then(contentWidth) .then(emptyStatePaddings) ) { - Header() EmptyState() } } @@ -491,24 +473,6 @@ private fun CourseItem( } } -@Composable -private fun Header() { - Text( - modifier = Modifier.testTag("txt_courses_title"), - text = stringResource(id = R.string.dashboard_courses), - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.displaySmall - ) - Text( - modifier = Modifier - .testTag("txt_courses_description") - .padding(top = 4.dp), - text = stringResource(id = R.string.dashboard_welcome_back), - color = MaterialTheme.appColors.textPrimaryVariant, - style = MaterialTheme.appTypography.titleSmall - ) -} - @Composable private fun EmptyState() { Box( @@ -542,7 +506,7 @@ private fun EmptyState() { @Preview(uiMode = UI_MODE_NIGHT_YES) @Composable private fun CourseItemPreview() { - OpenEdXTheme() { + OpenEdXTheme { CourseItem( "http://localhost:8000", mockCourseEnrolled, @@ -577,7 +541,6 @@ private fun DashboardListViewPreview() { refreshing = false, canLoadMore = false, paginationCallback = {}, - onSettingsClick = {}, appUpgradeParameters = AppUpdateState.AppUpgradeParameters() ) } @@ -609,7 +572,6 @@ private fun DashboardListViewTabletPreview() { refreshing = false, canLoadMore = false, paginationCallback = {}, - onSettingsClick = {}, appUpgradeParameters = AppUpdateState.AppUpgradeParameters() ) } 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 4d9b5cdbc..2c712bad6 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardRouter.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardRouter.kt @@ -20,5 +20,5 @@ interface DashboardRouter { fun navigateToAllEnrolledCourses(fm: FragmentManager) - fun getProgramFragmentInstance(): Fragment + fun getProgramFragment(): Fragment } diff --git a/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt b/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt index b2de66cd4..7a79f3c2e 100644 --- a/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt +++ b/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt @@ -39,8 +39,8 @@ 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.koin.androidx.viewmodel.ext.android.viewModel import org.openedx.core.adapter.NavigationFragmentAdapter import org.openedx.core.presentation.global.viewBinding import org.openedx.core.ui.crop @@ -51,17 +51,15 @@ 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 val viewModel by viewModel() private lateinit var adapter: NavigationFragmentAdapter override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -82,8 +80,8 @@ class LearnFragment : Fragment(R.layout.fragment_learn) { binding.viewPager.offscreenPageLimit = 2 adapter = NavigationFragmentAdapter(this).apply { - addFragment(DashboardGalleryFragment()) - addFragment(router.getProgramFragmentInstance()) + addFragment(viewModel.getDashboardFragment) + addFragment(viewModel.getProgramFragment) } binding.viewPager.adapter = adapter binding.viewPager.setUserInputEnabled(false) @@ -93,7 +91,7 @@ class LearnFragment : Fragment(R.layout.fragment_learn) { @Composable private fun Header( fragmentManager: FragmentManager, - viewPager: ViewPager2 + viewPager: ViewPager2, ) { val viewModel: LearnViewModel = koinViewModel() val windowSize = rememberWindowSize() @@ -120,7 +118,6 @@ private fun Header( viewModel.onSettingsClick(fragmentManager) } ) - if (viewModel.isProgramTypeWebView) { LearnDropdownMenu( modifier = Modifier @@ -136,7 +133,7 @@ private fun Header( private fun Title( modifier: Modifier = Modifier, label: String, - onSettingsClick: () -> Unit + onSettingsClick: () -> Unit, ) { Box( modifier = modifier.fillMaxWidth() @@ -169,7 +166,7 @@ private fun Title( @Composable private fun LearnDropdownMenu( modifier: Modifier = Modifier, - viewPager: ViewPager2 + viewPager: ViewPager2, ) { var expanded by remember { mutableStateOf(false) } var currentValue by remember { mutableStateOf(LearnType.COURSES) } @@ -212,7 +209,12 @@ private fun LearnDropdownMenu( MaterialTheme( colors = MaterialTheme.colors.copy(surface = MaterialTheme.appColors.background), - shapes = MaterialTheme.shapes.copy(medium = RoundedCornerShape(bottomStart = 8.dp, bottomEnd = 8.dp)) + shapes = MaterialTheme.shapes.copy( + medium = RoundedCornerShape( + bottomStart = 8.dp, + bottomEnd = 8.dp + ) + ) ) { DropdownMenu( modifier = Modifier diff --git a/dashboard/src/main/java/org/openedx/learn/presentation/LearnViewModel.kt b/dashboard/src/main/java/org/openedx/learn/presentation/LearnViewModel.kt index d2300f652..62ee774cb 100644 --- a/dashboard/src/main/java/org/openedx/learn/presentation/LearnViewModel.kt +++ b/dashboard/src/main/java/org/openedx/learn/presentation/LearnViewModel.kt @@ -1,18 +1,24 @@ package org.openedx.learn.presentation import androidx.fragment.app.FragmentManager +import org.openedx.DashboardNavigator 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 + private val dashboardRouter: DashboardRouter, ) : BaseViewModel() { + private val dashboardType get() = config.getDashboardConfig().getType() val isProgramTypeWebView get() = config.getProgramConfig().isViewTypeWebView() fun onSettingsClick(fragmentManager: FragmentManager) { dashboardRouter.navigateToSettings(fragmentManager) } + + val getDashboardFragment get() = DashboardNavigator(dashboardType).getDashboardFragment() + + val getProgramFragment get() = dashboardRouter.getProgramFragment() } diff --git a/dashboard/src/main/res/values-uk/strings.xml b/dashboard/src/main/res/values-uk/strings.xml index a7b3ef9d3..bf1c7da16 100644 --- a/dashboard/src/main/res/values-uk/strings.xml +++ b/dashboard/src/main/res/values-uk/strings.xml @@ -1,8 +1,6 @@ - Мої курси Курси - Ласкаво просимо назад. Продовжуймо навчатися. You are not enrolled in any courses yet. \ No newline at end of file diff --git a/dashboard/src/main/res/values/strings.xml b/dashboard/src/main/res/values/strings.xml index 23a33fb50..f83c35a2f 100644 --- a/dashboard/src/main/res/values/strings.xml +++ b/dashboard/src/main/res/values/strings.xml @@ -1,8 +1,6 @@ - Dashboard Courses - Welcome back. Let\'s keep learning. You are not enrolled in any courses yet. Learn Programs 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 3b74dbc42..ef79e1f32 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 @@ -46,6 +46,7 @@ import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel import org.koin.androidx.viewmodel.ext.android.viewModel import org.openedx.core.extension.loadUrl +import org.openedx.core.extension.takeIfNotEmpty import org.openedx.core.extension.toastMessage import org.openedx.core.presentation.dialog.alert.ActionDialogFragment import org.openedx.core.presentation.dialog.alert.InfoDialogFragment @@ -68,16 +69,15 @@ 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, - private val isNestedFragment: Boolean = false -) : Fragment() { +class ProgramFragment : Fragment() { private val viewModel by viewModel() + private var isNestedFragment = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - if (myPrograms.not()) { + isNestedFragment = arguments?.getBoolean(ARG_NESTED_FRAGMENT, false) ?: false + if (isNestedFragment.not()) { lifecycle.addObserver(viewModel) } } @@ -85,7 +85,7 @@ class ProgramFragment( override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? + savedInstanceState: Bundle?, ) = ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { @@ -96,7 +96,7 @@ class ProgramFragment( mutableStateOf(viewModel.hasInternetConnection) } - if (myPrograms.not()) { + if (isNestedFragment.not()) { DisposableEffect(uiState is ProgramUIState.CourseEnrolled) { if (uiState is ProgramUIState.CourseEnrolled) { @@ -157,7 +157,8 @@ class ProgramFragment( } linkAuthority.PROGRAM_INFO, - linkAuthority.COURSE_INFO -> { + linkAuthority.COURSE_INFO, + -> { viewModel.onViewCourseClick( fragmentManager = requireActivity().supportFragmentManager, courseId = param, @@ -198,23 +199,26 @@ class ProgramFragment( } private fun getInitialUrl(): String { - return arguments?.let { args -> - val pathId = args.getString(ARG_PATH_ID) ?: "" - viewModel.programConfig.programDetailUrlTemplate.replace("{$ARG_PATH_ID}", pathId) + val pathId = arguments?.getString(ARG_PATH_ID, "") + return pathId?.takeIfNotEmpty()?.let { + viewModel.programConfig.programDetailUrlTemplate.replace("{$ARG_PATH_ID}", it) } ?: viewModel.programConfig.programUrl } companion object { private const val ARG_PATH_ID = "path_id" + private const val ARG_NESTED_FRAGMENT = "nested_fragment" fun newInstance( - pathId: String, + pathId: String = "", + isNestedFragment: Boolean = false, ): ProgramFragment { - val fragment = ProgramFragment(false) - fragment.arguments = bundleOf( - ARG_PATH_ID to pathId, - ) - return fragment + return ProgramFragment().apply { + arguments = bundleOf( + ARG_PATH_ID to pathId, + ARG_NESTED_FRAGMENT to isNestedFragment + ) + } } } } From 7d623ce72b87814a9da8aee309b103fc68d2726e Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk <141041606+PavloNetrebchuk@users.noreply.github.com> Date: Fri, 7 Jun 2024 19:03:23 +0300 Subject: [PATCH 12/56] fix: Filter block IDs before download, minor refactoring (#333) --- .../outline/CourseOutlineScreen.kt | 21 +++++-------------- .../outline/CourseOutlineViewModel.kt | 19 +++++++++++++++++ .../course/presentation/ui/CourseUI.kt | 7 ++++--- .../course/presentation/ui/CourseVideosUI.kt | 12 ----------- 4 files changed, 28 insertions(+), 31 deletions(-) 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 1a29819f2..1f31b32de 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 @@ -65,7 +65,6 @@ 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.ui.CourseDatesBanner import org.openedx.course.presentation.ui.CourseDatesBannerTablet @@ -138,21 +137,11 @@ fun CourseOutlineScreen( ) }, onDownloadClick = { blocksIds -> - blocksIds.forEach { blockId -> - if (viewModel.isBlockDownloading(blockId)) { - viewModel.courseRouter.navigateToDownloadQueue( - fm = fragmentManager, - viewModel.getDownloadableChildren(blockId) - ?: arrayListOf() - ) - } else if (viewModel.isBlockDownloaded(blockId)) { - viewModel.removeDownloadModels(blockId) - } else { - viewModel.saveDownloadModels( - FileUtil(context).getExternalAppDir().path, blockId - ) - } - } + viewModel.downloadBlocks( + blocksIds = blocksIds, + fragmentManager = fragmentManager, + context = context + ) }, onResetDatesClick = { viewModel.resetCourseDatesBanner( 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 602c7d2fa..6ea080957 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 android.content.Context import androidx.fragment.app.FragmentManager import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableSharedFlow @@ -36,6 +37,7 @@ 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.core.utils.FileUtil import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseAnalyticsEvent @@ -385,4 +387,21 @@ class CourseOutlineViewModel( ) } } + + fun downloadBlocks(blocksIds: List, fragmentManager: FragmentManager, context: Context) { + blocksIds.forEach { blockId -> + if (isBlockDownloading(blockId)) { + courseRouter.navigateToDownloadQueue( + fm = fragmentManager, + getDownloadableChildren(blockId) ?: arrayListOf() + ) + } else if (isBlockDownloaded(blockId)) { + removeDownloadModels(blockId) + } else { + saveDownloadModels( + FileUtil(context).getExternalAppDir().path, blockId + ) + } + } + } } 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 27da57afb..7d8729fac 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 @@ -595,14 +595,15 @@ fun CourseSection( val arrowRotation by animateFloatAsState( targetValue = if (courseSectionsState == true) -90f else 90f, label = "" ) - val sectionIds = courseSubSections?.map { it.id }.orEmpty() - val filteredStatuses = downloadedStateMap.filterKeys { it in sectionIds }.values + val subSectionIds = courseSubSections?.map { it.id }.orEmpty() + val filteredStatuses = downloadedStateMap.filterKeys { it in subSectionIds }.values val downloadedState = when { filteredStatuses.isEmpty() -> null filteredStatuses.all { it.isDownloaded } -> DownloadedState.DOWNLOADED filteredStatuses.any { it.isWaitingOrDownloading } -> DownloadedState.DOWNLOADING else -> DownloadedState.NOT_DOWNLOADED } + val downloadBlockIds = downloadedStateMap.keys.filter { it in block.descendants } Column(modifier = modifier .clip(MaterialTheme.appShapes.cardShape) @@ -619,7 +620,7 @@ fun CourseSection( arrowDegrees = arrowRotation, downloadedState = downloadedState, onDownloadClick = { - onDownloadClick(downloadedStateMap.keys.toList()) + onDownloadClick(downloadBlockIds) } ) courseSubSections?.forEach { subSectionBlock -> 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 7beb0f91d..1a406181d 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 @@ -99,14 +99,6 @@ fun CourseVideosScreen( uiMessage = uiMessage, courseTitle = viewModel.courseTitle, videoSettings = videoSettings, - onItemClick = { block -> - viewModel.courseRouter.navigateToCourseSubsections( - fm = fragmentManager, - courseId = viewModel.courseId, - subSectionId = block.id, - mode = CourseViewMode.VIDEOS - ) - }, onExpandClick = { block -> viewModel.switchCourseSections(block.id) }, @@ -166,7 +158,6 @@ private fun CourseVideosUI( uiMessage: UIMessage?, courseTitle: String, videoSettings: VideoSettings, - onItemClick: (Block) -> Unit, onExpandClick: (Block) -> Unit, onSubSectionClick: (Block) -> Unit, onDownloadClick: (blocksIds: List) -> Unit, @@ -636,7 +627,6 @@ private fun CourseVideosScreenPreview() { ) ), courseTitle = "", - onItemClick = { }, onExpandClick = { }, onSubSectionClick = { }, videoSettings = VideoSettings.default, @@ -660,7 +650,6 @@ private fun CourseVideosScreenEmptyPreview() { "This course does not include any videos." ), courseTitle = "", - onItemClick = { }, onExpandClick = { }, onSubSectionClick = { }, videoSettings = VideoSettings.default, @@ -695,7 +684,6 @@ private fun CourseVideosScreenTabletPreview() { ) ), courseTitle = "", - onItemClick = { }, onExpandClick = { }, onSubSectionClick = { }, videoSettings = VideoSettings.default, From 651db4c5f0b04e67f86aa7f731706993bf370948 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk <141041606+PavloNetrebchuk@users.noreply.github.com> Date: Fri, 7 Jun 2024 21:44:57 +0300 Subject: [PATCH 13/56] fix: Update feature flag name (#334) --- default_config/dev/config.yaml | 2 +- default_config/prod/config.yaml | 2 +- default_config/stage/config.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index 8b1dc8f07..da6687b2e 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -80,5 +80,5 @@ WHATS_NEW_ENABLED: false SOCIAL_AUTH_ENABLED: false #Course navigation feature flags UI_COMPONENTS: - COURSE_NESTED_LIST_ENABLED: false + COURSE_DROPDOWN_NAVIGATION_ENABLED: false COURSE_UNIT_PROGRESS_ENABLED: false diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml index 8b1dc8f07..da6687b2e 100644 --- a/default_config/prod/config.yaml +++ b/default_config/prod/config.yaml @@ -80,5 +80,5 @@ WHATS_NEW_ENABLED: false SOCIAL_AUTH_ENABLED: false #Course navigation feature flags UI_COMPONENTS: - COURSE_NESTED_LIST_ENABLED: false + COURSE_DROPDOWN_NAVIGATION_ENABLED: false COURSE_UNIT_PROGRESS_ENABLED: false diff --git a/default_config/stage/config.yaml b/default_config/stage/config.yaml index 8b1dc8f07..da6687b2e 100644 --- a/default_config/stage/config.yaml +++ b/default_config/stage/config.yaml @@ -80,5 +80,5 @@ WHATS_NEW_ENABLED: false SOCIAL_AUTH_ENABLED: false #Course navigation feature flags UI_COMPONENTS: - COURSE_NESTED_LIST_ENABLED: false + COURSE_DROPDOWN_NAVIGATION_ENABLED: false COURSE_UNIT_PROGRESS_ENABLED: false From 21bd1a17233025e8690623b90e3c2d9c81efe594 Mon Sep 17 00:00:00 2001 From: Volodymyr Chekyrta <127732735+volodymyr-chekyrta@users.noreply.github.com> Date: Sat, 8 Jun 2024 11:15:41 +0300 Subject: [PATCH 14/56] fix: sourceSets dir fallback (#335) --- app/build.gradle | 8 ++++---- core/build.gradle | 14 +++++++------- default_config/dev/config.yaml | 2 ++ default_config/prod/config.yaml | 2 ++ default_config/stage/config.yaml | 2 ++ 5 files changed, 17 insertions(+), 11 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 2b0ab4f74..86363a2b7 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,6 +1,6 @@ def config = configHelper.fetchConfig() def appId = config.getOrDefault("APPLICATION_ID", "org.openedx.app") -def platformName = config.getOrDefault("PLATFORM_NAME", "OpenEdx").toLowerCase() +def themeDirectory = config.getOrDefault("THEME_DIRECTORY", "openedx") def firebaseConfig = config.get('FIREBASE') def firebaseEnabled = firebaseConfig?.getOrDefault('ENABLED', false) @@ -63,13 +63,13 @@ android { sourceSets { prod { - res.srcDirs = ["src/$platformName/res"] + res.srcDirs = ["src/$themeDirectory/res"] } develop { - res.srcDirs = ["src/$platformName/res"] + res.srcDirs = ["src/$themeDirectory/res"] } stage { - res.srcDirs = ["src/$platformName/res"] + res.srcDirs = ["src/$themeDirectory/res"] } } diff --git a/core/build.gradle b/core/build.gradle index f69d633cb..f5dc7841f 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -17,7 +17,7 @@ plugins { def currentFlavour = getCurrentFlavor() def config = configHelper.fetchConfig() -def platformName = config.getOrDefault("PLATFORM_NAME", "OpenEdx").toLowerCase() +def themeDirectory = config.getOrDefault("THEME_DIRECTORY", "openedx") android { compileSdk 34 @@ -50,16 +50,16 @@ android { sourceSets { prod { - java.srcDirs = ["src/$platformName"] - res.srcDirs = ["src/$platformName/res"] + java.srcDirs = ["src/$themeDirectory"] + res.srcDirs = ["src/$themeDirectory/res"] } develop { - java.srcDirs = ["src/$platformName"] - res.srcDirs = ["src/$platformName/res"] + java.srcDirs = ["src/$themeDirectory"] + res.srcDirs = ["src/$themeDirectory/res"] } stage { - java.srcDirs = ["src/$platformName"] - res.srcDirs = ["src/$platformName/res"] + java.srcDirs = ["src/$themeDirectory"] + res.srcDirs = ["src/$themeDirectory/res"] } main { assets { diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index da6687b2e..eee22e36d 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -72,6 +72,8 @@ BRANCH: #Platform names PLATFORM_NAME: "OpenEdX" PLATFORM_FULL_NAME: "OpenEdX" +#App sourceSets dir +THEME_DIRECTORY: "openedx" #tokenType enum accepts JWT and BEARER only TOKEN_TYPE: "JWT" #feature flag for activating What’s New feature diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml index da6687b2e..eee22e36d 100644 --- a/default_config/prod/config.yaml +++ b/default_config/prod/config.yaml @@ -72,6 +72,8 @@ BRANCH: #Platform names PLATFORM_NAME: "OpenEdX" PLATFORM_FULL_NAME: "OpenEdX" +#App sourceSets dir +THEME_DIRECTORY: "openedx" #tokenType enum accepts JWT and BEARER only TOKEN_TYPE: "JWT" #feature flag for activating What’s New feature diff --git a/default_config/stage/config.yaml b/default_config/stage/config.yaml index da6687b2e..eee22e36d 100644 --- a/default_config/stage/config.yaml +++ b/default_config/stage/config.yaml @@ -72,6 +72,8 @@ BRANCH: #Platform names PLATFORM_NAME: "OpenEdX" PLATFORM_FULL_NAME: "OpenEdX" +#App sourceSets dir +THEME_DIRECTORY: "openedx" #tokenType enum accepts JWT and BEARER only TOKEN_TYPE: "JWT" #feature flag for activating What’s New feature From 07f6dcacbfc80e2cb42bb2e197d2cb9dff9c4a00 Mon Sep 17 00:00:00 2001 From: Omer Habib <30689349+omerhabib26@users.noreply.github.com> Date: Sat, 8 Jun 2024 13:16:22 +0500 Subject: [PATCH 15/56] fix: Crash on opening a course from DashboardListFragment (#336) - Replace the fragmentManager of parentFragment with activity fix: LEARNER-10035 --- .../org/openedx/dashboard/presentation/DashboardListFragment.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt index 31aaa2e3c..e3d6abdf6 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt @@ -139,7 +139,7 @@ class DashboardListFragment : Fragment() { onItemClick = { viewModel.dashboardCourseClickedEvent(it.course.id, it.course.name) router.navigateToCourseOutline( - requireParentFragment().parentFragmentManager, + requireActivity().supportFragmentManager, it.course.id, it.course.name, it.mode From d845e5ee72a313e63522ec3e7f641f583b6f1555 Mon Sep 17 00:00:00 2001 From: Igor Date: Mon, 10 Jun 2024 10:12:40 +0200 Subject: [PATCH 16/56] feat: added the external router for deep links (#320) --- app/src/main/AndroidManifest.xml | 3 +- .../main/java/org/openedx/app/AppActivity.kt | 11 +- .../main/java/org/openedx/app/AppRouter.kt | 16 +- .../main/java/org/openedx/app/AppViewModel.kt | 8 + .../main/java/org/openedx/app/MainFragment.kt | 38 +- .../java/org/openedx/app/deeplink/DeepLink.kt | 22 + .../openedx/app/deeplink/DeepLinkRouter.kt | 507 ++++++++++++++++++ .../java/org/openedx/app/deeplink/HomeTab.kt | 8 + .../java/org/openedx/app/deeplink/Screen.kt | 20 + .../main/java/org/openedx/app/di/AppModule.kt | 2 + .../java/org/openedx/app/di/ScreenModule.kt | 2 +- .../test/java/org/openedx/AppViewModelTest.kt | 5 + .../openedx/auth/presentation/AuthRouter.kt | 7 +- .../course/presentation/CourseRouter.kt | 2 +- .../container/CourseContainerFragment.kt | 6 +- .../handouts/HandoutsWebViewFragment.kt | 20 +- .../learn/presentation/LearnFragment.kt | 29 +- .../openedx/learn/presentation/LearnTab.kt | 6 + .../detail/CourseDetailsFragment.kt | 2 +- .../presentation/info/CourseInfoViewModel.kt | 2 +- .../discussion/data/api/DiscussionApi.kt | 15 + .../data/repository/DiscussionRepository.kt | 16 +- .../domain/interactor/DiscussionInteractor.kt | 12 +- .../org/openedx/whatsnew/WhatsNewRouter.kt | 7 +- .../whatsnew/WhatsNewViewModel.kt | 3 +- 25 files changed, 730 insertions(+), 39 deletions(-) create mode 100644 app/src/main/java/org/openedx/app/deeplink/DeepLink.kt create mode 100644 app/src/main/java/org/openedx/app/deeplink/DeepLinkRouter.kt create mode 100644 app/src/main/java/org/openedx/app/deeplink/HomeTab.kt create mode 100644 app/src/main/java/org/openedx/app/deeplink/Screen.kt create mode 100644 dashboard/src/main/java/org/openedx/learn/presentation/LearnTab.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3e8282acb..efc65add4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -41,7 +41,8 @@ android:exported="true" android:fitsSystemWindows="true" android:theme="@style/Theme.App.Starting" - android:windowSoftInputMode="adjustPan"> + android:windowSoftInputMode="adjustPan" + android:launchMode="singleInstance"> diff --git a/app/src/main/java/org/openedx/app/AppActivity.kt b/app/src/main/java/org/openedx/app/AppActivity.kt index 5ab0d0b0e..9781b0ca7 100644 --- a/app/src/main/java/org/openedx/app/AppActivity.kt +++ b/app/src/main/java/org/openedx/app/AppActivity.kt @@ -18,6 +18,7 @@ import io.branch.referral.Branch.BranchUniversalReferralInitListener import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.openedx.app.databinding.ActivityAppBinding +import org.openedx.app.deeplink.DeepLink import org.openedx.auth.presentation.logistration.LogistrationFragment import org.openedx.auth.presentation.signin.SignInFragment import org.openedx.core.data.storage.CorePreferences @@ -145,10 +146,14 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { super.onStart() if (viewModel.isBranchEnabled) { - val callback = BranchUniversalReferralInitListener { _, linkProperties, error -> - if (linkProperties != null) { + val callback = BranchUniversalReferralInitListener { branchUniversalObject, _, error -> + if (branchUniversalObject?.contentMetadata?.customMetadata != null) { branchLogger.i { "Branch init complete." } - branchLogger.i { linkProperties.controlParams.toString() } + branchLogger.i { branchUniversalObject.contentMetadata.customMetadata.toString() } + viewModel.makeExternalRoute( + fm = supportFragmentManager, + deepLink = DeepLink(branchUniversalObject.contentMetadata.customMetadata) + ) } else if (error != null) { branchLogger.e { "Branch init failed. Caused by -" + error.message } } diff --git a/app/src/main/java/org/openedx/app/AppRouter.kt b/app/src/main/java/org/openedx/app/AppRouter.kt index fe4394cde..99eb919dc 100644 --- a/app/src/main/java/org/openedx/app/AppRouter.kt +++ b/app/src/main/java/org/openedx/app/AppRouter.kt @@ -59,10 +59,15 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di ProfileRouter, AppUpgradeRouter, WhatsNewRouter { //region AuthRouter - override fun navigateToMain(fm: FragmentManager, courseId: String?, infoType: String?) { + override fun navigateToMain( + fm: FragmentManager, + courseId: String?, + infoType: String?, + openTab: String + ) { fm.popBackStack() fm.beginTransaction() - .replace(R.id.container, MainFragment.newInstance(courseId, infoType)) + .replace(R.id.container, MainFragment.newInstance(courseId, infoType, openTab)) .commit() } @@ -286,12 +291,11 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di override fun navigateToHandoutsWebView( fm: FragmentManager, courseId: String, - title: String, type: HandoutsType, ) { replaceFragmentWithBackStack( fm, - HandoutsWebViewFragment.newInstance(title, type.name, courseId) + HandoutsWebViewFragment.newInstance(type.name, courseId) ) } //endregion @@ -409,6 +413,10 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di } //endregion + fun getVisibleFragment(fm: FragmentManager): Fragment? { + return fm.fragments.firstOrNull { it.isVisible } + } + private fun replaceFragmentWithBackStack(fm: FragmentManager, fragment: Fragment) { fm.beginTransaction() .replace(R.id.container, fragment, fragment.javaClass.simpleName) diff --git a/app/src/main/java/org/openedx/app/AppViewModel.kt b/app/src/main/java/org/openedx/app/AppViewModel.kt index c18e48026..3fc49859f 100644 --- a/app/src/main/java/org/openedx/app/AppViewModel.kt +++ b/app/src/main/java/org/openedx/app/AppViewModel.kt @@ -1,5 +1,6 @@ package org.openedx.app +import androidx.fragment.app.FragmentManager import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import androidx.lifecycle.viewModelScope @@ -7,6 +8,8 @@ import androidx.room.RoomDatabase import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.openedx.app.deeplink.DeepLink +import org.openedx.app.deeplink.DeepLinkRouter import org.openedx.app.system.notifier.AppNotifier import org.openedx.app.system.notifier.LogoutEvent import org.openedx.core.BaseViewModel @@ -22,6 +25,7 @@ class AppViewModel( private val preferencesManager: CorePreferences, private val dispatcher: CoroutineDispatcher, private val analytics: AppAnalytics, + private val deepLinkRouter: DeepLinkRouter, private val fileUtil: FileUtil, ) : BaseViewModel() { @@ -71,6 +75,10 @@ class AppViewModel( preferencesManager.canResetAppDirectory = false } + fun makeExternalRoute(fm: FragmentManager, deepLink: DeepLink) { + deepLinkRouter.makeRoute(fm, deepLink) + } + private fun setUserId() { preferencesManager.user?.let { analytics.setUserIdForSession(it.id) diff --git a/app/src/main/java/org/openedx/app/MainFragment.kt b/app/src/main/java/org/openedx/app/MainFragment.kt index 7087fee8f..62857ee9f 100644 --- a/app/src/main/java/org/openedx/app/MainFragment.kt +++ b/app/src/main/java/org/openedx/app/MainFragment.kt @@ -12,11 +12,13 @@ import kotlinx.coroutines.launch import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.openedx.app.databinding.FragmentMainBinding +import org.openedx.app.deeplink.HomeTab 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.discovery.presentation.DiscoveryRouter import org.openedx.learn.presentation.LearnFragment +import org.openedx.learn.presentation.LearnTab import org.openedx.profile.presentation.profile.ProfileFragment class MainFragment : Fragment(R.layout.fragment_main) { @@ -60,8 +62,6 @@ class MainFragment : Fragment(R.layout.fragment_main) { } true } - // Trigger click event for the first tab on initial load - binding.bottomNavView.selectedItemId = binding.bottomNavView.selectedItemId viewModel.isBottomBarEnabled.observe(viewLifecycleOwner) { isBottomBarEnabled -> enableBottomBar(isBottomBarEnabled) @@ -89,6 +89,22 @@ class MainFragment : Fragment(R.layout.fragment_main) { putString(ARG_COURSE_ID, "") putString(ARG_INFO_TYPE, "") } + + when (requireArguments().getString(ARG_OPEN_TAB, HomeTab.LEARN.name)) { + HomeTab.LEARN.name, + HomeTab.PROGRAMS.name -> { + binding.bottomNavView.selectedItemId = R.id.fragmentLearn + } + + HomeTab.DISCOVER.name -> { + binding.bottomNavView.selectedItemId = R.id.fragmentDiscover + } + + HomeTab.PROFILE.name -> { + binding.bottomNavView.selectedItemId = R.id.fragmentProfile + } + } + requireArguments().remove(ARG_OPEN_TAB) } } @@ -96,8 +112,14 @@ class MainFragment : Fragment(R.layout.fragment_main) { binding.viewPager.orientation = ViewPager2.ORIENTATION_HORIZONTAL binding.viewPager.offscreenPageLimit = 4 + val openTab = requireArguments().getString(ARG_OPEN_TAB, HomeTab.LEARN.name) + val learnTab = if (openTab == HomeTab.PROGRAMS.name) { + LearnTab.PROGRAMS + } else { + LearnTab.COURSES + } adapter = NavigationFragmentAdapter(this).apply { - addFragment(LearnFragment()) + addFragment(LearnFragment.newInstance(openTab = learnTab.name)) addFragment(viewModel.getDiscoveryFragment) addFragment(ProfileFragment()) } @@ -114,11 +136,17 @@ class MainFragment : Fragment(R.layout.fragment_main) { companion object { private const val ARG_COURSE_ID = "courseId" private const val ARG_INFO_TYPE = "info_type" - fun newInstance(courseId: String? = null, infoType: String? = null): MainFragment { + private const val ARG_OPEN_TAB = "open_tab" + fun newInstance( + courseId: String? = null, + infoType: String? = null, + openTab: String = HomeTab.LEARN.name + ): MainFragment { val fragment = MainFragment() fragment.arguments = bundleOf( ARG_COURSE_ID to courseId, - ARG_INFO_TYPE to infoType + ARG_INFO_TYPE to infoType, + ARG_OPEN_TAB to openTab ) return fragment } diff --git a/app/src/main/java/org/openedx/app/deeplink/DeepLink.kt b/app/src/main/java/org/openedx/app/deeplink/DeepLink.kt new file mode 100644 index 000000000..2b65a92b1 --- /dev/null +++ b/app/src/main/java/org/openedx/app/deeplink/DeepLink.kt @@ -0,0 +1,22 @@ +package org.openedx.app.deeplink + +class DeepLink(params: Map) { + + val screenName = params[Keys.SCREEN_NAME.value] + val courseId = params[Keys.COURSE_ID.value] + val pathId = params[Keys.PATH_ID.value] + val componentId = params[Keys.COMPONENT_ID.value] + val topicId = params[Keys.TOPIC_ID.value] + val threadId = params[Keys.THREAD_ID.value] + val commentId = params[Keys.COMMENT_ID.value] + + enum class Keys(val value: String) { + SCREEN_NAME("screen_name"), + COURSE_ID("course_id"), + PATH_ID("path_id"), + COMPONENT_ID("component_id"), + TOPIC_ID("topic_id"), + THREAD_ID("thread_id"), + COMMENT_ID("comment_id") + } +} diff --git a/app/src/main/java/org/openedx/app/deeplink/DeepLinkRouter.kt b/app/src/main/java/org/openedx/app/deeplink/DeepLinkRouter.kt new file mode 100644 index 000000000..02bc5cd0e --- /dev/null +++ b/app/src/main/java/org/openedx/app/deeplink/DeepLinkRouter.kt @@ -0,0 +1,507 @@ +package org.openedx.app.deeplink + +import androidx.fragment.app.FragmentManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.openedx.app.AppRouter +import org.openedx.app.MainFragment +import org.openedx.app.R +import org.openedx.auth.presentation.signin.SignInFragment +import org.openedx.core.FragmentViewType +import org.openedx.core.config.Config +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.presentation.course.CourseViewMode +import org.openedx.course.domain.interactor.CourseInteractor +import org.openedx.course.presentation.handouts.HandoutsType +import org.openedx.discovery.presentation.catalog.WebViewLink +import org.openedx.discussion.domain.interactor.DiscussionInteractor +import org.openedx.discussion.presentation.topics.DiscussionTopicsViewModel +import kotlin.coroutines.CoroutineContext + +class DeepLinkRouter( + private val config: Config, + private val appRouter: AppRouter, + private val corePreferences: CorePreferences, + private val courseInteractor: CourseInteractor, + private val discussionInteractor: DiscussionInteractor +) : CoroutineScope { + + override val coroutineContext: CoroutineContext + get() = Dispatchers.Default + + private val isUserLoggedIn + get() = corePreferences.user != null + + fun makeRoute(fm: FragmentManager, deepLink: DeepLink) { + val screenName = deepLink.screenName ?: return + when (screenName) { + // Discovery + Screen.DISCOVERY.screenName -> { + navigateToDiscoveryScreen(fm = fm) + return + } + + Screen.DISCOVERY_COURSE_DETAIL.screenName -> { + navigateToCourseDetail( + fm = fm, + deepLink = deepLink + ) + return + } + + Screen.DISCOVERY_PROGRAM_DETAIL.screenName -> { + navigateToProgramDetail( + fm = fm, + deepLink = deepLink + ) + return + } + } + + if (!isUserLoggedIn) { + navigateToSignIn(fm = fm) + return + } + + when (screenName) { + // Course + Screen.COURSE_DASHBOARD.screenName -> { + navigateToDashboard(fm = fm) + navigateToCourseDashboard( + fm = fm, + deepLink = deepLink + ) + } + + Screen.COURSE_VIDEOS.screenName -> { + navigateToDashboard(fm = fm) + navigateToCourseVideos( + fm = fm, + deepLink = deepLink + ) + } + + Screen.COURSE_DATES.screenName -> { + navigateToDashboard(fm = fm) + navigateToCourseDates( + fm = fm, + deepLink = deepLink + ) + } + + Screen.COURSE_DISCUSSION.screenName -> { + navigateToDashboard(fm = fm) + navigateToCourseDiscussion( + fm = fm, + deepLink = deepLink + ) + } + + Screen.COURSE_HANDOUT.screenName -> { + navigateToDashboard(fm = fm) + navigateToCourseMore( + fm = fm, + deepLink = deepLink + ) + navigateToCourseHandout( + fm = fm, + deepLink = deepLink + ) + } + + Screen.COURSE_ANNOUNCEMENT.screenName -> { + navigateToDashboard(fm = fm) + navigateToCourseMore( + fm = fm, + deepLink = deepLink + ) + navigateToCourseAnnouncement( + fm = fm, + deepLink = deepLink + ) + } + + Screen.COURSE_COMPONENT.screenName -> { + navigateToDashboard(fm = fm) + navigateToCourseDashboard( + fm = fm, + deepLink = deepLink + ) + navigateToCourseComponent( + fm = fm, + deepLink = deepLink + ) + } + + // Program + Screen.PROGRAM.screenName -> { + navigateToProgram( + fm = fm, + deepLink = deepLink + ) + } + + // Discussions + Screen.DISCUSSION_TOPIC.screenName -> { + navigateToDashboard(fm = fm) + navigateToCourseDiscussion( + fm = fm, + deepLink = deepLink + ) + navigateToDiscussionTopic( + fm = fm, + deepLink = deepLink + ) + } + + Screen.DISCUSSION_POST.screenName -> { + navigateToDashboard(fm = fm) + navigateToCourseDiscussion( + fm = fm, + deepLink = deepLink + ) + navigateToDiscussionPost( + fm = fm, + deepLink = deepLink + ) + } + + Screen.DISCUSSION_COMMENT.screenName -> { + navigateToDashboard(fm = fm) + navigateToCourseDiscussion( + fm = fm, + deepLink = deepLink + ) + navigateToDiscussionComment( + fm = fm, + deepLink = deepLink + ) + } + + // Profile + Screen.PROFILE.screenName, + Screen.USER_PROFILE.screenName -> { + navigateToProfile(fm = fm) + } + } + } + + // Returns true if there was a successful redirect to the discovery screen + private fun navigateToDiscoveryScreen(fm: FragmentManager): Boolean { + return if (isUserLoggedIn) { + fm.popBackStack() + fm.beginTransaction() + .replace(R.id.container, MainFragment.newInstance(openTab = "DISCOVER")) + .commitNow() + true + } else if (!config.isPreLoginExperienceEnabled()) { + navigateToSignIn(fm = fm) + false + } else if (config.getDiscoveryConfig().isViewTypeWebView()) { + appRouter.navigateToWebDiscoverCourses( + fm = fm, + querySearch = "" + ) + true + } else { + appRouter.navigateToNativeDiscoverCourses( + fm = fm, + querySearch = "" + ) + true + } + } + + private fun navigateToCourseDetail(fm: FragmentManager, deepLink: DeepLink) { + deepLink.courseId?.let { courseId -> + if (navigateToDiscoveryScreen(fm = fm)) { + appRouter.navigateToCourseInfo( + fm = fm, + courseId = courseId, + infoType = WebViewLink.Authority.COURSE_INFO.name + ) + } + } + } + + private fun navigateToProgramDetail(fm: FragmentManager, deepLink: DeepLink) { + deepLink.pathId?.let { pathId -> + if (navigateToDiscoveryScreen(fm = fm)) { + appRouter.navigateToCourseInfo( + fm = fm, + courseId = pathId, + infoType = WebViewLink.Authority.PROGRAM_INFO.name + ) + } + } + } + + private fun navigateToSignIn(fm: FragmentManager) { + if (appRouter.getVisibleFragment(fm = fm) !is SignInFragment) { + appRouter.navigateToSignIn( + fm = fm, + courseId = null, + infoType = null + ) + } + } + + private fun navigateToCourseDashboard(fm: FragmentManager, deepLink: DeepLink) { + deepLink.courseId?.let { courseId -> + appRouter.navigateToCourseOutline( + fm = fm, + courseId = courseId, + courseTitle = "", + enrollmentMode = "" + ) + } + } + + private fun navigateToCourseVideos(fm: FragmentManager, deepLink: DeepLink) { + deepLink.courseId?.let { courseId -> + appRouter.navigateToCourseOutline( + fm = fm, + courseId = courseId, + courseTitle = "", + enrollmentMode = "", + openTab = "VIDEOS" + ) + } + } + + private fun navigateToCourseDates(fm: FragmentManager, deepLink: DeepLink) { + deepLink.courseId?.let { courseId -> + appRouter.navigateToCourseOutline( + fm = fm, + courseId = courseId, + courseTitle = "", + enrollmentMode = "", + openTab = "DATES" + ) + } + } + + private fun navigateToCourseDiscussion(fm: FragmentManager, deepLink: DeepLink) { + deepLink.courseId?.let { courseId -> + appRouter.navigateToCourseOutline( + fm = fm, + courseId = courseId, + courseTitle = "", + enrollmentMode = "", + openTab = "DISCUSSIONS" + ) + } + } + + private fun navigateToCourseMore(fm: FragmentManager, deepLink: DeepLink) { + deepLink.courseId?.let { courseId -> + appRouter.navigateToCourseOutline( + fm = fm, + courseId = courseId, + courseTitle = "", + enrollmentMode = "", + openTab = "MORE" + ) + } + } + + private fun navigateToCourseHandout(fm: FragmentManager, deepLink: DeepLink) { + deepLink.courseId?.let { courseId -> + appRouter.navigateToHandoutsWebView( + fm = fm, + courseId = courseId, + type = HandoutsType.Handouts + ) + } + } + + private fun navigateToCourseAnnouncement(fm: FragmentManager, deepLink: DeepLink) { + deepLink.courseId?.let { courseId -> + appRouter.navigateToHandoutsWebView( + fm = fm, + courseId = courseId, + type = HandoutsType.Announcements + ) + } + } + + private fun navigateToCourseComponent(fm: FragmentManager, deepLink: DeepLink) { + deepLink.courseId?.let { courseId -> + deepLink.componentId?.let { componentId -> + launch { + try { + val courseStructure = courseInteractor.getCourseStructure(courseId) + courseStructure.blockData + .find { it.descendants.contains(componentId) }?.let { block -> + appRouter.navigateToCourseContainer( + fm = fm, + courseId = courseId, + unitId = block.id, + componentId = componentId, + mode = CourseViewMode.FULL + ) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + } + } + + private fun navigateToProgram(fm: FragmentManager, deepLink: DeepLink) { + val pathId = deepLink.pathId + if (pathId == null) { + navigateToPrograms(fm = fm) + } else { + appRouter.navigateToEnrolledProgramInfo( + fm = fm, + pathId = pathId + ) + } + } + + private fun navigateToDiscussionTopic(fm: FragmentManager, deepLink: DeepLink) { + deepLink.courseId?.let { courseId -> + deepLink.topicId?.let { topicId -> + launch { + try { + discussionInteractor.getCourseTopics(courseId) + .find { it.id == topicId }?.let { topic -> + launch(Dispatchers.Main) { + appRouter.navigateToDiscussionThread( + fm = fm, + action = DiscussionTopicsViewModel.TOPIC, + courseId = courseId, + topicId = topicId, + title = topic.name, + viewType = FragmentViewType.FULL_CONTENT + ) + } + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + } + } + + private fun navigateToDiscussionPost(fm: FragmentManager, deepLink: DeepLink) { + deepLink.courseId?.let { courseId -> + deepLink.topicId?.let { topicId -> + deepLink.threadId?.let { threadId -> + launch { + try { + discussionInteractor.getCourseTopics(courseId) + .find { it.id == topicId }?.let { topic -> + launch(Dispatchers.Main) { + appRouter.navigateToDiscussionThread( + fm = fm, + action = DiscussionTopicsViewModel.TOPIC, + courseId = courseId, + topicId = topicId, + title = topic.name, + viewType = FragmentViewType.FULL_CONTENT + ) + } + } + val thread = discussionInteractor.getThread( + threadId, + courseId, + topicId + ) + launch(Dispatchers.Main) { + appRouter.navigateToDiscussionComments( + fm = fm, + thread = thread + ) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + } + } + } + + private fun navigateToDiscussionComment(fm: FragmentManager, deepLink: DeepLink) { + deepLink.courseId?.let { courseId -> + deepLink.topicId?.let { topicId -> + deepLink.threadId?.let { threadId -> + deepLink.commentId?.let { commentId -> + launch { + try { + discussionInteractor.getCourseTopics(courseId) + .find { it.id == topicId }?.let { topic -> + launch(Dispatchers.Main) { + appRouter.navigateToDiscussionThread( + fm = fm, + action = DiscussionTopicsViewModel.TOPIC, + courseId = courseId, + topicId = topicId, + title = topic.name, + viewType = FragmentViewType.FULL_CONTENT + ) + } + } + val thread = discussionInteractor.getThread( + threadId, + courseId, + topicId + ) + launch(Dispatchers.Main) { + appRouter.navigateToDiscussionComments( + fm = fm, + thread = thread + ) + } + val commentsData = discussionInteractor.getThreadComment(commentId) + commentsData.results.firstOrNull()?.let { comment -> + launch(Dispatchers.Main) { + appRouter.navigateToDiscussionResponses( + fm = fm, + comment = comment, + isClosed = false + ) + } + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + } + } + } + } + + private fun navigateToDashboard(fm: FragmentManager) { + appRouter.navigateToMain( + fm = fm, + courseId = null, + infoType = null, + openTab = "LEARN" + ) + } + + private fun navigateToPrograms(fm: FragmentManager) { + appRouter.navigateToMain( + fm = fm, + courseId = null, + infoType = null, + openTab = "PROGRAMS" + ) + } + + private fun navigateToProfile(fm: FragmentManager) { + appRouter.navigateToMain( + fm = fm, + courseId = null, + infoType = null, + openTab = "PROFILE" + ) + } +} diff --git a/app/src/main/java/org/openedx/app/deeplink/HomeTab.kt b/app/src/main/java/org/openedx/app/deeplink/HomeTab.kt new file mode 100644 index 000000000..c020cf636 --- /dev/null +++ b/app/src/main/java/org/openedx/app/deeplink/HomeTab.kt @@ -0,0 +1,8 @@ +package org.openedx.app.deeplink + +enum class HomeTab { + LEARN, + PROGRAMS, + DISCOVER, + PROFILE +} diff --git a/app/src/main/java/org/openedx/app/deeplink/Screen.kt b/app/src/main/java/org/openedx/app/deeplink/Screen.kt new file mode 100644 index 000000000..e877649e8 --- /dev/null +++ b/app/src/main/java/org/openedx/app/deeplink/Screen.kt @@ -0,0 +1,20 @@ +package org.openedx.app.deeplink + +enum class Screen(val screenName: String) { + DISCOVERY("discovery"), + DISCOVERY_COURSE_DETAIL("discovery_course_detail"), + DISCOVERY_PROGRAM_DETAIL("discovery_program_detail"), + COURSE_DASHBOARD("course_dashboard"), + COURSE_VIDEOS("course_videos"), + COURSE_DISCUSSION("course_discussion"), + COURSE_DATES("course_dates"), + COURSE_HANDOUT("course_handout"), + COURSE_ANNOUNCEMENT("course_announcement"), + COURSE_COMPONENT("course_component"), + PROGRAM("program"), + DISCUSSION_TOPIC("discussion_topic"), + DISCUSSION_POST("discussion_post"), + DISCUSSION_COMMENT("discussion_comment"), + PROFILE("profile"), + USER_PROFILE("user_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 a5ec76b37..1d1dc7e0c 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -12,6 +12,7 @@ import org.koin.core.qualifier.named import org.koin.dsl.module import org.openedx.app.AnalyticsManager import org.openedx.app.AppAnalytics +import org.openedx.app.deeplink.DeepLinkRouter import org.openedx.app.AppRouter import org.openedx.app.BuildConfig import org.openedx.app.data.storage.PreferencesManager @@ -109,6 +110,7 @@ val appModule = module { single { get() } single { get() } single { get() } + single { DeepLinkRouter(get(), get(), get(), get(), get()) } single { NetworkConnection(get()) } diff --git a/app/src/main/java/org/openedx/app/di/ScreenModule.kt b/app/src/main/java/org/openedx/app/di/ScreenModule.kt index 393b16248..0c2c85474 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -67,7 +67,7 @@ import org.openedx.whatsnew.presentation.whatsnew.WhatsNewViewModel val screenModule = module { - viewModel { AppViewModel(get(), get(), get(), get(), get(named("IODispatcher")), get(), get()) } + viewModel { AppViewModel(get(), get(), get(), get(), get(named("IODispatcher")), get(), get(), get()) } viewModel { MainViewModel(get(), get(), get()) } factory { AuthRepository(get(), get(), get()) } diff --git a/app/src/test/java/org/openedx/AppViewModelTest.kt b/app/src/test/java/org/openedx/AppViewModelTest.kt index c81c9c2e5..35a2d3d96 100644 --- a/app/src/test/java/org/openedx/AppViewModelTest.kt +++ b/app/src/test/java/org/openedx/AppViewModelTest.kt @@ -21,6 +21,7 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule import org.openedx.app.AppAnalytics +import org.openedx.app.deeplink.DeepLinkRouter import org.openedx.app.AppViewModel import org.openedx.app.data.storage.PreferencesManager import org.openedx.app.room.AppDatabase @@ -44,6 +45,7 @@ class AppViewModelTest { private val preferencesManager = mockk() private val analytics = mockk() private val fileUtil = mockk() + private val deepLinkRouter = mockk() private val user = User(0, "", "", "") @@ -71,6 +73,7 @@ class AppViewModelTest { preferencesManager, dispatcher, analytics, + deepLinkRouter, fileUtil ) @@ -102,6 +105,7 @@ class AppViewModelTest { preferencesManager, dispatcher, analytics, + deepLinkRouter, fileUtil ) @@ -135,6 +139,7 @@ class AppViewModelTest { preferencesManager, dispatcher, analytics, + deepLinkRouter, fileUtil ) diff --git a/auth/src/main/java/org/openedx/auth/presentation/AuthRouter.kt b/auth/src/main/java/org/openedx/auth/presentation/AuthRouter.kt index 9b1266119..945acf02e 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/AuthRouter.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/AuthRouter.kt @@ -4,7 +4,12 @@ import androidx.fragment.app.FragmentManager interface AuthRouter { - fun navigateToMain(fm: FragmentManager, courseId: String?, infoType: String?) + fun navigateToMain( + fm: FragmentManager, + courseId: String?, + infoType: String?, + openTab: String = "" + ) fun navigateToSignIn(fm: FragmentManager, courseId: String?, infoType: String?) diff --git a/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt b/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt index 9b34e7617..3b59be61d 100644 --- a/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt +++ b/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt @@ -56,7 +56,7 @@ interface CourseRouter { ) fun navigateToHandoutsWebView( - fm: FragmentManager, courseId: String, title: String, type: HandoutsType + fm: FragmentManager, courseId: String, type: HandoutsType ) fun navigateToDownloadQueue(fm: FragmentManager, descendants: List = arrayListOf()) 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 4df1fcf64..49d6b8cae 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 @@ -140,7 +140,7 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { snackBar?.show() } - lifecycleScope.launch { + viewLifecycleOwner.lifecycleScope.launch { viewModel.showProgress.collect { binding.progressBar.isVisible = it } @@ -522,15 +522,12 @@ fun DashboardPager( } CourseContainerTab.MORE -> { - val announcementsString = stringResource(id = R.string.course_announcements) - val handoutsString = stringResource(id = R.string.course_handouts) HandoutsScreen( windowSize = windowSize, onHandoutsClick = { viewModel.courseRouter.navigateToHandoutsWebView( fragmentManager, bundle.getString(CourseContainerFragment.ARG_COURSE_ID, ""), - handoutsString, HandoutsType.Handouts ) }, @@ -538,7 +535,6 @@ fun DashboardPager( viewModel.courseRouter.navigateToHandoutsWebView( fragmentManager, bundle.getString(CourseContainerFragment.ARG_COURSE_ID, ""), - announcementsString, HandoutsType.Announcements ) }) diff --git a/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsWebViewFragment.kt b/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsWebViewFragment.kt index 7c9d3615e..16cc67b84 100644 --- a/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsWebViewFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsWebViewFragment.kt @@ -22,6 +22,7 @@ import org.openedx.core.ui.WindowType import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors +import org.openedx.course.R import org.openedx.course.presentation.CourseAnalyticsEvent class HandoutsWebViewFragment : Fragment() { @@ -39,6 +40,15 @@ class HandoutsWebViewFragment : Fragment() { savedInstanceState: Bundle?, ) = ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + + val title = if (HandoutsType.valueOf(viewModel.handoutsType) == HandoutsType.Handouts) { + viewModel.logEvent(CourseAnalyticsEvent.HANDOUTS) + getString(R.string.course_handouts) + } else { + viewModel.logEvent(CourseAnalyticsEvent.ANNOUNCEMENTS) + getString(R.string.course_announcements) + } + setContent { OpenEdXTheme { val windowSize = rememberWindowSize() @@ -50,7 +60,7 @@ class HandoutsWebViewFragment : Fragment() { WebContentScreen( windowSize = windowSize, apiHostUrl = viewModel.apiHostUrl, - title = requireArguments().getString(ARG_TITLE, ""), + title = title, htmlBody = viewModel.injectDarkMode( htmlBody, colorBackgroundValue, @@ -61,26 +71,18 @@ class HandoutsWebViewFragment : Fragment() { }) } } - if (HandoutsType.valueOf(viewModel.handoutsType) == HandoutsType.Handouts) { - viewModel.logEvent(CourseAnalyticsEvent.HANDOUTS) - } else { - viewModel.logEvent(CourseAnalyticsEvent.ANNOUNCEMENTS) - } } companion object { - private val ARG_TITLE = "argTitle" private val ARG_TYPE = "argType" private val ARG_COURSE_ID = "argCourse" fun newInstance( - title: String, type: String, courseId: String, ): HandoutsWebViewFragment { val fragment = HandoutsWebViewFragment() fragment.arguments = bundleOf( - ARG_TITLE to title, ARG_TYPE to type, ARG_COURSE_ID to courseId ) diff --git a/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt b/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt index 7a79f3c2e..1fc574f41 100644 --- a/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt +++ b/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt @@ -36,6 +36,7 @@ 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.core.os.bundleOf import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.viewpager2.widget.ViewPager2 @@ -64,15 +65,22 @@ class LearnFragment : Fragment(R.layout.fragment_learn) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + initViewPager() + val openTab = requireArguments().getString(ARG_OPEN_TAB, LearnTab.COURSES.name) + val defaultLearnType = if (openTab == LearnTab.PROGRAMS.name) { + LearnType.PROGRAMS + } else { + LearnType.COURSES + } binding.header.setContent { OpenEdXTheme { Header( fragmentManager = requireParentFragment().parentFragmentManager, + defaultLearnType = defaultLearnType, viewPager = binding.viewPager ) } } - initViewPager() } private fun initViewPager() { @@ -86,11 +94,25 @@ class LearnFragment : Fragment(R.layout.fragment_learn) { binding.viewPager.adapter = adapter binding.viewPager.setUserInputEnabled(false) } + + companion object { + private const val ARG_OPEN_TAB = "open_tab" + fun newInstance( + openTab: String = LearnTab.COURSES.name + ): LearnFragment { + val fragment = LearnFragment() + fragment.arguments = bundleOf( + ARG_OPEN_TAB to openTab + ) + return fragment + } + } } @Composable private fun Header( fragmentManager: FragmentManager, + defaultLearnType: LearnType, viewPager: ViewPager2, ) { val viewModel: LearnViewModel = koinViewModel() @@ -123,6 +145,7 @@ private fun Header( modifier = Modifier .align(Alignment.Start) .padding(horizontal = 16.dp), + defaultLearnType = defaultLearnType, viewPager = viewPager ) } @@ -166,10 +189,11 @@ private fun Title( @Composable private fun LearnDropdownMenu( modifier: Modifier = Modifier, + defaultLearnType: LearnType, viewPager: ViewPager2, ) { var expanded by remember { mutableStateOf(false) } - var currentValue by remember { mutableStateOf(LearnType.COURSES) } + var currentValue by remember { mutableStateOf(defaultLearnType) } val iconRotation by animateFloatAsState( targetValue = if (expanded) 180f else 0f, label = "" @@ -270,6 +294,7 @@ private fun LearnDropdownMenuPreview() { OpenEdXTheme { val context = LocalContext.current LearnDropdownMenu( + defaultLearnType = LearnType.COURSES, viewPager = ViewPager2(context) ) } diff --git a/dashboard/src/main/java/org/openedx/learn/presentation/LearnTab.kt b/dashboard/src/main/java/org/openedx/learn/presentation/LearnTab.kt new file mode 100644 index 000000000..c7498298a --- /dev/null +++ b/dashboard/src/main/java/org/openedx/learn/presentation/LearnTab.kt @@ -0,0 +1,6 @@ +package org.openedx.learn.presentation + +enum class LearnTab { + COURSES, + PROGRAMS +} diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt index 0060199da..4c7eb1da6 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt @@ -164,7 +164,7 @@ class CourseDetailsFragment : Fragment() { requireActivity().supportFragmentManager, currentState.course.courseId, currentState.course.name, - "", + enrollmentMode = "" ) } diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoViewModel.kt b/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoViewModel.kt index 636cb9275..6d41ac4b1 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoViewModel.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoViewModel.kt @@ -122,7 +122,7 @@ class CourseInfoViewModel( fm = fragmentManager, courseId = courseId, courseTitle = "", - enrollmentMode = "", + enrollmentMode = "" ) } } diff --git a/discussion/src/main/java/org/openedx/discussion/data/api/DiscussionApi.kt b/discussion/src/main/java/org/openedx/discussion/data/api/DiscussionApi.kt index ebc911425..75a780d72 100644 --- a/discussion/src/main/java/org/openedx/discussion/data/api/DiscussionApi.kt +++ b/discussion/src/main/java/org/openedx/discussion/data/api/DiscussionApi.kt @@ -5,6 +5,7 @@ import org.openedx.discussion.data.model.request.* import org.openedx.discussion.data.model.response.CommentResult import org.openedx.discussion.data.model.response.CommentsResponse import org.openedx.discussion.data.model.response.ThreadsResponse +import org.openedx.discussion.data.model.response.ThreadsResponse.Thread import org.openedx.discussion.data.model.response.TopicsResponse import retrofit2.http.* @@ -26,6 +27,14 @@ interface DiscussionApi { @Query("requested_fields") requestedFields: List = listOf("profile_image") ): ThreadsResponse + @GET("/api/discussion/v1/threads/{thread_id}") + suspend fun getCourseThread( + @Path("thread_id") threadId: String, + @Query("course_id") courseId: String, + @Query("topic_id") topicId: String, + @Query("requested_fields") requestedFields: List = listOf("profile_image") + ): Thread + @GET("/api/discussion/v1/threads/") suspend fun searchThreads( @Query("course_id") courseId: String, @@ -41,6 +50,12 @@ interface DiscussionApi { @Query("requested_fields") requestedFields: List = listOf("profile_image") ): CommentsResponse + @GET("/api/discussion/v1/comments/{comment_id}") + suspend fun getThreadComment( + @Path("comment_id") commentId: String, + @Query("requested_fields") requestedFields: List = listOf("profile_image") + ): CommentsResponse + @GET("/api/discussion/v1/comments/") suspend fun getThreadQuestionComments( @Query("thread_id") threadId: String, diff --git a/discussion/src/main/java/org/openedx/discussion/data/repository/DiscussionRepository.kt b/discussion/src/main/java/org/openedx/discussion/data/repository/DiscussionRepository.kt index 4ca6cde8d..3ee4f74a5 100644 --- a/discussion/src/main/java/org/openedx/discussion/data/repository/DiscussionRepository.kt +++ b/discussion/src/main/java/org/openedx/discussion/data/repository/DiscussionRepository.kt @@ -58,6 +58,14 @@ class DiscussionRepository( return api.getCourseThreads(courseId, following, topicId, orderBy, view, page).mapToDomain() } + suspend fun getCourseThread( + threadId: String, + courseId: String, + topicId: String + ): org.openedx.discussion.domain.model.Thread { + return api.getCourseThread(threadId, courseId, topicId).mapToDomain() + } + suspend fun searchThread( courseId: String, query: String, @@ -73,6 +81,12 @@ class DiscussionRepository( return api.getThreadComments(threadId, page).mapToDomain() } + suspend fun getThreadComment( + commentId: String + ): CommentsData { + return api.getThreadComment(commentId).mapToDomain() + } + suspend fun getThreadQuestionComments( threadId: String, endorsed: Boolean, @@ -142,4 +156,4 @@ class DiscussionRepository( return api.markBlocksCompletion(blocksCompletionBody) } -} \ No newline at end of file +} diff --git a/discussion/src/main/java/org/openedx/discussion/domain/interactor/DiscussionInteractor.kt b/discussion/src/main/java/org/openedx/discussion/domain/interactor/DiscussionInteractor.kt index 7225cc443..561a75006 100644 --- a/discussion/src/main/java/org/openedx/discussion/domain/interactor/DiscussionInteractor.kt +++ b/discussion/src/main/java/org/openedx/discussion/domain/interactor/DiscussionInteractor.kt @@ -1,6 +1,7 @@ package org.openedx.discussion.domain.interactor import org.openedx.discussion.data.repository.DiscussionRepository +import org.openedx.discussion.domain.model.CommentsData class DiscussionInteractor( private val repository: DiscussionRepository @@ -31,12 +32,18 @@ class DiscussionInteractor( ) = repository.getCourseThreads(courseId, null, topicId, orderBy, view, page) + suspend fun getThread(threadId: String, courseId: String, topicId: String) = + repository.getCourseThread(threadId, courseId, topicId) + suspend fun searchThread(courseId: String, query: String, page: Int) = repository.searchThread(courseId, query, page) suspend fun getThreadComments(threadId: String, page: Int) = repository.getThreadComments(threadId, page) + suspend fun getThreadComment(commentId: String): CommentsData = + repository.getThreadComment(commentId) + suspend fun getThreadQuestionComments(threadId: String, endorsed: Boolean, page: Int) = repository.getThreadQuestionComments(threadId, endorsed, page) @@ -87,5 +94,6 @@ class DiscussionInteractor( follow: Boolean ) = repository.createThread(topicId, courseId, type, title, rawBody, follow) - suspend fun markBlocksCompletion(courseId: String, blocksId: List) = repository.markBlocksCompletion(courseId, blocksId) -} \ No newline at end of file + suspend fun markBlocksCompletion(courseId: String, blocksId: List) = + repository.markBlocksCompletion(courseId, blocksId) +} diff --git a/whatsnew/src/main/java/org/openedx/whatsnew/WhatsNewRouter.kt b/whatsnew/src/main/java/org/openedx/whatsnew/WhatsNewRouter.kt index a8d1cd463..82d02f000 100644 --- a/whatsnew/src/main/java/org/openedx/whatsnew/WhatsNewRouter.kt +++ b/whatsnew/src/main/java/org/openedx/whatsnew/WhatsNewRouter.kt @@ -3,5 +3,10 @@ package org.openedx.whatsnew import androidx.fragment.app.FragmentManager interface WhatsNewRouter { - fun navigateToMain(fm: FragmentManager, courseId: String? = null, infoType: String? = null) + fun navigateToMain( + fm: FragmentManager, + courseId: String?, + infoType: String?, + openTab: String + ) } diff --git a/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewViewModel.kt b/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewViewModel.kt index 51f0f9646..534a54f13 100644 --- a/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewViewModel.kt +++ b/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewViewModel.kt @@ -41,7 +41,8 @@ class WhatsNewViewModel( router.navigateToMain( fm, courseId, - infoType + infoType, + "" ) } From 5c887eb4910614f6a589923ab3a5c4949c773c0b Mon Sep 17 00:00:00 2001 From: Hamza Israr <71447999+HamzaIsrar12@users.noreply.github.com> Date: Tue, 11 Jun 2024 18:10:04 +0500 Subject: [PATCH 17/56] fix: UI Issues on Auth Screens (#332) - feat: Add password visibility toggle to the password fields - fix: Autofill the "Full Name" and "Email" fields with SSO data - refactor: Redesign the modal that appears after SSO Fixes: Issue#331 --- .../presentation/signin/compose/SignInView.kt | 19 +++++-- .../presentation/signup/SignUpViewModel.kt | 11 ++-- .../presentation/signup/compose/SignUpView.kt | 4 +- .../signup/compose/SocialSignedView.kt | 37 +++++++++---- .../openedx/auth/presentation/ui/AuthUI.kt | 54 +++++++++++++++++-- auth/src/main/res/values/strings.xml | 2 + .../org/openedx/core/ui/theme/AppColors.kt | 2 + .../java/org/openedx/core/ui/theme/Theme.kt | 4 ++ .../src/main/res/drawable/ic_core_check.xml | 0 .../org/openedx/core/ui/theme/Colors.kt | 4 ++ .../course/presentation/ui/CourseUI.kt | 2 +- 11 files changed, 116 insertions(+), 23 deletions(-) rename course/src/main/res/drawable/ic_course_check.xml => core/src/main/res/drawable/ic_core_check.xml (100%) diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt index 37309cadf..783a60a99 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt @@ -49,6 +49,7 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview @@ -58,6 +59,7 @@ import org.openedx.auth.R import org.openedx.auth.presentation.signin.AuthEvent import org.openedx.auth.presentation.signin.SignInUIState import org.openedx.auth.presentation.ui.LoginTextField +import org.openedx.auth.presentation.ui.PasswordVisibilityIcon import org.openedx.auth.presentation.ui.SocialAuthView import org.openedx.core.UIMessage import org.openedx.core.extension.TextConverter @@ -305,11 +307,11 @@ private fun PasswordTextField( onPressDone: () -> Unit, ) { var passwordTextFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { - mutableStateOf( - TextFieldValue("") - ) + mutableStateOf(TextFieldValue("")) } + var isPasswordVisible by remember { mutableStateOf(false) } val focusManager = LocalFocusManager.current + Text( modifier = Modifier .testTag("txt_password_label") @@ -318,7 +320,9 @@ private fun PasswordTextField( color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.labelLarge ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( modifier = modifier.testTag("tf_password"), value = passwordTextFieldValue, @@ -341,11 +345,18 @@ private fun PasswordTextField( style = MaterialTheme.appTypography.bodyMedium ) }, + trailingIcon = { + PasswordVisibilityIcon( + isPasswordVisible = isPasswordVisible, + onClick = { isPasswordVisible = !isPasswordVisible } + ) + }, keyboardOptions = KeyboardOptions.Default.copy( keyboardType = KeyboardType.Password, imeAction = ImeAction.Done ), - visualTransformation = PasswordVisualTransformation(), + visualTransformation = if (isPasswordVisible) VisualTransformation.None + else PasswordVisualTransformation(), keyboardActions = KeyboardActions { focusManager.clearFocus() onPressDone() diff --git a/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpViewModel.kt b/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpViewModel.kt index 8fafe40ff..08bbce466 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpViewModel.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpViewModel.kt @@ -226,9 +226,14 @@ class SignUpViewModel( interactor.loginSocial(socialAuth.accessToken, socialAuth.authType) }.onFailure { val fields = uiState.value.allFields.toMutableList() - .filter { field -> field.type != RegistrationFieldType.PASSWORD } - updateField(ApiConstants.NAME, socialAuth.name) - updateField(ApiConstants.EMAIL, socialAuth.email) + .filter { it.type != RegistrationFieldType.PASSWORD } + .map { field -> + when (field.name) { + ApiConstants.NAME -> field.copy(placeholder = socialAuth.name) + ApiConstants.EMAIL -> field.copy(placeholder = socialAuth.email) + else -> field + } + } setErrorInstructions(emptyMap()) _uiState.update { it.copy( diff --git a/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt b/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt index 42fd894df..2872c579b 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt @@ -53,6 +53,7 @@ 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.font.FontWeight import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Devices @@ -317,10 +318,11 @@ internal fun SignUpView( Text( modifier = Modifier .fillMaxWidth() - .padding(top = 4.dp), + .padding(top = 8.dp), text = stringResource( id = R.string.auth_compete_registration ), + fontWeight = FontWeight.Bold, color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.titleSmall ) diff --git a/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SocialSignedView.kt b/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SocialSignedView.kt index 25a9434d1..b2dee1919 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SocialSignedView.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SocialSignedView.kt @@ -3,11 +3,15 @@ package org.openedx.auth.presentation.signup.compose import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Devices @@ -26,21 +30,36 @@ internal fun SocialSignedView(authType: AuthType) { Column( modifier = Modifier .background( - color = MaterialTheme.appColors.secondary, + color = MaterialTheme.appColors.authSSOSuccessBackground, shape = MaterialTheme.appShapes.buttonShape ) .padding(20.dp) ) { - Text( - fontSize = 18.sp, - fontWeight = FontWeight.Bold, - text = stringResource( - id = R.string.auth_social_signed_title, - authType.methodName + Row { + Icon( + modifier = Modifier + .padding(end = 8.dp) + .size(20.dp), + painter = painterResource(id = coreR.drawable.ic_core_check), + tint = MaterialTheme.appColors.successBackground, + contentDescription = "" ) - ) + + Text( + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.primary, + text = stringResource( + id = R.string.auth_social_signed_title, + authType.methodName + ) + ) + } + Text( - modifier = Modifier.padding(top = 8.dp), + modifier = Modifier.padding(top = 8.dp, start = 28.dp), + fontSize = 14.sp, + fontWeight = FontWeight.Normal, text = stringResource( id = R.string.auth_social_signed_desc, stringResource(id = coreR.string.app_name) diff --git a/auth/src/main/java/org/openedx/auth/presentation/ui/AuthUI.kt b/auth/src/main/java/org/openedx/auth/presentation/ui/AuthUI.kt index 90fb91ee1..16d75492b 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/ui/AuthUI.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/ui/AuthUI.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.Icon +import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme import androidx.compose.material.OutlinedTextField import androidx.compose.material.Text @@ -23,6 +24,8 @@ import androidx.compose.material.TextFieldDefaults import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -70,7 +73,10 @@ fun RequiredFields( ) { fields.forEach { field -> when (field.type) { - RegistrationFieldType.TEXT, RegistrationFieldType.EMAIL, RegistrationFieldType.CONFIRM_EMAIL, RegistrationFieldType.PASSWORD -> { + RegistrationFieldType.TEXT, + RegistrationFieldType.EMAIL, + RegistrationFieldType.CONFIRM_EMAIL, + RegistrationFieldType.PASSWORD -> { InputRegistrationField( modifier = Modifier.fillMaxWidth(), isErrorShown = showErrorMap[field.name] ?: true, @@ -289,11 +295,15 @@ fun InputRegistrationField( var inputRegistrationFieldValue by rememberSaveable { mutableStateOf(registrationField.placeholder) } + var isPasswordVisible by remember { mutableStateOf(false) } + val focusManager = LocalFocusManager.current - val visualTransformation = if (registrationField.type == RegistrationFieldType.PASSWORD) { - PasswordVisualTransformation() - } else { - VisualTransformation.None + val visualTransformation = remember(isPasswordVisible) { + if (registrationField.type == RegistrationFieldType.PASSWORD && !isPasswordVisible) { + PasswordVisualTransformation() + } else { + VisualTransformation.None + } } val keyboardType = when (registrationField.type) { RegistrationFieldType.CONFIRM_EMAIL, RegistrationFieldType.EMAIL -> KeyboardType.Email @@ -315,6 +325,18 @@ fun InputRegistrationField( } else { registrationField.instructions } + val trailingIcon: @Composable (() -> Unit)? = + if (registrationField.type == RegistrationFieldType.PASSWORD) { + { + PasswordVisibilityIcon( + isPasswordVisible = isPasswordVisible, + onClick = { isPasswordVisible = !isPasswordVisible } + ) + } + } else { + null + } + Column { Text( modifier = Modifier @@ -359,6 +381,7 @@ fun InputRegistrationField( keyboardActions = KeyboardActions { focusManager.moveFocus(FocusDirection.Down) }, + trailingIcon = trailingIcon, textStyle = MaterialTheme.appTypography.bodyMedium, singleLine = isSingleLine, modifier = modifier.testTag("tf_${registrationField.name.tagId()}") @@ -418,6 +441,7 @@ fun SelectableRegisterField( OutlinedTextField( readOnly = true, enabled = false, + singleLine = true, value = initialValue, colors = TextFieldDefaults.outlinedTextFieldColors( unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, @@ -510,6 +534,26 @@ fun ExpandableText( } } +@Composable +internal fun PasswordVisibilityIcon( + isPasswordVisible: Boolean, + onClick: () -> Unit +) { + val (image, description) = if (isPasswordVisible) { + Icons.Filled.VisibilityOff to stringResource(R.string.auth_accessibility_hide_password) + } else { + Icons.Filled.Visibility to stringResource(R.string.auth_accessibility_show_password) + } + + IconButton(onClick = onClick) { + Icon( + imageVector = image, + contentDescription = description, + tint = MaterialTheme.appColors.onSurface + ) + } +} + @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable diff --git a/auth/src/main/res/values/strings.xml b/auth/src/main/res/values/strings.xml index 49b3e0bbd..642185915 100644 --- a/auth/src/main/res/values/strings.xml +++ b/auth/src/main/res/values/strings.xml @@ -39,4 +39,6 @@ By creating an account, you agree to the %1$s and %2$s and you acknowledge that %3$s and each Member process your personal data in accordance with the %4$s. By signing in to this app, you agree to the %1$s and %2$s and you acknowledge that %3$s and each Member process your personal data in accordance with the %4$s. %2$s]]> + Show password + Hide password diff --git a/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt b/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt index 625c52b27..37783b820 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt @@ -51,6 +51,7 @@ data class AppColors( val inactiveButtonText: Color, val successGreen: Color, + val successBackground: Color, val datesSectionBarPastDue: Color, val datesSectionBarToday: Color, @@ -58,6 +59,7 @@ data class AppColors( val datesSectionBarNextWeek: Color, val datesSectionBarUpcoming: Color, + val authSSOSuccessBackground: Color, val authGoogleButtonBackground: Color, val authFacebookButtonBackground: Color, val authMicrosoftButtonBackground: Color, diff --git a/core/src/main/java/org/openedx/core/ui/theme/Theme.kt b/core/src/main/java/org/openedx/core/ui/theme/Theme.kt index 88c973105..8fe1eb8ff 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/Theme.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/Theme.kt @@ -69,6 +69,7 @@ private val DarkColorPalette = AppColors( inactiveButtonText = dark_primary_button_text, successGreen = dark_success_green, + successBackground = dark_success_background, datesSectionBarPastDue = dark_dates_section_bar_past_due, datesSectionBarToday = dark_dates_section_bar_today, @@ -76,6 +77,7 @@ private val DarkColorPalette = AppColors( datesSectionBarNextWeek = dark_dates_section_bar_next_week, datesSectionBarUpcoming = dark_dates_section_bar_upcoming, + authSSOSuccessBackground = dark_auth_sso_success_background, authGoogleButtonBackground = dark_auth_google_button_background, authFacebookButtonBackground = dark_auth_facebook_button_background, authMicrosoftButtonBackground = dark_auth_microsoft_button_background, @@ -156,6 +158,7 @@ private val LightColorPalette = AppColors( inactiveButtonText = light_primary_button_text, successGreen = light_success_green, + successBackground = light_success_background, datesSectionBarPastDue = light_dates_section_bar_past_due, datesSectionBarToday = light_dates_section_bar_today, @@ -163,6 +166,7 @@ private val LightColorPalette = AppColors( datesSectionBarNextWeek = light_dates_section_bar_next_week, datesSectionBarUpcoming = light_dates_section_bar_upcoming, + authSSOSuccessBackground = light_auth_sso_success_background, authGoogleButtonBackground = light_auth_google_button_background, authFacebookButtonBackground = light_auth_facebook_button_background, authMicrosoftButtonBackground = light_auth_microsoft_button_background, diff --git a/course/src/main/res/drawable/ic_course_check.xml b/core/src/main/res/drawable/ic_core_check.xml similarity index 100% rename from course/src/main/res/drawable/ic_course_check.xml rename to core/src/main/res/drawable/ic_core_check.xml diff --git a/core/src/openedx/org/openedx/core/ui/theme/Colors.kt b/core/src/openedx/org/openedx/core/ui/theme/Colors.kt index 855d557d3..ffc0b64e2 100644 --- a/core/src/openedx/org/openedx/core/ui/theme/Colors.kt +++ b/core/src/openedx/org/openedx/core/ui/theme/Colors.kt @@ -52,11 +52,13 @@ val light_info = Color(0xFF42AAFF) val light_rate_stars = Color(0xFFFFC94D) val light_inactive_button_background = Color(0xFFCCD4E0) val light_success_green = Color(0xFF198571) +val light_success_background = Color(0xFF0D7D4D) val light_dates_section_bar_past_due = light_warning val light_dates_section_bar_today = light_info val light_dates_section_bar_this_week = light_text_primary_variant val light_dates_section_bar_next_week = light_text_field_border val light_dates_section_bar_upcoming = Color(0xFFCCD4E0) +val light_auth_sso_success_background = light_secondary val light_auth_google_button_background = Color.White val light_auth_facebook_button_background = Color(0xFF0866FF) val light_auth_microsoft_button_background = Color(0xFA000000) @@ -124,11 +126,13 @@ val dark_onInfo = Color.White val dark_rate_stars = Color(0xFFFFC94D) val dark_inactive_button_background = Color(0xFFCCD4E0) val dark_success_green = Color(0xFF198571) +val dark_success_background = Color.White val dark_dates_section_bar_past_due = dark_warning val dark_dates_section_bar_today = dark_info val dark_dates_section_bar_this_week = dark_text_primary_variant val dark_dates_section_bar_next_week = dark_text_field_border val dark_dates_section_bar_upcoming = Color(0xFFCCD4E0) +val dark_auth_sso_success_background = dark_secondary val dark_auth_google_button_background = Color(0xFF19212F) val dark_auth_facebook_button_background = Color(0xFF0866FF) val dark_auth_microsoft_button_background = Color(0xFA000000) 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 7d8729fac..c187af0ad 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 @@ -911,7 +911,7 @@ fun SubSectionUnitsList( modifier = Modifier .size(16.dp) .alpha(if (unit.isCompleted()) 1f else 0f), - painter = painterResource(id = R.drawable.ic_course_check), + painter = painterResource(id = coreR.drawable.ic_core_check), contentDescription = "done" ) Text( From efd39981c3135a883fff471b9b7e8104cc501ed1 Mon Sep 17 00:00:00 2001 From: Hamza Israr <71447999+HamzaIsrar12@users.noreply.github.com> Date: Wed, 12 Jun 2024 16:24:04 +0500 Subject: [PATCH 18/56] feat: Add Branch deep links to local calendar events (#249) - Add branch deep links to Calendar Events - Resolved Calendar Dialogs Issue Caused by Permission Launcher Fixes: LEARNER-9795 --- app/build.gradle | 5 ---- core/build.gradle | 5 ++++ .../calendarsync/CalendarSyncUIState.kt | 5 +++- .../openedx/core/system/CalendarManager.kt | 25 +++++++++++-------- .../container/CourseContainerViewModel.kt | 4 +++ 5 files changed, 27 insertions(+), 17 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 86363a2b7..f949e477f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -141,11 +141,6 @@ dependencies { // Firebase Cloud Messaging Integration for Braze implementation 'com.google.firebase:firebase-messaging-ktx:23.4.1' - // Branch SDK Integration - implementation 'io.branch.sdk.android:library:5.9.0' - implementation 'com.google.android.gms:play-services-ads-identifier:18.0.1' - implementation "com.android.installreferrer:installreferrer:2.2" - androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version" diff --git a/core/build.gradle b/core/build.gradle index f5dc7841f..2360efd4d 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -156,6 +156,11 @@ dependencies { api "androidx.webkit:webkit:$webkit_version" + // Branch SDK Integration + api "io.branch.sdk.android:library:5.9.0" + api "com.google.android.gms:play-services-ads-identifier:18.0.1" + api "com.android.installreferrer:installreferrer:2.2" + testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' diff --git a/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncUIState.kt b/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncUIState.kt index e3062d970..1f32f3f56 100644 --- a/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncUIState.kt +++ b/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncUIState.kt @@ -11,4 +11,7 @@ data class CalendarSyncUIState( val isSynced: Boolean = false, val checkForOutOfSync: AtomicReference = AtomicReference(false), val uiMessage: AtomicReference = AtomicReference(""), -) +) { + val isDialogVisible: Boolean + get() = dialogType != CalendarSyncDialogType.NONE +} diff --git a/core/src/main/java/org/openedx/core/system/CalendarManager.kt b/core/src/main/java/org/openedx/core/system/CalendarManager.kt index 53d7a1e1f..e1e6f926d 100644 --- a/core/src/main/java/org/openedx/core/system/CalendarManager.kt +++ b/core/src/main/java/org/openedx/core/system/CalendarManager.kt @@ -10,6 +10,9 @@ import android.database.Cursor import android.net.Uri import android.provider.CalendarContract import androidx.core.content.ContextCompat +import io.branch.indexing.BranchUniversalObject +import io.branch.referral.util.ContentMetadata +import io.branch.referral.util.LinkProperties import org.openedx.core.R import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.CourseDateBlock @@ -94,7 +97,7 @@ class CalendarManager( contentValues.put(CalendarContract.Calendars.VISIBLE, 1) contentValues.put( CalendarContract.Calendars.CALENDAR_COLOR, - ContextCompat.getColor(context, org.openedx.core.R.color.primary) + ContextCompat.getColor(context, R.color.primary) ) val creationUri: Uri? = asSyncAdapter( Uri.parse(CalendarContract.Calendars.CONTENT_URI.toString()), @@ -191,17 +194,16 @@ class CalendarManager( courseDateBlock: CourseDateBlock, isDeeplinkEnabled: Boolean ): String { - val eventDescription = courseDateBlock.title - // The following code for branch and deep links will be enabled after implementation - /* - if (isDeeplinkEnabled && !TextUtils.isEmpty(courseDateBlock.blockId)) { + var eventDescription = courseDateBlock.title + + if (isDeeplinkEnabled && courseDateBlock.blockId.isNotEmpty()) { val metaData = ContentMetadata() - .addCustomMetadata(DeepLink.Keys.SCREEN_NAME, Screen.COURSE_COMPONENT) - .addCustomMetadata(DeepLink.Keys.COURSE_ID, courseId) - .addCustomMetadata(DeepLink.Keys.COMPONENT_ID, courseDateBlock.blockId) + .addCustomMetadata("screen_name", "course_component") + .addCustomMetadata("course_id", courseId) + .addCustomMetadata("component_id", courseDateBlock.blockId) val branchUniversalObject = BranchUniversalObject() - .setCanonicalIdentifier("${Screen.COURSE_COMPONENT}\n${courseDateBlock.blockId}") + .setCanonicalIdentifier("course_component\n${courseDateBlock.blockId}") .setTitle(courseDateBlock.title) .setContentDescription(courseDateBlock.title) .setContentMetadata(metaData) @@ -209,9 +211,10 @@ class CalendarManager( val linkProperties = LinkProperties() .addControlParameter("\$desktop_url", courseDateBlock.link) - eventDescription += "\n" + branchUniversalObject.getShortUrl(context, linkProperties) + val shortUrl = branchUniversalObject.getShortUrl(context, linkProperties) + eventDescription += "\n$shortUrl" } - */ + return eventDescription } 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 4e233e3d7..97045561e 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 @@ -135,6 +135,10 @@ class CourseContainerViewModel( } is CreateCalendarSyncEvent -> { + // Skip out-of-sync check if any calendar dialog is visible + if (event.checkOutOfSync && _calendarSyncUIState.value.isDialogVisible) { + return@collect + } _calendarSyncUIState.update { val dialogType = CalendarSyncDialogType.valueOf(event.dialogType) it.copy( From 065d5830e8b96b8edd1e0a1953ca2a688958cd2d Mon Sep 17 00:00:00 2001 From: Amr Nashawaty Date: Wed, 12 Jun 2024 22:24:36 +0300 Subject: [PATCH 19/56] feat: atlas push pull scripts: FC-55 (#317) --- .gitignore | 3 + Makefile | 12 + README.md | 41 ++++ i18n_scripts/requirements.txt | 2 + i18n_scripts/translation.py | 431 ++++++++++++++++++++++++++++++++++ 5 files changed, 489 insertions(+) create mode 100644 Makefile create mode 100644 i18n_scripts/requirements.txt create mode 100644 i18n_scripts/translation.py diff --git a/.gitignore b/.gitignore index 1cc8ec083..1152644c7 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,6 @@ local.properties /.idea/ *.log /config_settings.yaml +.venv/ +i18n/ +**/values-*/strings.xml diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..e7ff3745b --- /dev/null +++ b/Makefile @@ -0,0 +1,12 @@ +clean_translations_temp_directory: + rm -rf i18n/ + +translation_requirements: + pip3 install -r i18n_scripts/requirements.txt + +pull_translations: clean_translations_temp_directory + atlas pull $(ATLAS_OPTIONS) translations/openedx-app-android/i18n:i18n + python3 i18n_scripts/translation.py --split --replace-underscore + +extract_translations: clean_translations_temp_directory + python3 i18n_scripts/translation.py --combine diff --git a/README.md b/README.md index c8453877a..65a19cce7 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,47 @@ Modern vision of the mobile application for the Open edX platform from Raccoon G 6. Click the **Run** button. +## Translations + +### Getting Translations for the App +Translations aren't included in the source code of this repository as of [OEP-58](https://docs.openedx.org/en/latest/developers/concepts/oep58.html). Therefore, they need to be pulled before testing or publishing to App Store. + +Before retrieving the translations for the app, we need to install the requirements listed in the requirements.txt file located in the i18n_scripts directory. This can be done easily by running the following make command: +```bash +make translation_requirements +``` + +Then, to get the latest translations for all languages use the following command: +```bash +make pull_translations +``` +This command runs [`atlas pull`](https://github.com/openedx/openedx-atlas) to download the latest translations files from the [openedx/openedx-translations](https://github.com/openedx/openedx-translations) repository. These files contain the latest translations for all languages. In the [openedx/openedx-translations](https://github.com/openedx/openedx-translations) repository each language's translations are saved as a single file e.g. `i18n/src/main/res/values-uk/strings.xml` ([example](https://github.com/openedx/openedx-translations/blob/04ccea36b8e6a9889646dfb5a5acb99686fa9ae0/translations/openedx-app-android/i18n/src/main/res/values-uk/strings.xml)). After these are pulled, each language's translation file is split into the App's modules e.g. `auth/src/main/res/values-uk/strings.xml`. + + After this command is run the application can load the translations by changing the device (or the emulator) language in the settings. + +### Using Custom Translations + +By default, the command `make pull_translations` runs [`atlas pull`](https://github.com/openedx/openedx-atlas) with no arguments which pulls translations from the [openedx-translations repository](https://github.com/openedx/openedx-translations). + +You can use custom translations on your fork of the openedx-translations repository by setting the following configuration parameters: + +- `--revision` (default: `"main"`): Branch or git tag to pull translations from. +- `--repository` (default: `"openedx/openedx-translations"`): GitHub repository slug. There's a feature request to [support GitLab and other providers](https://github.com/openedx/openedx-atlas/issues/20). + +Arguments can be passed via the `ATLAS_OPTIONS` environment variable as shown below: +``` bash +make ATLAS_OPTIONS='--repository=/ --revision=' pull_translations +``` +Additional arguments can be passed to `atlas pull`. Refer to the [atlas documentations ](https://github.com/openedx/openedx-atlas) for more information. + +### How to Translate the App + +Translations are managed in the [open-edx/openedx-translations](https://app.transifex.com/open-edx/openedx-translations/dashboard/) Transifex project. + +To translate the app join the [Transifex project](https://app.transifex.com/open-edx/openedx-translations/dashboard/) and add your translations `openedx-app-android` resource: https://app.transifex.com/open-edx/openedx-translations/openedx-app-android/ (the link will start working after the [pull request #317](https://github.com/openedx/openedx-app-android/pull/317) is merged) + +Once the resource is both 100% translated and reviewed the [Transifex integration](https://github.com/apps/transifex-integration) will automatically push it to the [openedx-translations](https://github.com/openedx/openedx-translations) repository and developers can use the translations in their app. + ## API This project targets on the latest Open edX release and rely on the relevant mobile APIs. diff --git a/i18n_scripts/requirements.txt b/i18n_scripts/requirements.txt new file mode 100644 index 000000000..918b88814 --- /dev/null +++ b/i18n_scripts/requirements.txt @@ -0,0 +1,2 @@ +openedx-atlas==0.6.1 +lxml==5.2.2 diff --git a/i18n_scripts/translation.py b/i18n_scripts/translation.py new file mode 100644 index 000000000..a21aa9eb5 --- /dev/null +++ b/i18n_scripts/translation.py @@ -0,0 +1,431 @@ +#!/usr/bin/env python3 +""" +# Translation Management Script + +This script is designed to manage translations for a project by performing two operations: +1) Getting the English translations from all modules. +2) Splitting translations into separate files for each module and language into a single file. + +More detailed specifications are described in the docs/0002-atlas-translations-management.rst design doc. +""" +import argparse +import os +import re +import sys +from lxml import etree + + +def parse_arguments(): + """ + This function is the argument parser for this script. + The script takes only one of the two arguments --split or --combine. + Additionally, the --replace-underscore argument can only be used with --split. + """ + parser = argparse.ArgumentParser(description='Split or Combine translations.') + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument('--split', action='store_true', + help='Split translations into separate files for each module and language.') + group.add_argument('--combine', action='store_true', + help='Combine the English translations from all modules into a single file.') + parser.add_argument('--replace-underscore', action='store_true', + help='Replace underscores with "-r" in language directories (only with --split).') + return parser.parse_args() + + +def append_element_and_comment(element, previous_element, root): + """ + Appends the given element to the root XML element, preserving the previous element's comment if exists. + + Args: + element (etree.Element): The XML element to append. + previous_element (etree.Element or None): The previous XML element before the current one. + root (etree.Element): The root XML element to append the new element to. + + Returns: + None + """ + try: + # If there was a comment before the current element, add it first. + if isinstance(previous_element, etree._Comment): + previous_element.tail = '\n\t' + root.append(previous_element) + + # Indent all elements with one tab. + element.tail = '\n\t' + root.append(element) + + except Exception as e: + print(f"Error appending element and comment: {e}", file=sys.stderr) + raise + + +def get_translation_file_path(modules_dir, module_name, lang_dir, create_dirs=False): + """ + Retrieves the path of the translation file for a specified module and language directory. + + Parameters: + modules_dir (str): The path to the base directory containing all the modules. + module_name (str): The name of the module for which the translation path is being retrieved. + lang_dir (str): The name of the language directory within the module's directory. + create_dirs (bool): If True, creates the parent directories if they do not exist. Defaults to False. + + Returns: + str: The path to the module's translation file (Localizable.strings). + """ + try: + lang_dir_path = os.path.join(modules_dir, module_name, 'src', 'main', 'res', lang_dir, 'strings.xml') + if create_dirs: + os.makedirs(os.path.dirname(lang_dir_path), exist_ok=True) + return lang_dir_path + except Exception as e: + print(f"Error creating directory path: {e}", file=sys.stderr) + raise + + +def write_translation_file(modules_dir, root, module, lang_dir): + """ + Writes the XML root element to a strings.xml file in the specified language directory. + + Args: + modules_dir (str): The root directory of the project. + root (etree.Element): The root XML element to be written. + module (str): The name of the module. + lang_dir (str): The language directory to write the XML file to. + + Returns: + None + """ + try: + translation_file_path = get_translation_file_path(modules_dir, module, lang_dir, create_dirs=True) + tree = etree.ElementTree(root) + tree.write(translation_file_path, encoding='utf-8', xml_declaration=True) + except Exception as e: + print(f"Error writing translations to file.\n Module: {module}\n Error: {e}", file=sys.stderr) + raise + + +def get_modules_to_translate(modules_dir): + """ + Retrieve the names of modules that have translation files for a specified language. + + Parameters: + modules_dir (str): The path to the directory containing all the modules. + + Returns: + list of str: A list of module names that have translation files for the specified language. + """ + try: + modules_list = [ + directory for directory in os.listdir(modules_dir) + if ( + os.path.isdir(os.path.join(modules_dir, directory)) + and os.path.isfile(get_translation_file_path(modules_dir, directory, 'values')) + and directory != 'i18n' + ) + ] + return modules_list + except FileNotFoundError as e: + print(f"Directory not found: {e}", file=sys.stderr) + raise + except PermissionError as e: + print(f"Permission denied: {e}", file=sys.stderr) + raise + + +def process_module_translations(module_root, combined_root, module): + """ + Process translations from a module and append them to the combined translations. + + Parameters: + module_root (etree.Element): The root element of the module's translations. + combined_root (etree.Element): The combined translations root element. + module (str): The name of the module. + + Returns: + etree.Element: The updated combined translations root element. + """ + previous_element = None + for idx, element in enumerate(module_root.getchildren(), start=1): + try: + try: + translatable = element.attrib.get('translatable', True) + except KeyError as e: + print(f"Error processing element #{idx} from module {module}: " + f"Missing key 'translatable' in element attributes: {e}", file=sys.stderr) + raise + except Exception as e: + print(f"Error processing element #{idx} from module {module}: " + f"Unexpected error accessing 'translatable' attribute: {e}", file=sys.stderr) + raise + + if ( + translatable and translatable != 'false' # Check for the translatable property. + and element.tag in ['string', 'string-array', 'plurals'] # Only those types are read by transifex. + and (not element.nsmap + or element.nsmap and not element.attrib.get('{%s}ignore' % element.nsmap["tools"])) + ): + try: + element.attrib['name'] = '.'.join([module, element.attrib.get('name')]) + except KeyError as e: + print(f"Error setting attribute 'name' for element #{idx} from module {module}: Missing key 'name':" + f" {e}", file=sys.stderr) + raise + except Exception as e: + print(f"Error setting attribute 'name' for element #{idx} from module {module}: Unexpected error:" + f" {e}", file=sys.stderr) + raise + + try: + append_element_and_comment(element, previous_element, combined_root) + except Exception as e: + print(f"Error appending element #{idx} and comment from module {module}: {e}", file=sys.stderr) + raise + + # To check for comments in the next round. + previous_element = element + + except Exception as e: + print(f"Error processing element #{idx} from module {module}: {e}", file=sys.stderr) + raise + + return combined_root + + +def combine_translations(modules_dir): + """ + Combine translations from all specified modules into a single XML element. + + Parameters: + modules_dir (str): The directory containing the modules. + + Returns: + etree.Element: An XML element representing the combined translations. + """ + try: + combined_root = etree.Element('resources') + combined_root.text = '\n\t' + + modules = get_modules_to_translate(modules_dir) + for module in modules: + try: + translation_file = get_translation_file_path(modules_dir, module, 'values') + module_translations_tree = etree.parse(translation_file) + module_root = module_translations_tree.getroot() + combined_root = process_module_translations(module_root, combined_root, module) + + # Put a new line after each module translations. + if len(combined_root): + combined_root[-1].tail = '\n\n\t' + + except etree.XMLSyntaxError as e: + print(f"Error parsing XML file {translation_file}: {e}", file=sys.stderr) + raise + except FileNotFoundError as e: + print(f"Translation file not found: {e}", file=sys.stderr) + raise + except Exception as e: + print(f"Error processing module '{module}': {e}", file=sys.stderr) + raise + + # Unindent the resources closing tag. + if len(combined_root): + combined_root[-1].tail = '\n' + return combined_root + + except Exception as e: + print(f"Error combining translations: {e}", file=sys.stderr) + raise + + +def combine_translation_files(modules_dir=None): + """ + Combine translation files from different modules into a single file. + """ + try: + if not modules_dir: + modules_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + combined_root_element = combine_translations(modules_dir) + write_translation_file(modules_dir, combined_root_element, 'i18n', 'values') + except Exception as e: + print(f"Error combining translation files: {e}", file=sys.stderr) + raise + + +def get_languages_dirs(modules_dir): + """ + Retrieve directories containing language files for translation. + + Args: + modules_dir (str): The directory containing all the modules. + + Returns: + list: A list of directories containing language files for translation. Each directory represents + a specific language and starts with the 'values-' extension. + + Example: + Input: + get_languages_dirs('/path/to/modules') + Output: + ['values-ar', 'values-uk', ...] + """ + try: + lang_parent_dir = os.path.join(modules_dir, 'i18n', 'src', 'main', 'res') + languages_dirs = [ + directory for directory in os.listdir(lang_parent_dir) + if ( + directory.startswith('values-') + and 'strings.xml' in os.listdir(os.path.join(lang_parent_dir, directory)) + ) + ] + return languages_dirs + except FileNotFoundError as e: + print(f"Directory not found: {e}", file=sys.stderr) + raise + except PermissionError as e: + print(f"Permission denied: {e}", file=sys.stderr) + raise + + +def separate_translation_to_modules(modules_dir, lang_dir): + """ + Separates translations from a translation file into modules. + + Args: + modules_dir (str): The directory containing all the modules. + lang_dir (str): The directory containing the translation file being split. + + Returns: + dict: A dictionary containing the translations separated by module. + { + 'module_1_name': etree.Element('resources')_1. + 'module_2_name': etree.Element('resources')_2. + ... + } + """ + translations_roots = {} + try: + # Parse the translation file + file_path = get_translation_file_path(modules_dir, 'i18n', lang_dir) + module_translations_tree = etree.parse(file_path) + root = module_translations_tree.getroot() + previous_entry = None + + # Iterate through translation entries, with index starting from 1 for readablity + for i, translation_entry in enumerate(root.getchildren(), start=1): + try: + if not isinstance(translation_entry, etree._Comment): + # Split the key to extract the module name + module_name, key_remainder = translation_entry.attrib['name'].split('.', maxsplit=1) + translation_entry.attrib['name'] = key_remainder + + # Create a dictionary entry for the module if it doesn't exist + if module_name not in translations_roots: + translations_roots[module_name] = etree.Element('resources') + translations_roots[module_name].text = '\n\t' + + # Append the translation entry to the corresponding module + append_element_and_comment(translation_entry, previous_entry, translations_roots[module_name]) + + previous_entry = translation_entry + + except KeyError as e: + print(f"Error processing entry #{i}: Missing key in translation entry: {e}", file=sys.stderr) + raise + except ValueError as e: + print(f"Error processing entry #{i}: Error splitting module name: {e}", file=sys.stderr) + raise + except Exception as e: + print(f"Error processing entry #{i}: {e}", file=sys.stderr) + raise + + return translations_roots + + except FileNotFoundError as e: + print(f"Error: Translation file not found: {e}", file=sys.stderr) + raise + except etree.XMLSyntaxError as e: + print(f"Error: XML syntax error in translation file: {e}", file=sys.stderr) + raise + except Exception as e: + print(f"Error: In \"separate_translation_to_modules\" an unexpected error occurred: {e}", file=sys.stderr) + raise + + +def split_translation_files(modules_dir=None): + """ + Splits translation files into separate files for each module and language. + + Args: + modules_dir (str, optional): The directory containing all the modules. Defaults to None. + + """ + try: + # Set the modules directory if not provided + if not modules_dir: + modules_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + # Get the directories containing language files + languages_dirs = get_languages_dirs(modules_dir) + + # Iterate through each language directory + for lang_dir in languages_dirs: + translations = separate_translation_to_modules(modules_dir, lang_dir) + # Iterate through each module and write its translations to a file + for module, root in translations.items(): + # Unindent the resources closing tag + root[-1].tail = '\n' + # Write the translation file for the module and language + write_translation_file(modules_dir, root, module, lang_dir) + + except Exception as e: + print(f"Error: In \"split_translation_files\" an unexpected error occurred: {e}", file=sys.stderr) + raise + + +def replace_underscores(modules_dir=None): + try: + if not modules_dir: + modules_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + languages_dirs = get_languages_dirs(modules_dir) + + for lang_dir in languages_dirs: + try: + pattern = r'(values-\w\w)_' + if re.search(pattern, lang_dir): + replacement = r'\1-r' + new_name = re.sub(pattern, replacement, lang_dir, 1) + lang_old_path = os.path.dirname(get_translation_file_path(modules_dir, 'i18n', lang_dir)) + lang_new_path = os.path.dirname(get_translation_file_path(modules_dir, 'i18n', new_name)) + + os.rename(lang_old_path, lang_new_path) + print(f"Renamed {lang_old_path} to {lang_new_path}") + + except FileNotFoundError as e: + print(f"Error: The file or directory {lang_old_path} does not exist: {e}", file=sys.stderr) + raise + except PermissionError as e: + print(f"Error: Permission denied while renaming {lang_old_path}: {e}", file=sys.stderr) + raise + except Exception as e: + print(f"Error: An unexpected error occurred while renaming {lang_old_path} to {lang_new_path}: {e}", + file=sys.stderr) + raise + + except Exception as e: + print(f"Error: An unexpected error occurred in rename_translations_files: {e}", file=sys.stderr) + raise + + +def main(): + args = parse_arguments() + if args.split: + if args.replace_underscore: + replace_underscores() + split_translation_files() + elif args.combine: + combine_translation_files() + + +if __name__ == "__main__": + main() From dbd02e3a573274447ecf42a97252c4a34b7b573b Mon Sep 17 00:00:00 2001 From: Omar Al-Ithawi Date: Sun, 16 Jun 2024 01:59:03 +0300 Subject: [PATCH 20/56] fix: English plurals error in transifex should be `one` and `other` (#341) --- discovery/src/main/res/values/strings.xml | 4 ---- discussion/src/main/res/values/strings.xml | 24 ---------------------- 2 files changed, 28 deletions(-) diff --git a/discovery/src/main/res/values/strings.xml b/discovery/src/main/res/values/strings.xml index 5a02b65cf..1d2d9c44b 100644 --- a/discovery/src/main/res/values/strings.xml +++ b/discovery/src/main/res/values/strings.xml @@ -16,11 +16,7 @@ Programs - Found %s courses on your request Found %s course on your request - Found %s courses on your request - Found %s courses on your request - Found %s courses on your request Found %s courses on your request diff --git a/discussion/src/main/res/values/strings.xml b/discussion/src/main/res/values/strings.xml index 2527da01f..a9b11d04d 100644 --- a/discussion/src/main/res/values/strings.xml +++ b/discussion/src/main/res/values/strings.xml @@ -39,56 +39,32 @@ - %1$d votes %1$d vote - %1$d votes - %1$d votes - %1$d votes %1$d votes - %1$d Comments %1$d Comment - %1$d Comments - %1$d Comments - %1$d Comments %1$d Comments - %1$d Missed posts %1$d Missed post - %1$d Missed posts - %1$d Missed posts - %1$d Missed posts %1$d Missed posts - %1$d responses %1$d response - %1$d responses - %1$d responses - %1$d responses %1$d responses - %1$d Responses %1$d Response - %1$d Responses - %1$d Responses - %1$d Responses %1$d Responses - Found %s posts Found %s post - Found %s posts - Found %s posts - Found %s posts Found %s posts From 05c3544660ed3cb6dabea207d0424c762bdd5dd0 Mon Sep 17 00:00:00 2001 From: Volodymyr Chekyrta <127732735+volodymyr-chekyrta@users.noreply.github.com> Date: Wed, 19 Jun 2024 15:45:07 +0300 Subject: [PATCH 21/56] fix: secondary courses view all button behavior (#345) --- .../java/org/openedx/app/di/ScreenModule.kt | 27 +++++++++++++++++-- .../presentation/DashboardGalleryView.kt | 16 +++++++---- .../presentation/DashboardGalleryViewModel.kt | 14 +++++++++- .../data/repository/DashboardRepository.kt | 3 ++- .../domain/interactor/DashboardInteractor.kt | 2 +- 5 files changed, 52 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/org/openedx/app/di/ScreenModule.kt b/app/src/main/java/org/openedx/app/di/ScreenModule.kt index 0c2c85474..11e70d4f8 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -14,6 +14,7 @@ import org.openedx.auth.presentation.signup.SignUpViewModel import org.openedx.core.Validator import org.openedx.core.presentation.dialog.selectorbottomsheet.SelectDialogViewModel import org.openedx.core.presentation.settings.video.VideoQualityViewModel +import org.openedx.core.ui.WindowSize import org.openedx.course.data.repository.CourseRepository import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.container.CourseContainerViewModel @@ -67,7 +68,18 @@ import org.openedx.whatsnew.presentation.whatsnew.WhatsNewViewModel val screenModule = module { - viewModel { AppViewModel(get(), get(), get(), get(), get(named("IODispatcher")), get(), get(), get()) } + viewModel { + AppViewModel( + get(), + get(), + get(), + get(), + get(named("IODispatcher")), + get(), + get(), + get() + ) + } viewModel { MainViewModel(get(), get(), get()) } factory { AuthRepository(get(), get(), get()) } @@ -121,7 +133,18 @@ val screenModule = module { factory { DashboardRepository(get(), get(), get(), get()) } factory { DashboardInteractor(get()) } viewModel { DashboardListViewModel(get(), get(), get(), get(), get(), get(), get()) } - viewModel { DashboardGalleryViewModel(get(), get(), get(), get(), get(), get(), get()) } + viewModel { (windowSize: WindowSize) -> + DashboardGalleryViewModel( + get(), + get(), + get(), + get(), + get(), + get(), + get(), + windowSize + ) + } viewModel { AllEnrolledCoursesViewModel(get(), get(), get(), get(), get(), get(), get()) } viewModel { LearnViewModel(get(), get()) } diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt index 74515e0c1..7401f6304 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt @@ -66,6 +66,7 @@ import androidx.fragment.app.FragmentManager import coil.compose.AsyncImage import coil.request.ImageRequest import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf import org.openedx.Lock import org.openedx.core.UIMessage import org.openedx.core.domain.model.AppConfig @@ -100,7 +101,8 @@ import org.openedx.core.R as CoreR fun DashboardGalleryView( fragmentManager: FragmentManager, ) { - val viewModel: DashboardGalleryViewModel = koinViewModel() + val windowSize = rememberWindowSize() + val viewModel: DashboardGalleryViewModel = koinViewModel { parametersOf(windowSize) } val updating by viewModel.updating.collectAsState(false) val uiMessage by viewModel.uiMessage.collectAsState(null) val uiState by viewModel.uiState.collectAsState(DashboardGalleryUIState.Loading) @@ -293,6 +295,7 @@ private fun UserCourses( if (userCourses.enrollments.courses.isNotEmpty()) { SecondaryCourses( courses = userCourses.enrollments.courses, + hasNextPage = userCourses.enrollments.pagination.next.isNotEmpty(), apiHostUrl = apiHostUrl, onCourseClick = openCourse, onViewAllClick = onViewAllClick @@ -304,6 +307,7 @@ private fun UserCourses( @Composable private fun SecondaryCourses( courses: List, + hasNextPage: Boolean, apiHostUrl: String, onCourseClick: (EnrolledCourse) -> Unit, onViewAllClick: () -> Unit @@ -342,10 +346,12 @@ private fun SecondaryCourses( onCourseClick = onCourseClick ) } - item { - ViewAllItem( - onViewAllClick = onViewAllClick - ) + if (hasNextPage) { + item { + ViewAllItem( + onViewAllClick = onViewAllClick + ) + } } } ) diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt index 6ff7ba3fd..136b914e8 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt @@ -21,6 +21,7 @@ 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.ui.WindowSize import org.openedx.core.utils.FileUtil import org.openedx.dashboard.domain.interactor.DashboardInteractor import org.openedx.dashboard.presentation.DashboardRouter @@ -33,6 +34,7 @@ class DashboardGalleryViewModel( private val networkConnection: NetworkConnection, private val fileUtil: FileUtil, private val dashboardRouter: DashboardRouter, + private val windowSize: WindowSize ) : BaseViewModel() { val apiHostUrl get() = config.getApiHostURL() @@ -62,7 +64,12 @@ class DashboardGalleryViewModel( viewModelScope.launch { try { if (networkConnection.isOnline()) { - val response = interactor.getMainUserCourses() + val pageSize = if (windowSize.isTablet) { + PAGE_SIZE_TABLET + } else { + PAGE_SIZE_PHONE + } + val response = interactor.getMainUserCourses(pageSize) if (response.primary == null && response.enrollments.courses.isEmpty()) { _uiState.value = DashboardGalleryUIState.Empty } else { @@ -127,4 +134,9 @@ class DashboardGalleryViewModel( } } } + + companion object { + private const val PAGE_SIZE_TABLET = 7 + private const val PAGE_SIZE_PHONE = 5 + } } 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 22637f48c..a65ca8d07 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 @@ -35,9 +35,10 @@ class DashboardRepository( return list.map { it.mapToDomain() } } - suspend fun getMainUserCourses(): CourseEnrollments { + suspend fun getMainUserCourses(pageSize: Int): CourseEnrollments { val result = api.getUserCourses( username = preferencesManager.user?.username ?: "", + pageSize = pageSize ) preferencesManager.appConfig = result.configs.mapToDomain() 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 ae2e94d93..ac1870c7b 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 @@ -14,7 +14,7 @@ class DashboardInteractor( suspend fun getEnrolledCoursesFromCache() = repository.getEnrolledCoursesFromCache() - suspend fun getMainUserCourses() = repository.getMainUserCourses() + suspend fun getMainUserCourses(pageSize: Int) = repository.getMainUserCourses(pageSize) suspend fun getAllUserCourses( page: Int = 1, From 7ff0ff4a81e4a9f0c3031ec9d460ce173f1ce37e Mon Sep 17 00:00:00 2001 From: Omar Al-Ithawi Date: Fri, 21 Jun 2024 11:55:11 +0300 Subject: [PATCH 22/56] chore: validate plurals in strings.xml | FC-55 (#348) * chore: validate plurals in strings.xml * fix: more plurals fixes on English --- .../workflows/validate-english-strings.yml | 32 +++++++++++++++++++ Makefile | 13 ++++++++ core/src/main/res/values/strings.xml | 8 ----- course/src/main/res/values/strings.xml | 4 --- dashboard/src/main/res/values/strings.xml | 4 --- 5 files changed, 45 insertions(+), 16 deletions(-) create mode 100644 .github/workflows/validate-english-strings.yml diff --git a/.github/workflows/validate-english-strings.yml b/.github/workflows/validate-english-strings.yml new file mode 100644 index 000000000..43935b05e --- /dev/null +++ b/.github/workflows/validate-english-strings.yml @@ -0,0 +1,32 @@ +name: Validate English strings.xml + +on: + pull_request: { } + push: + branches: [ main, develop ] + +jobs: + translation_strings: + name: Validate strings.xml + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Use Python + uses: actions/setup-python@v5 + with: + python-version: 3.11 + + - name: Install translations requirements + run: make translation_requirements + + - name: Validate English plurals in strings.xml + run: make validate_english_plurals + + - name: Test extract strings + run: | + make extract_translations + # Ensure the file is extracted + test -f i18n/src/main/res/values/strings.xml diff --git a/Makefile b/Makefile index e7ff3745b..a0ba67b45 100644 --- a/Makefile +++ b/Makefile @@ -10,3 +10,16 @@ pull_translations: clean_translations_temp_directory extract_translations: clean_translations_temp_directory python3 i18n_scripts/translation.py --combine + +validate_english_plurals: + @if git grep 'quantity' -- '**/res/values/strings.xml' | grep -E 'quantity=.(zero|two|few|many)'; then \ + echo ""; \ + echo ""; \ + echo "Error: Found invalid plurals in the files listed above."; \ + echo " Please only use 'one' and 'other' in English strings.xml files,"; \ + echo " otherwise Transifex fails to parse them."; \ + echo ""; \ + exit 1; \ + else \ + echo "strings.xml files are valid."; \ + fi diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index 580d262ac..931d2c6da 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -100,19 +100,11 @@ Due Tomorrow Due Yesterday - Due %1$d days ago Due %1$d day ago - Due %1$d days ago - Due %1$d days ago - Due %1$d days ago Due %1$d days ago - Due in %1$d days Due in %1$d day - Due in %1$d days - Due in %1$d days - Due in %1$d days Due in %1$d days diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index 2fcb1d950..51ac39e95 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -65,11 +65,7 @@ %1$s - %2$s - %3$d / %4$d - %1$s of %2$s assignments complete %1$s of %2$s assignment complete - %1$s of %2$s assignments complete - %1$s of %2$s assignments complete - %1$s of %2$s assignments complete %1$s of %2$s assignments complete diff --git a/dashboard/src/main/res/values/strings.xml b/dashboard/src/main/res/values/strings.xml index f83c35a2f..80c05bc42 100644 --- a/dashboard/src/main/res/values/strings.xml +++ b/dashboard/src/main/res/values/strings.xml @@ -21,11 +21,7 @@ No %1$s Courses - %1$d Past Due Assignments %1$d Past Due Assignment - %1$d Past Due Assignments - %1$d Past Due Assignments - %1$d Past Due Assignments %1$d Past Due Assignments From 948277af5a7621ffe795b6171684d8eaa078cc46 Mon Sep 17 00:00:00 2001 From: Volodymyr Chekyrta <127732735+volodymyr-chekyrta@users.noreply.github.com> Date: Tue, 25 Jun 2024 12:32:38 +0200 Subject: [PATCH 23/56] feat: [FC-0047] FCM (#344) * feat: fcm * fix: address feedback --- app/build.gradle | 6 +- app/src/main/AndroidManifest.xml | 4 +- .../main/java/org/openedx/app/AppActivity.kt | 16 + .../main/java/org/openedx/app/AppViewModel.kt | 62 ++- .../openedx/app/data/api/NotificationsApi.kt | 14 + .../data/networking/AppUpgradeInterceptor.kt | 12 +- .../OauthRefreshTokenAuthenticator.kt | 8 +- .../app/data/storage/PreferencesManager.kt | 8 + .../java/org/openedx/app/deeplink/DeepLink.kt | 41 +- .../openedx/app/deeplink/DeepLinkRouter.kt | 406 +++++++++++------- .../java/org/openedx/app/deeplink/Screen.kt | 20 - .../main/java/org/openedx/app/di/AppModule.kt | 6 +- .../org/openedx/app/di/NetworkingModule.kt | 2 + .../java/org/openedx/app/di/ScreenModule.kt | 1 + .../openedx/app/system/notifier/AppEvent.kt | 3 - .../app/system/notifier/LogoutEvent.kt | 3 - .../push/OpenEdXFirebaseMessagingService.kt | 95 ++++ .../system/push/RefreshFirebaseTokenWorker.kt | 46 ++ .../system/push/SyncFirebaseTokenWorker.kt | 47 ++ .../test/java/org/openedx/AppViewModelTest.kt | 87 ++-- .../restore/RestorePasswordViewModel.kt | 12 +- .../presentation/signin/SignInViewModel.kt | 15 +- .../auth/presentation/signup/SignUpUIState.kt | 2 +- .../presentation/signup/SignUpViewModel.kt | 14 +- .../restore/RestorePasswordViewModelTest.kt | 34 +- .../signin/SignInViewModelTest.kt | 33 +- .../signup/SignUpViewModelTest.kt | 34 +- build.gradle | 6 +- .../java/org/openedx/core/AppUpdateState.kt | 2 +- .../core/data/storage/CorePreferences.kt | 1 + .../system/notifier/AppUpgradeNotifier.kt | 15 - .../core/system/notifier/app/AppEvent.kt | 3 + .../core/system/notifier/app}/AppNotifier.kt | 8 +- .../notifier/{ => app}/AppUpgradeEvent.kt | 4 +- .../core/system/notifier/app/LogoutEvent.kt | 3 + .../core/system/notifier/app/SignInEvent.kt | 3 + .../container/CourseContainerFragment.kt | 14 + .../presentation/DashboardGalleryViewModel.kt | 7 + .../presentation/DashboardListFragment.kt | 2 +- .../presentation/DashboardListViewModel.kt | 15 +- .../presentation/DashboardViewModelTest.kt | 46 +- .../presentation/NativeDiscoveryFragment.kt | 2 +- .../presentation/NativeDiscoveryViewModel.kt | 9 +- .../NativeDiscoveryViewModelTest.kt | 26 +- .../discussion/data/api/DiscussionApi.kt | 11 +- .../data/model/response/CommentsResponse.kt | 6 +- .../data/repository/DiscussionRepository.kt | 9 +- .../domain/interactor/DiscussionInteractor.kt | 5 +- .../presentation/settings/SettingsScreenUI.kt | 2 +- .../settings/SettingsViewModel.kt | 14 +- 50 files changed, 839 insertions(+), 405 deletions(-) create mode 100644 app/src/main/java/org/openedx/app/data/api/NotificationsApi.kt delete mode 100644 app/src/main/java/org/openedx/app/deeplink/Screen.kt delete mode 100644 app/src/main/java/org/openedx/app/system/notifier/AppEvent.kt delete mode 100644 app/src/main/java/org/openedx/app/system/notifier/LogoutEvent.kt create mode 100644 app/src/main/java/org/openedx/app/system/push/OpenEdXFirebaseMessagingService.kt create mode 100644 app/src/main/java/org/openedx/app/system/push/RefreshFirebaseTokenWorker.kt create mode 100644 app/src/main/java/org/openedx/app/system/push/SyncFirebaseTokenWorker.kt delete mode 100644 core/src/main/java/org/openedx/core/system/notifier/AppUpgradeNotifier.kt create mode 100644 core/src/main/java/org/openedx/core/system/notifier/app/AppEvent.kt rename {app/src/main/java/org/openedx/app/system/notifier => core/src/main/java/org/openedx/core/system/notifier/app}/AppNotifier.kt (67%) rename core/src/main/java/org/openedx/core/system/notifier/{ => app}/AppUpgradeEvent.kt (61%) create mode 100644 core/src/main/java/org/openedx/core/system/notifier/app/LogoutEvent.kt create mode 100644 core/src/main/java/org/openedx/core/system/notifier/app/SignInEvent.kt diff --git a/app/build.gradle b/app/build.gradle index f949e477f..e0992b266 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -130,6 +130,9 @@ dependencies { implementation 'androidx.core:core-splashscreen:1.0.1' + api platform("com.google.firebase:firebase-bom:$firebase_version") + api "com.google.firebase:firebase-messaging" + // Segment Library implementation "com.segment.analytics.kotlin:android:1.14.2" // Segment's Firebase integration @@ -138,9 +141,6 @@ dependencies { implementation "com.braze:braze-segment-kotlin:1.4.2" implementation "com.braze:android-sdk-ui:30.2.0" - // Firebase Cloud Messaging Integration for Braze - implementation 'com.google.firebase:firebase-messaging-ktx:23.4.1' - androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index efc65add4..a3921ac64 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -105,9 +105,9 @@ android:foregroundServiceType="dataSync" tools:node="merge" /> - + diff --git a/app/src/main/java/org/openedx/app/AppActivity.kt b/app/src/main/java/org/openedx/app/AppActivity.kt index 9781b0ca7..c8d4c9259 100644 --- a/app/src/main/java/org/openedx/app/AppActivity.kt +++ b/app/src/main/java/org/openedx/app/AppActivity.kt @@ -13,6 +13,7 @@ import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import androidx.fragment.app.Fragment import androidx.window.layout.WindowMetricsCalculator +import com.braze.support.toStringMap import io.branch.referral.Branch import io.branch.referral.Branch.BranchUniversalReferralInitListener import org.koin.android.ext.android.inject @@ -135,6 +136,11 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { addFragment(MainFragment.newInstance()) } } + + val extras = intent.extras + if (extras?.containsKey(DeepLink.Keys.NOTIFICATION_TYPE.value) == true) { + handlePushNotification(extras) + } } viewModel.logoutUser.observe(this) { @@ -170,6 +176,11 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { super.onNewIntent(intent) this.intent = intent + val extras = intent?.extras + if (extras?.containsKey(DeepLink.Keys.NOTIFICATION_TYPE.value) == true) { + handlePushNotification(extras) + } + if (viewModel.isBranchEnabled) { if (intent?.getBooleanExtra(BRANCH_FORCE_NEW_SESSION, false) == true) { Branch.sessionBuilder(this).withCallback { referringParams, error -> @@ -218,6 +229,11 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { } } + private fun handlePushNotification(data: Bundle) { + val deepLink = DeepLink(data.toStringMap()) + viewModel.makeExternalRoute(supportFragmentManager, deepLink) + } + companion object { const val TOP_INSET = "topInset" const val BOTTOM_INSET = "bottomInset" diff --git a/app/src/main/java/org/openedx/app/AppViewModel.kt b/app/src/main/java/org/openedx/app/AppViewModel.kt index 3fc49859f..20b3b0c97 100644 --- a/app/src/main/java/org/openedx/app/AppViewModel.kt +++ b/app/src/main/java/org/openedx/app/AppViewModel.kt @@ -1,5 +1,8 @@ package org.openedx.app +import android.annotation.SuppressLint +import android.app.NotificationManager +import android.content.Context import androidx.fragment.app.FragmentManager import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData @@ -10,14 +13,20 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.openedx.app.deeplink.DeepLink import org.openedx.app.deeplink.DeepLinkRouter -import org.openedx.app.system.notifier.AppNotifier -import org.openedx.app.system.notifier.LogoutEvent +import org.openedx.app.system.push.RefreshFirebaseTokenWorker +import org.openedx.app.system.push.SyncFirebaseTokenWorker import org.openedx.core.BaseViewModel import org.openedx.core.SingleEventLiveData import org.openedx.core.config.Config +import org.openedx.core.data.model.User import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.system.notifier.app.AppNotifier +import org.openedx.core.system.notifier.app.LogoutEvent +import org.openedx.core.system.notifier.app.SignInEvent import org.openedx.core.utils.FileUtil + +@SuppressLint("StaticFieldLeak") class AppViewModel( private val config: Config, private val notifier: AppNotifier, @@ -27,6 +36,7 @@ class AppViewModel( private val analytics: AppAnalytics, private val deepLinkRouter: DeepLinkRouter, private val fileUtil: FileUtil, + private val context: Context ) : BaseViewModel() { private val _logoutUser = SingleEventLiveData() @@ -42,20 +52,25 @@ class AppViewModel( override fun onCreate(owner: LifecycleOwner) { super.onCreate(owner) - setUserId() + + val user = preferencesManager.user + + setUserId(user) + + if (user != null && preferencesManager.pushToken.isNotEmpty()) { + SyncFirebaseTokenWorker.schedule(context) + } + if (canResetAppDirectory) { resetAppDirectory() } + viewModelScope.launch { notifier.notifier.collect { event -> - if (event is LogoutEvent && System.currentTimeMillis() - logoutHandledAt > 5000) { - logoutHandledAt = System.currentTimeMillis() - preferencesManager.clear() - withContext(dispatcher) { - room.clearAllTables() - } - analytics.logoutEvent(true) - _logoutUser.value = Unit + if (event is SignInEvent && config.getFirebaseConfig().isCloudMessagingEnabled) { + SyncFirebaseTokenWorker.schedule(context) + } else if (event is LogoutEvent) { + handleLogoutEvent(event) } } } @@ -79,9 +94,30 @@ class AppViewModel( deepLinkRouter.makeRoute(fm, deepLink) } - private fun setUserId() { - preferencesManager.user?.let { + private fun setUserId(user: User?) { + user?.let { analytics.setUserIdForSession(it.id) } } + + private suspend fun handleLogoutEvent(event: LogoutEvent) { + if (System.currentTimeMillis() - logoutHandledAt > 5000) { + if (event.isForced) { + logoutHandledAt = System.currentTimeMillis() + preferencesManager.clear() + withContext(dispatcher) { + room.clearAllTables() + } + analytics.logoutEvent(true) + _logoutUser.value = Unit + } + + if (config.getFirebaseConfig().isCloudMessagingEnabled) { + RefreshFirebaseTokenWorker.schedule(context) + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.cancelAll() + } + } + } } diff --git a/app/src/main/java/org/openedx/app/data/api/NotificationsApi.kt b/app/src/main/java/org/openedx/app/data/api/NotificationsApi.kt new file mode 100644 index 000000000..9106944c3 --- /dev/null +++ b/app/src/main/java/org/openedx/app/data/api/NotificationsApi.kt @@ -0,0 +1,14 @@ +package org.openedx.app.data.api + +import retrofit2.http.Field +import retrofit2.http.FormUrlEncoded +import retrofit2.http.POST + +interface NotificationsApi { + @POST("/api/mobile/v4/notifications/create-token/") + @FormUrlEncoded + suspend fun syncFirebaseToken( + @Field("registration_id") token: String, + @Field("active") active: Boolean = true + ) +} diff --git a/app/src/main/java/org/openedx/app/data/networking/AppUpgradeInterceptor.kt b/app/src/main/java/org/openedx/app/data/networking/AppUpgradeInterceptor.kt index 4e88eec42..abf90d7a2 100644 --- a/app/src/main/java/org/openedx/app/data/networking/AppUpgradeInterceptor.kt +++ b/app/src/main/java/org/openedx/app/data/networking/AppUpgradeInterceptor.kt @@ -4,13 +4,13 @@ import kotlinx.coroutines.runBlocking import okhttp3.Interceptor import okhttp3.Response import org.openedx.app.BuildConfig -import org.openedx.core.system.notifier.AppUpgradeEvent -import org.openedx.core.system.notifier.AppUpgradeNotifier +import org.openedx.core.system.notifier.app.AppNotifier +import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.core.utils.TimeUtils import java.util.Date class AppUpgradeInterceptor( - private val appUpgradeNotifier: AppUpgradeNotifier + private val appNotifier: AppNotifier ) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val response = chain.proceed(chain.request()) @@ -21,15 +21,15 @@ class AppUpgradeInterceptor( runBlocking { when { responseCode == 426 -> { - appUpgradeNotifier.send(AppUpgradeEvent.UpgradeRequiredEvent) + appNotifier.send(AppUpgradeEvent.UpgradeRequiredEvent) } BuildConfig.VERSION_NAME != latestAppVersion && lastSupportedDateTime > Date().time -> { - appUpgradeNotifier.send(AppUpgradeEvent.UpgradeRecommendedEvent(latestAppVersion)) + appNotifier.send(AppUpgradeEvent.UpgradeRecommendedEvent(latestAppVersion)) } latestAppVersion.isNotEmpty() && BuildConfig.VERSION_NAME != latestAppVersion && lastSupportedDateTime < Date().time -> { - appUpgradeNotifier.send(AppUpgradeEvent.UpgradeRequiredEvent) + appNotifier.send(AppUpgradeEvent.UpgradeRequiredEvent) } } } diff --git a/app/src/main/java/org/openedx/app/data/networking/OauthRefreshTokenAuthenticator.kt b/app/src/main/java/org/openedx/app/data/networking/OauthRefreshTokenAuthenticator.kt index 3cc6b82ae..38305c007 100644 --- a/app/src/main/java/org/openedx/app/data/networking/OauthRefreshTokenAuthenticator.kt +++ b/app/src/main/java/org/openedx/app/data/networking/OauthRefreshTokenAuthenticator.kt @@ -9,8 +9,7 @@ import okhttp3.ResponseBody.Companion.toResponseBody import okhttp3.logging.HttpLoggingInterceptor import org.json.JSONException import org.json.JSONObject -import org.openedx.app.system.notifier.AppNotifier -import org.openedx.app.system.notifier.LogoutEvent +import org.openedx.core.system.notifier.app.LogoutEvent import org.openedx.auth.data.api.AuthApi import org.openedx.auth.domain.model.AuthResponse import org.openedx.core.ApiConstants @@ -18,6 +17,7 @@ import org.openedx.core.ApiConstants.TOKEN_TYPE_JWT import org.openedx.core.BuildConfig import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.core.utils.TimeUtils import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory @@ -119,7 +119,7 @@ class OauthRefreshTokenAuthenticator( } runBlocking { - appNotifier.send(LogoutEvent()) + appNotifier.send(LogoutEvent(true)) } } @@ -128,7 +128,7 @@ class OauthRefreshTokenAuthenticator( JWT_USER_EMAIL_MISMATCH, -> { runBlocking { - appNotifier.send(LogoutEvent()) + appNotifier.send(LogoutEvent(true)) } } } diff --git a/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt b/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt index e0b65af14..473340beb 100644 --- a/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt +++ b/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt @@ -54,6 +54,7 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences remove(ACCESS_TOKEN) remove(REFRESH_TOKEN) remove(USER) + remove(ACCOUNT) remove(EXPIRES_IN) }.apply() } @@ -70,6 +71,12 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences } get() = getString(REFRESH_TOKEN) + override var pushToken: String + set(value) { + saveString(PUSH_TOKEN, value) + } + get() = getString(PUSH_TOKEN) + override var accessTokenExpiresAt: Long set(value) { saveLong(EXPIRES_IN, value) @@ -168,6 +175,7 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences companion object { private const val ACCESS_TOKEN = "access_token" private const val REFRESH_TOKEN = "refresh_token" + private const val PUSH_TOKEN = "push_token" private const val EXPIRES_IN = "expires_in" private const val USER = "user" private const val ACCOUNT = "account" diff --git a/app/src/main/java/org/openedx/app/deeplink/DeepLink.kt b/app/src/main/java/org/openedx/app/deeplink/DeepLink.kt index 2b65a92b1..ac494df06 100644 --- a/app/src/main/java/org/openedx/app/deeplink/DeepLink.kt +++ b/app/src/main/java/org/openedx/app/deeplink/DeepLink.kt @@ -2,21 +2,58 @@ package org.openedx.app.deeplink class DeepLink(params: Map) { - val screenName = params[Keys.SCREEN_NAME.value] + private val screenName = params[Keys.SCREEN_NAME.value] + private val notificationType = params[Keys.NOTIFICATION_TYPE.value] val courseId = params[Keys.COURSE_ID.value] val pathId = params[Keys.PATH_ID.value] val componentId = params[Keys.COMPONENT_ID.value] val topicId = params[Keys.TOPIC_ID.value] val threadId = params[Keys.THREAD_ID.value] val commentId = params[Keys.COMMENT_ID.value] + val parentId = params[Keys.PARENT_ID.value] + val type = DeepLinkType.typeOf(screenName ?: notificationType ?: "") enum class Keys(val value: String) { SCREEN_NAME("screen_name"), + NOTIFICATION_TYPE("notification_type"), COURSE_ID("course_id"), PATH_ID("path_id"), COMPONENT_ID("component_id"), TOPIC_ID("topic_id"), THREAD_ID("thread_id"), - COMMENT_ID("comment_id") + COMMENT_ID("comment_id"), + PARENT_ID("parent_id"), + } +} + +enum class DeepLinkType(val type: String) { + DISCOVERY("discovery"), + DISCOVERY_COURSE_DETAIL("discovery_course_detail"), + DISCOVERY_PROGRAM_DETAIL("discovery_program_detail"), + COURSE_DASHBOARD("course_dashboard"), + COURSE_VIDEOS("course_videos"), + COURSE_DISCUSSION("course_discussion"), + COURSE_DATES("course_dates"), + COURSE_HANDOUT("course_handout"), + COURSE_ANNOUNCEMENT("course_announcement"), + COURSE_COMPONENT("course_component"), + PROGRAM("program"), + DISCUSSION_TOPIC("discussion_topic"), + DISCUSSION_POST("discussion_post"), + DISCUSSION_COMMENT("discussion_comment"), + PROFILE("profile"), + USER_PROFILE("user_profile"), + ENROLL("enroll"), + UNENROLL("unenroll"), + ADD_BETA_TESTER("add_beta_tester"), + REMOVE_BETA_TESTER("remove_beta_tester"), + FORUM_RESPONSE("forum_response"), + FORUM_COMMENT("forum_comment"), + NONE(""); + + companion object { + fun typeOf(type: String): DeepLinkType { + return entries.firstOrNull { it.type == type } ?: NONE + } } } diff --git a/app/src/main/java/org/openedx/app/deeplink/DeepLinkRouter.kt b/app/src/main/java/org/openedx/app/deeplink/DeepLinkRouter.kt index 02bc5cd0e..31564edf7 100644 --- a/app/src/main/java/org/openedx/app/deeplink/DeepLinkRouter.kt +++ b/app/src/main/java/org/openedx/app/deeplink/DeepLinkRouter.kt @@ -14,6 +14,8 @@ import org.openedx.core.data.storage.CorePreferences import org.openedx.core.presentation.course.CourseViewMode import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.handouts.HandoutsType +import org.openedx.discovery.domain.interactor.DiscoveryInteractor +import org.openedx.discovery.domain.model.Course import org.openedx.discovery.presentation.catalog.WebViewLink import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.presentation.topics.DiscussionTopicsViewModel @@ -23,6 +25,7 @@ class DeepLinkRouter( private val config: Config, private val appRouter: AppRouter, private val corePreferences: CorePreferences, + private val discoveryInteractor: DiscoveryInteractor, private val courseInteractor: CourseInteractor, private val discussionInteractor: DiscussionInteractor ) : CoroutineScope { @@ -34,15 +37,14 @@ class DeepLinkRouter( get() = corePreferences.user != null fun makeRoute(fm: FragmentManager, deepLink: DeepLink) { - val screenName = deepLink.screenName ?: return - when (screenName) { + when (deepLink.type) { // Discovery - Screen.DISCOVERY.screenName -> { + DeepLinkType.DISCOVERY -> { navigateToDiscoveryScreen(fm = fm) return } - Screen.DISCOVERY_COURSE_DETAIL.screenName -> { + DeepLinkType.DISCOVERY_COURSE_DETAIL -> { navigateToCourseDetail( fm = fm, deepLink = deepLink @@ -50,13 +52,17 @@ class DeepLinkRouter( return } - Screen.DISCOVERY_PROGRAM_DETAIL.screenName -> { + DeepLinkType.DISCOVERY_PROGRAM_DETAIL -> { navigateToProgramDetail( fm = fm, deepLink = deepLink ) return } + + else -> { + //ignore + } } if (!isUserLoggedIn) { @@ -64,125 +70,162 @@ class DeepLinkRouter( return } - when (screenName) { - // Course - Screen.COURSE_DASHBOARD.screenName -> { - navigateToDashboard(fm = fm) - navigateToCourseDashboard( + when (deepLink.type) { + // Program + DeepLinkType.PROGRAM -> { + navigateToProgram( fm = fm, deepLink = deepLink ) + return } - - Screen.COURSE_VIDEOS.screenName -> { - navigateToDashboard(fm = fm) - navigateToCourseVideos( - fm = fm, - deepLink = deepLink - ) + // Profile + DeepLinkType.PROFILE, + DeepLinkType.USER_PROFILE -> { + navigateToProfile(fm = fm) + return } - - Screen.COURSE_DATES.screenName -> { - navigateToDashboard(fm = fm) - navigateToCourseDates( - fm = fm, - deepLink = deepLink - ) + else -> { + //ignore } + } - Screen.COURSE_DISCUSSION.screenName -> { + launch(Dispatchers.Main) { + val courseId = deepLink.courseId ?: return@launch navigateToDashboard(fm = fm) + val course = getCourseDetails(courseId) ?: return@launch navigateToDashboard(fm = fm) + if (!course.isEnrolled) { navigateToDashboard(fm = fm) - navigateToCourseDiscussion( - fm = fm, - deepLink = deepLink - ) + return@launch } - Screen.COURSE_HANDOUT.screenName -> { - navigateToDashboard(fm = fm) - navigateToCourseMore( - fm = fm, - deepLink = deepLink - ) - navigateToCourseHandout( - fm = fm, - deepLink = deepLink - ) - } + when (deepLink.type) { + // Course + DeepLinkType.COURSE_DASHBOARD, DeepLinkType.ENROLL, DeepLinkType.ADD_BETA_TESTER -> { + navigateToDashboard(fm = fm) + navigateToCourseDashboard( + fm = fm, + deepLink = deepLink, + courseTitle = course.name + ) + } - Screen.COURSE_ANNOUNCEMENT.screenName -> { - navigateToDashboard(fm = fm) - navigateToCourseMore( - fm = fm, - deepLink = deepLink - ) - navigateToCourseAnnouncement( - fm = fm, - deepLink = deepLink - ) - } + DeepLinkType.UNENROLL, DeepLinkType.REMOVE_BETA_TESTER -> { + navigateToDashboard(fm = fm) + } - Screen.COURSE_COMPONENT.screenName -> { - navigateToDashboard(fm = fm) - navigateToCourseDashboard( - fm = fm, - deepLink = deepLink - ) - navigateToCourseComponent( - fm = fm, - deepLink = deepLink - ) - } + DeepLinkType.COURSE_VIDEOS -> { + navigateToDashboard(fm = fm) + navigateToCourseVideos( + fm = fm, + deepLink = deepLink + ) + } - // Program - Screen.PROGRAM.screenName -> { - navigateToProgram( - fm = fm, - deepLink = deepLink - ) - } + DeepLinkType.COURSE_DATES -> { + navigateToDashboard(fm = fm) + navigateToCourseDates( + fm = fm, + deepLink = deepLink + ) + } - // Discussions - Screen.DISCUSSION_TOPIC.screenName -> { - navigateToDashboard(fm = fm) - navigateToCourseDiscussion( - fm = fm, - deepLink = deepLink - ) - navigateToDiscussionTopic( - fm = fm, - deepLink = deepLink - ) - } + DeepLinkType.COURSE_DISCUSSION -> { + navigateToDashboard(fm = fm) + navigateToCourseDiscussion( + fm = fm, + deepLink = deepLink + ) + } - Screen.DISCUSSION_POST.screenName -> { - navigateToDashboard(fm = fm) - navigateToCourseDiscussion( - fm = fm, - deepLink = deepLink - ) - navigateToDiscussionPost( - fm = fm, - deepLink = deepLink - ) - } + DeepLinkType.COURSE_HANDOUT -> { + navigateToDashboard(fm = fm) + navigateToCourseMore( + fm = fm, + deepLink = deepLink + ) + navigateToCourseHandout( + fm = fm, + deepLink = deepLink + ) + } - Screen.DISCUSSION_COMMENT.screenName -> { - navigateToDashboard(fm = fm) - navigateToCourseDiscussion( - fm = fm, - deepLink = deepLink - ) - navigateToDiscussionComment( - fm = fm, - deepLink = deepLink - ) - } + DeepLinkType.COURSE_ANNOUNCEMENT -> { + navigateToDashboard(fm = fm) + navigateToCourseMore( + fm = fm, + deepLink = deepLink + ) + navigateToCourseAnnouncement( + fm = fm, + deepLink = deepLink + ) + } - // Profile - Screen.PROFILE.screenName, - Screen.USER_PROFILE.screenName -> { - navigateToProfile(fm = fm) + DeepLinkType.COURSE_COMPONENT -> { + navigateToDashboard(fm = fm) + navigateToCourseDashboard( + fm = fm, + deepLink = deepLink, + courseTitle = course.name + ) + navigateToCourseComponent( + fm = fm, + deepLink = deepLink + ) + } + + // Discussions + DeepLinkType.DISCUSSION_TOPIC -> { + navigateToDashboard(fm = fm) + navigateToCourseDiscussion( + fm = fm, + deepLink = deepLink + ) + navigateToDiscussionTopic( + fm = fm, + deepLink = deepLink + ) + } + + DeepLinkType.DISCUSSION_POST -> { + navigateToDashboard(fm = fm) + navigateToCourseDiscussion( + fm = fm, + deepLink = deepLink + ) + navigateToDiscussionPost( + fm = fm, + deepLink = deepLink + ) + } + + DeepLinkType.DISCUSSION_COMMENT, DeepLinkType.FORUM_RESPONSE -> { + navigateToDashboard(fm = fm) + navigateToCourseDiscussion( + fm = fm, + deepLink = deepLink + ) + navigateToDiscussionResponse( + fm = fm, + deepLink = deepLink + ) + } + + DeepLinkType.FORUM_COMMENT -> { + navigateToDashboard(fm = fm) + navigateToCourseDiscussion( + fm = fm, + deepLink = deepLink + ) + navigateToDiscussionComment( + fm = fm, + deepLink = deepLink + ) + } + + else -> { + //ignore + } } } } @@ -247,12 +290,16 @@ class DeepLinkRouter( } } - private fun navigateToCourseDashboard(fm: FragmentManager, deepLink: DeepLink) { + private fun navigateToCourseDashboard( + fm: FragmentManager, + deepLink: DeepLink, + courseTitle: String + ) { deepLink.courseId?.let { courseId -> appRouter.navigateToCourseOutline( fm = fm, courseId = courseId, - courseTitle = "", + courseTitle = courseTitle, enrollmentMode = "" ) } @@ -427,53 +474,99 @@ class DeepLinkRouter( } } + private fun navigateToDiscussionResponse(fm: FragmentManager, deepLink: DeepLink) { + val courseId = deepLink.courseId + val topicId = deepLink.topicId + val threadId = deepLink.threadId + val commentId = deepLink.commentId + if (courseId == null || topicId == null || threadId == null || commentId == null) { + return + } + launch { + try { + discussionInteractor.getCourseTopics(courseId) + .find { it.id == topicId }?.let { topic -> + launch(Dispatchers.Main) { + appRouter.navigateToDiscussionThread( + fm = fm, + action = DiscussionTopicsViewModel.TOPIC, + courseId = courseId, + topicId = topicId, + title = topic.name, + viewType = FragmentViewType.FULL_CONTENT + ) + } + } + val thread = discussionInteractor.getThread( + threadId, + courseId, + topicId + ) + launch(Dispatchers.Main) { + appRouter.navigateToDiscussionComments( + fm = fm, + thread = thread + ) + } + val response = discussionInteractor.getResponse(commentId) + launch(Dispatchers.Main) { + appRouter.navigateToDiscussionResponses( + fm = fm, + comment = response, + isClosed = false + ) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + private fun navigateToDiscussionComment(fm: FragmentManager, deepLink: DeepLink) { - deepLink.courseId?.let { courseId -> - deepLink.topicId?.let { topicId -> - deepLink.threadId?.let { threadId -> - deepLink.commentId?.let { commentId -> - launch { - try { - discussionInteractor.getCourseTopics(courseId) - .find { it.id == topicId }?.let { topic -> - launch(Dispatchers.Main) { - appRouter.navigateToDiscussionThread( - fm = fm, - action = DiscussionTopicsViewModel.TOPIC, - courseId = courseId, - topicId = topicId, - title = topic.name, - viewType = FragmentViewType.FULL_CONTENT - ) - } - } - val thread = discussionInteractor.getThread( - threadId, - courseId, - topicId - ) - launch(Dispatchers.Main) { - appRouter.navigateToDiscussionComments( - fm = fm, - thread = thread - ) - } - val commentsData = discussionInteractor.getThreadComment(commentId) - commentsData.results.firstOrNull()?.let { comment -> - launch(Dispatchers.Main) { - appRouter.navigateToDiscussionResponses( - fm = fm, - comment = comment, - isClosed = false - ) - } - } - } catch (e: Exception) { - e.printStackTrace() - } + val courseId = deepLink.courseId + val topicId = deepLink.topicId + val threadId = deepLink.threadId + val commentId = deepLink.commentId + val parentId = deepLink.parentId + if (courseId == null || topicId == null || threadId == null || commentId == null || parentId == null) { + return + } + launch { + try { + discussionInteractor.getCourseTopics(courseId) + .find { it.id == topicId }?.let { topic -> + launch(Dispatchers.Main) { + appRouter.navigateToDiscussionThread( + fm = fm, + action = DiscussionTopicsViewModel.TOPIC, + courseId = courseId, + topicId = topicId, + title = topic.name, + viewType = FragmentViewType.FULL_CONTENT + ) } } + val thread = discussionInteractor.getThread( + threadId, + courseId, + topicId + ) + launch(Dispatchers.Main) { + appRouter.navigateToDiscussionComments( + fm = fm, + thread = thread + ) } + val comment = discussionInteractor.getResponse(parentId) + launch(Dispatchers.Main) { + appRouter.navigateToDiscussionResponses( + fm = fm, + comment = comment, + isClosed = false + ) + } + } catch (e: Exception) { + e.printStackTrace() } } } @@ -504,4 +597,13 @@ class DeepLinkRouter( openTab = "PROFILE" ) } + + private suspend fun getCourseDetails(courseId: String): Course? { + return try { + discoveryInteractor.getCourseDetails(courseId) + } catch (e: Exception) { + e.printStackTrace() + null + } + } } diff --git a/app/src/main/java/org/openedx/app/deeplink/Screen.kt b/app/src/main/java/org/openedx/app/deeplink/Screen.kt deleted file mode 100644 index e877649e8..000000000 --- a/app/src/main/java/org/openedx/app/deeplink/Screen.kt +++ /dev/null @@ -1,20 +0,0 @@ -package org.openedx.app.deeplink - -enum class Screen(val screenName: String) { - DISCOVERY("discovery"), - DISCOVERY_COURSE_DETAIL("discovery_course_detail"), - DISCOVERY_PROGRAM_DETAIL("discovery_program_detail"), - COURSE_DASHBOARD("course_dashboard"), - COURSE_VIDEOS("course_videos"), - COURSE_DISCUSSION("course_discussion"), - COURSE_DATES("course_dates"), - COURSE_HANDOUT("course_handout"), - COURSE_ANNOUNCEMENT("course_announcement"), - COURSE_COMPONENT("course_component"), - PROGRAM("program"), - DISCUSSION_TOPIC("discussion_topic"), - DISCUSSION_POST("discussion_post"), - DISCUSSION_COMMENT("discussion_comment"), - PROFILE("profile"), - USER_PROFILE("user_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 1d1dc7e0c..9e3a1709d 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -18,7 +18,6 @@ import org.openedx.app.BuildConfig import org.openedx.app.data.storage.PreferencesManager import org.openedx.app.room.AppDatabase import org.openedx.app.room.DATABASE_NAME -import org.openedx.app.system.notifier.AppNotifier import org.openedx.auth.presentation.AgreementProvider import org.openedx.auth.presentation.AuthAnalytics import org.openedx.auth.presentation.AuthRouter @@ -44,11 +43,11 @@ import org.openedx.core.system.AppCookieManager import org.openedx.core.system.CalendarManager import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection -import org.openedx.core.system.notifier.AppUpgradeNotifier 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.system.notifier.app.AppNotifier import org.openedx.core.utils.FileUtil import org.openedx.course.data.storage.CoursePreferences import org.openedx.course.presentation.CourseAnalytics @@ -96,7 +95,6 @@ val appModule = module { single { CourseNotifier() } single { DiscussionNotifier() } single { ProfileNotifier() } - single { AppUpgradeNotifier() } single { DownloadNotifier() } single { VideoNotifier() } single { DiscoveryNotifier() } @@ -110,7 +108,7 @@ val appModule = module { single { get() } single { get() } single { get() } - single { DeepLinkRouter(get(), get(), get(), get(), get()) } + single { DeepLinkRouter(get(), get(), get(), get(), get(), get()) } single { NetworkConnection(get()) } diff --git a/app/src/main/java/org/openedx/app/di/NetworkingModule.kt b/app/src/main/java/org/openedx/app/di/NetworkingModule.kt index c281d0465..aae32b433 100644 --- a/app/src/main/java/org/openedx/app/di/NetworkingModule.kt +++ b/app/src/main/java/org/openedx/app/di/NetworkingModule.kt @@ -3,6 +3,7 @@ package org.openedx.app.di import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import org.koin.dsl.module +import org.openedx.app.data.api.NotificationsApi import org.openedx.app.data.networking.AppUpgradeInterceptor import org.openedx.app.data.networking.HandleErrorInterceptor import org.openedx.app.data.networking.HeadersInterceptor @@ -53,6 +54,7 @@ val networkingModule = module { single { provideApi(get()) } single { provideApi(get()) } single { provideApi(get()) } + single { provideApi(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 11e70d4f8..3fb4667df 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -77,6 +77,7 @@ val screenModule = module { get(named("IODispatcher")), get(), get(), + get(), get() ) } diff --git a/app/src/main/java/org/openedx/app/system/notifier/AppEvent.kt b/app/src/main/java/org/openedx/app/system/notifier/AppEvent.kt deleted file mode 100644 index 1a6f750f4..000000000 --- a/app/src/main/java/org/openedx/app/system/notifier/AppEvent.kt +++ /dev/null @@ -1,3 +0,0 @@ -package org.openedx.app.system.notifier - -interface AppEvent \ No newline at end of file diff --git a/app/src/main/java/org/openedx/app/system/notifier/LogoutEvent.kt b/app/src/main/java/org/openedx/app/system/notifier/LogoutEvent.kt deleted file mode 100644 index 209ac8815..000000000 --- a/app/src/main/java/org/openedx/app/system/notifier/LogoutEvent.kt +++ /dev/null @@ -1,3 +0,0 @@ -package org.openedx.app.system.notifier - -class LogoutEvent : AppEvent diff --git a/app/src/main/java/org/openedx/app/system/push/OpenEdXFirebaseMessagingService.kt b/app/src/main/java/org/openedx/app/system/push/OpenEdXFirebaseMessagingService.kt new file mode 100644 index 000000000..60917940e --- /dev/null +++ b/app/src/main/java/org/openedx/app/system/push/OpenEdXFirebaseMessagingService.kt @@ -0,0 +1,95 @@ +package org.openedx.app.system.push + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.media.RingtoneManager +import android.os.Build +import android.os.SystemClock +import androidx.core.app.NotificationCompat +import com.braze.push.BrazeFirebaseMessagingService +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import org.koin.android.ext.android.inject +import org.openedx.app.AppActivity +import org.openedx.app.R +import org.openedx.core.config.Config +import org.openedx.core.data.storage.CorePreferences + +class OpenEdXFirebaseMessagingService : FirebaseMessagingService() { + + private val preferences: CorePreferences by inject() + private val config: Config by inject() + + override fun onMessageReceived(message: RemoteMessage) { + super.onMessageReceived(message) + if (BrazeFirebaseMessagingService.handleBrazeRemoteMessage(this, message)) { + // This Remote Message originated from Braze and a push notification was displayed. + // No further action is needed. + return + } else { + // This Remote Message did not originate from Braze. + handlePushNotification(message) + } + } + + override fun onNewToken(token: String) { + super.onNewToken(token) + preferences.pushToken = token + if (preferences.user != null) { + SyncFirebaseTokenWorker.schedule(this) + } + } + + private fun handlePushNotification(message: RemoteMessage) { + val notification = message.notification ?: return + val data = message.data + + val intent = Intent(this, AppActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + data.forEach { (k, v) -> + intent.putExtra(k, v) + } + + val code = createId() + val pendingIntent = PendingIntent.getActivity( + this, + code, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + val channelId = "${config.getPlatformName()}_channel" + val defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) + val notificationBuilder = NotificationCompat.Builder(this, channelId) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle(notification.title) + .setStyle( + NotificationCompat.BigTextStyle() + .bigText(notification.body)) + .setAutoCancel(true) + .setSound(defaultSoundUri) + .setContentIntent(pendingIntent) + + val notificationManager = + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + // Since android Oreo notification channel is needed. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + channelId, + config.getPlatformName(), + NotificationManager.IMPORTANCE_HIGH, + ) + notificationManager.createNotificationChannel(channel) + } + + notificationManager.notify(code, notificationBuilder.build()) + } + + private fun createId(): Int { + return SystemClock.uptimeMillis().toInt() + } +} diff --git a/app/src/main/java/org/openedx/app/system/push/RefreshFirebaseTokenWorker.kt b/app/src/main/java/org/openedx/app/system/push/RefreshFirebaseTokenWorker.kt new file mode 100644 index 000000000..0f37f36e3 --- /dev/null +++ b/app/src/main/java/org/openedx/app/system/push/RefreshFirebaseTokenWorker.kt @@ -0,0 +1,46 @@ +package org.openedx.app.system.push + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.google.firebase.messaging.FirebaseMessaging +import kotlinx.coroutines.tasks.await +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.openedx.core.data.storage.CorePreferences + +class RefreshFirebaseTokenWorker(context: Context, params: WorkerParameters) : + CoroutineWorker(context, params), + KoinComponent { + + private val preferences: CorePreferences by inject() + + override suspend fun doWork(): Result { + FirebaseMessaging.getInstance().deleteToken().await() + + val newPushToken = FirebaseMessaging.getInstance().getToken().await() + + preferences.pushToken = newPushToken + + return Result.success() + } + + companion object { + private const val WORKER_TAG = "RefreshFirebaseTokenWorker" + + fun schedule(context: Context) { + val work = OneTimeWorkRequest + .Builder(RefreshFirebaseTokenWorker::class.java) + .addTag(WORKER_TAG) + .build() + WorkManager.getInstance(context).beginUniqueWork( + WORKER_TAG, + ExistingWorkPolicy.REPLACE, + work + ).enqueue() + } + } +} diff --git a/app/src/main/java/org/openedx/app/system/push/SyncFirebaseTokenWorker.kt b/app/src/main/java/org/openedx/app/system/push/SyncFirebaseTokenWorker.kt new file mode 100644 index 000000000..ed4d841eb --- /dev/null +++ b/app/src/main/java/org/openedx/app/system/push/SyncFirebaseTokenWorker.kt @@ -0,0 +1,47 @@ +package org.openedx.app.system.push + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.openedx.app.data.api.NotificationsApi +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.module.DownloadWorker + +class SyncFirebaseTokenWorker(context: Context, params: WorkerParameters) : + CoroutineWorker(context, params), + KoinComponent { + + private val preferences: CorePreferences by inject() + private val api: NotificationsApi by inject() + + override suspend fun doWork(): Result { + if (preferences.user != null && preferences.pushToken.isNotEmpty()) { + + api.syncFirebaseToken(preferences.pushToken) + + return Result.success() + } + return Result.failure() + } + + companion object { + private const val WORKER_TAG = "SyncFirebaseTokenWorker" + + fun schedule(context: Context) { + val work = OneTimeWorkRequest + .Builder(SyncFirebaseTokenWorker::class.java) + .addTag(WORKER_TAG) + .build() + WorkManager.getInstance(context).beginUniqueWork( + WORKER_TAG, + ExistingWorkPolicy.REPLACE, + work + ).enqueue() + } + } +} diff --git a/app/src/test/java/org/openedx/AppViewModelTest.kt b/app/src/test/java/org/openedx/AppViewModelTest.kt index 35a2d3d96..87a34e790 100644 --- a/app/src/test/java/org/openedx/AppViewModelTest.kt +++ b/app/src/test/java/org/openedx/AppViewModelTest.kt @@ -1,5 +1,6 @@ package org.openedx +import android.content.Context import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner @@ -25,10 +26,11 @@ import org.openedx.app.deeplink.DeepLinkRouter import org.openedx.app.AppViewModel import org.openedx.app.data.storage.PreferencesManager import org.openedx.app.room.AppDatabase -import org.openedx.app.system.notifier.AppNotifier -import org.openedx.app.system.notifier.LogoutEvent +import org.openedx.core.system.notifier.app.LogoutEvent import org.openedx.core.config.Config +import org.openedx.core.config.FirebaseConfig import org.openedx.core.data.model.User +import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.core.utils.FileUtil @ExperimentalCoroutinesApi @@ -46,6 +48,7 @@ class AppViewModelTest { private val analytics = mockk() private val fileUtil = mockk() private val deepLinkRouter = mockk() + private val context = mockk() private val user = User(0, "", "", "") @@ -65,17 +68,19 @@ class AppViewModelTest { every { preferencesManager.user } returns user every { notifier.notifier } returns flow { } every { preferencesManager.canResetAppDirectory } returns false - val viewModel = - AppViewModel( - config, - notifier, - room, - preferencesManager, - dispatcher, - analytics, - deepLinkRouter, - fileUtil - ) + every { preferencesManager.pushToken } returns "" + + val viewModel = AppViewModel( + config, + notifier, + room, + preferencesManager, + dispatcher, + analytics, + deepLinkRouter, + fileUtil, + context + ) val mockLifeCycleOwner: LifecycleOwner = mockk() val lifecycleRegistry = LifecycleRegistry(mockLifeCycleOwner) @@ -89,7 +94,7 @@ class AppViewModelTest { @Test fun forceLogout() = runTest { every { notifier.notifier } returns flow { - emit(LogoutEvent()) + emit(LogoutEvent(true)) } every { preferencesManager.clear() } returns Unit every { analytics.setUserIdForSession(any()) } returns Unit @@ -97,17 +102,20 @@ class AppViewModelTest { every { room.clearAllTables() } returns Unit every { analytics.logoutEvent(true) } returns Unit every { preferencesManager.canResetAppDirectory } returns false - val viewModel = - AppViewModel( - config, - notifier, - room, - preferencesManager, - dispatcher, - analytics, - deepLinkRouter, - fileUtil - ) + every { preferencesManager.pushToken } returns "" + every { config.getFirebaseConfig() } returns FirebaseConfig() + + val viewModel = AppViewModel( + config, + notifier, + room, + preferencesManager, + dispatcher, + analytics, + deepLinkRouter, + fileUtil, + context + ) val mockLifeCycleOwner: LifecycleOwner = mockk() val lifecycleRegistry = LifecycleRegistry(mockLifeCycleOwner) @@ -122,8 +130,8 @@ class AppViewModelTest { @Test fun forceLogoutTwice() = runTest { every { notifier.notifier } returns flow { - emit(LogoutEvent()) - emit(LogoutEvent()) + emit(LogoutEvent(true)) + emit(LogoutEvent(true)) } every { preferencesManager.clear() } returns Unit every { analytics.setUserIdForSession(any()) } returns Unit @@ -131,17 +139,20 @@ class AppViewModelTest { every { room.clearAllTables() } returns Unit every { analytics.logoutEvent(true) } returns Unit every { preferencesManager.canResetAppDirectory } returns false - val viewModel = - AppViewModel( - config, - notifier, - room, - preferencesManager, - dispatcher, - analytics, - deepLinkRouter, - fileUtil - ) + every { preferencesManager.pushToken } returns "" + every { config.getFirebaseConfig() } returns FirebaseConfig() + + val viewModel = AppViewModel( + config, + notifier, + room, + preferencesManager, + dispatcher, + analytics, + deepLinkRouter, + fileUtil, + context + ) val mockLifeCycleOwner: LifecycleOwner = mockk() val lifecycleRegistry = LifecycleRegistry(mockLifeCycleOwner) diff --git a/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordViewModel.kt b/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordViewModel.kt index b21c694da..6827d8e78 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordViewModel.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordViewModel.kt @@ -16,14 +16,14 @@ import org.openedx.core.extension.isEmailValid import org.openedx.core.extension.isInternetError import org.openedx.core.system.EdxError import org.openedx.core.system.ResourceManager -import org.openedx.core.system.notifier.AppUpgradeEvent -import org.openedx.core.system.notifier.AppUpgradeNotifier +import org.openedx.core.system.notifier.app.AppNotifier +import org.openedx.core.system.notifier.app.AppUpgradeEvent class RestorePasswordViewModel( private val interactor: AuthInteractor, private val resourceManager: ResourceManager, private val analytics: AuthAnalytics, - private val appUpgradeNotifier: AppUpgradeNotifier, + private val appNotifier: AppNotifier ) : BaseViewModel() { private val _uiState = MutableLiveData() @@ -81,8 +81,10 @@ class RestorePasswordViewModel( private fun collectAppUpgradeEvent() { viewModelScope.launch { - appUpgradeNotifier.notifier.collect { event -> - _appUpgradeEvent.value = event + appNotifier.notifier.collect { event -> + if (event is AppUpgradeEvent) { + _appUpgradeEvent.value = event + } } } } diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt index 7ebc5a569..9fbd8c2fe 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt @@ -32,8 +32,9 @@ import org.openedx.core.extension.isInternetError import org.openedx.core.presentation.global.WhatsNewGlobalManager import org.openedx.core.system.EdxError import org.openedx.core.system.ResourceManager -import org.openedx.core.system.notifier.AppUpgradeEvent -import org.openedx.core.system.notifier.AppUpgradeNotifier +import org.openedx.core.system.notifier.app.AppNotifier +import org.openedx.core.system.notifier.app.AppUpgradeEvent +import org.openedx.core.system.notifier.app.SignInEvent import org.openedx.core.utils.Logger import org.openedx.core.R as CoreRes @@ -42,7 +43,7 @@ class SignInViewModel( private val resourceManager: ResourceManager, private val preferencesManager: CorePreferences, private val validator: Validator, - private val appUpgradeNotifier: AppUpgradeNotifier, + private val appNotifier: AppNotifier, private val analytics: AuthAnalytics, private val oAuthHelper: OAuthHelper, private val router: AuthRouter, @@ -107,6 +108,7 @@ class SignInViewModel( ) } ) + appNotifier.send(SignInEvent()) } catch (e: Exception) { if (e is EdxError.InvalidGrantException) { _uiMessage.value = @@ -125,8 +127,10 @@ class SignInViewModel( private fun collectAppUpgradeEvent() { viewModelScope.launch { - appUpgradeNotifier.notifier.collect { event -> - _appUpgradeEvent.value = event + appNotifier.notifier.collect { event -> + if (event is AppUpgradeEvent) { + _appUpgradeEvent.value = event + } } } } @@ -170,6 +174,7 @@ class SignInViewModel( _uiState.update { it.copy(loginSuccess = true) } setUserId() _uiState.update { it.copy(showProgress = false) } + appNotifier.send(SignInEvent()) } } diff --git a/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpUIState.kt b/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpUIState.kt index 0f7873b78..7e60beb1d 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpUIState.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpUIState.kt @@ -2,7 +2,7 @@ package org.openedx.auth.presentation.signup import org.openedx.auth.domain.model.SocialAuthResponse import org.openedx.core.domain.model.RegistrationField -import org.openedx.core.system.notifier.AppUpgradeEvent +import org.openedx.core.system.notifier.app.AppUpgradeEvent data class SignUpUIState( val allFields: List = emptyList(), diff --git a/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpViewModel.kt b/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpViewModel.kt index 08bbce466..42b6bf2d1 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpViewModel.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpViewModel.kt @@ -31,7 +31,9 @@ import org.openedx.core.domain.model.RegistrationFieldType import org.openedx.core.domain.model.createHonorCodeField import org.openedx.core.extension.isInternetError import org.openedx.core.system.ResourceManager -import org.openedx.core.system.notifier.AppUpgradeNotifier +import org.openedx.core.system.notifier.app.AppNotifier +import org.openedx.core.system.notifier.app.AppUpgradeEvent +import org.openedx.core.system.notifier.app.SignInEvent import org.openedx.core.utils.Logger import org.openedx.core.R as coreR @@ -40,7 +42,7 @@ class SignUpViewModel( private val resourceManager: ResourceManager, private val analytics: AuthAnalytics, private val preferencesManager: CorePreferences, - private val appUpgradeNotifier: AppUpgradeNotifier, + private val appNotifier: AppNotifier, private val agreementProvider: AgreementProvider, private val oAuthHelper: OAuthHelper, private val config: Config, @@ -175,6 +177,7 @@ class SignUpViewModel( ) setUserId() _uiState.update { it.copy(successLogin = true, isButtonLoading = false) } + appNotifier.send(SignInEvent()) } else { exchangeToken(socialAuth) } @@ -255,6 +258,7 @@ class SignUpViewModel( ) _uiState.update { it.copy(successLogin = true) } logger.d { "Social login (${socialAuth.authType.methodName}) success" } + appNotifier.send(SignInEvent()) } } @@ -274,8 +278,10 @@ class SignUpViewModel( private fun collectAppUpgradeEvent() { viewModelScope.launch { - appUpgradeNotifier.notifier.collect { event -> - _uiState.update { it.copy(appUpgradeEvent = event) } + appNotifier.notifier.collect { event -> + if (event is AppUpgradeEvent) { + _uiState.update { it.copy(appUpgradeEvent = event) } + } } } } diff --git a/auth/src/test/java/org/openedx/auth/presentation/restore/RestorePasswordViewModelTest.kt b/auth/src/test/java/org/openedx/auth/presentation/restore/RestorePasswordViewModelTest.kt index 4c92b317f..0f040e908 100644 --- a/auth/src/test/java/org/openedx/auth/presentation/restore/RestorePasswordViewModelTest.kt +++ b/auth/src/test/java/org/openedx/auth/presentation/restore/RestorePasswordViewModelTest.kt @@ -26,7 +26,7 @@ import org.openedx.core.R import org.openedx.core.UIMessage import org.openedx.core.system.EdxError import org.openedx.core.system.ResourceManager -import org.openedx.core.system.notifier.AppUpgradeNotifier +import org.openedx.core.system.notifier.app.AppNotifier import java.net.UnknownHostException @OptIn(ExperimentalCoroutinesApi::class) @@ -39,7 +39,7 @@ class RestorePasswordViewModelTest { private val resourceManager = mockk() private val interactor = mockk() private val analytics = mockk() - private val appUpgradeNotifier = mockk() + private val appNotifier = mockk() //region parameters @@ -60,7 +60,7 @@ class RestorePasswordViewModelTest { every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong every { resourceManager.getString(org.openedx.auth.R.string.auth_invalid_email) } returns invalidEmail every { resourceManager.getString(org.openedx.auth.R.string.auth_invalid_password) } returns invalidPassword - every { appUpgradeNotifier.notifier } returns emptyFlow() + every { appNotifier.notifier } returns emptyFlow() } @After @@ -71,14 +71,14 @@ class RestorePasswordViewModelTest { @Test fun `passwordReset empty email validation error`() = runTest { val viewModel = - RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) + RestorePasswordViewModel(interactor, resourceManager, analytics, appNotifier) coEvery { interactor.passwordReset(emptyEmail) } returns true every { analytics.logEvent(any(), any()) } returns Unit viewModel.passwordReset(emptyEmail) advanceUntilIdle() coVerify(exactly = 0) { interactor.passwordReset(any()) } verify(exactly = 2) { analytics.logEvent(any(), any()) } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage @@ -89,14 +89,14 @@ class RestorePasswordViewModelTest { @Test fun `passwordReset invalid email validation error`() = runTest { val viewModel = - RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) + RestorePasswordViewModel(interactor, resourceManager, analytics, appNotifier) coEvery { interactor.passwordReset(invalidEmail) } returns true every { analytics.logEvent(any(), any()) } returns Unit viewModel.passwordReset(invalidEmail) advanceUntilIdle() coVerify(exactly = 0) { interactor.passwordReset(any()) } verify(exactly = 2) { analytics.logEvent(any(), any()) } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage @@ -107,14 +107,14 @@ class RestorePasswordViewModelTest { @Test fun `passwordReset validation error`() = runTest { val viewModel = - RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) + RestorePasswordViewModel(interactor, resourceManager, analytics, appNotifier) coEvery { interactor.passwordReset(correctEmail) } throws EdxError.ValidationException("error") every { analytics.logEvent(any(), any()) } returns Unit viewModel.passwordReset(correctEmail) advanceUntilIdle() coVerify(exactly = 1) { interactor.passwordReset(any()) } verify(exactly = 2) { analytics.logEvent(any(), any()) } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage @@ -125,14 +125,14 @@ class RestorePasswordViewModelTest { @Test fun `passwordReset no internet error`() = runTest { val viewModel = - RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) + RestorePasswordViewModel(interactor, resourceManager, analytics, appNotifier) coEvery { interactor.passwordReset(correctEmail) } throws UnknownHostException() every { analytics.logEvent(any(), any()) } returns Unit viewModel.passwordReset(correctEmail) advanceUntilIdle() coVerify(exactly = 1) { interactor.passwordReset(any()) } verify(exactly = 2) { analytics.logEvent(any(), any()) } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage @@ -143,14 +143,14 @@ class RestorePasswordViewModelTest { @Test fun `passwordReset unknown error`() = runTest { val viewModel = - RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) + RestorePasswordViewModel(interactor, resourceManager, analytics, appNotifier) coEvery { interactor.passwordReset(correctEmail) } throws Exception() every { analytics.logEvent(any(), any()) } returns Unit viewModel.passwordReset(correctEmail) advanceUntilIdle() coVerify(exactly = 1) { interactor.passwordReset(any()) } verify(exactly = 2) { analytics.logEvent(any(), any()) } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage @@ -161,14 +161,14 @@ class RestorePasswordViewModelTest { @Test fun `unSuccess restore password`() = runTest { val viewModel = - RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) + RestorePasswordViewModel(interactor, resourceManager, analytics, appNotifier) coEvery { interactor.passwordReset(correctEmail) } returns false every { analytics.logEvent(any(), any()) } returns Unit viewModel.passwordReset(correctEmail) advanceUntilIdle() coVerify(exactly = 1) { interactor.passwordReset(any()) } verify(exactly = 2) { analytics.logEvent(any(), any()) } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage @@ -180,14 +180,14 @@ class RestorePasswordViewModelTest { @Test fun `success restore password`() = runTest { val viewModel = - RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) + RestorePasswordViewModel(interactor, resourceManager, analytics, appNotifier) coEvery { interactor.passwordReset(correctEmail) } returns true every { analytics.logEvent(any(), any()) } returns Unit viewModel.passwordReset(correctEmail) advanceUntilIdle() coVerify(exactly = 1) { interactor.passwordReset(any()) } verify(exactly = 2) { analytics.logEvent(any(), any()) } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } val state = viewModel.uiState.value as? RestorePasswordUIState.Success val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage diff --git a/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt b/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt index b36aabb10..d35e34040 100644 --- a/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt +++ b/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt @@ -38,7 +38,9 @@ import org.openedx.core.data.storage.CorePreferences import org.openedx.core.presentation.global.WhatsNewGlobalManager import org.openedx.core.system.EdxError import org.openedx.core.system.ResourceManager -import org.openedx.core.system.notifier.AppUpgradeNotifier +import org.openedx.core.system.notifier.app.AppEvent +import org.openedx.core.system.notifier.app.AppNotifier +import org.openedx.core.system.notifier.app.SignInEvent import java.net.UnknownHostException import org.openedx.core.R as CoreRes @@ -56,7 +58,7 @@ class SignInViewModelTest { private val preferencesManager = mockk() private val interactor = mockk() private val analytics = mockk() - private val appUpgradeNotifier = mockk() + private val appNotifier = mockk() private val agreementProvider = mockk() private val oAuthHelper = mockk() private val router = mockk() @@ -78,7 +80,7 @@ class SignInViewModelTest { every { resourceManager.getString(CoreRes.string.core_error_unknown_error) } returns somethingWrong every { resourceManager.getString(R.string.auth_invalid_email_username) } returns invalidEmailOrUsername every { resourceManager.getString(R.string.auth_invalid_password) } returns invalidPassword - every { appUpgradeNotifier.notifier } returns emptyFlow() + every { appNotifier.notifier } returns emptyFlow() every { agreementProvider.getAgreement(true) } returns null every { config.isPreLoginExperienceEnabled() } returns false every { config.isSocialAuthEnabled() } returns false @@ -104,7 +106,7 @@ class SignInViewModelTest { preferencesManager = preferencesManager, validator = validator, analytics = analytics, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, @@ -137,7 +139,7 @@ class SignInViewModelTest { preferencesManager = preferencesManager, validator = validator, analytics = analytics, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, @@ -171,7 +173,7 @@ class SignInViewModelTest { preferencesManager = preferencesManager, validator = validator, analytics = analytics, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, @@ -204,7 +206,7 @@ class SignInViewModelTest { preferencesManager = preferencesManager, validator = validator, analytics = analytics, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, @@ -233,13 +235,14 @@ class SignInViewModelTest { every { preferencesManager.user } returns user every { analytics.setUserIdForSession(any()) } returns Unit every { analytics.logEvent(any(), any()) } returns Unit + coEvery { appNotifier.send(any()) } returns Unit val viewModel = SignInViewModel( interactor = interactor, resourceManager = resourceManager, preferencesManager = preferencesManager, validator = validator, analytics = analytics, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, @@ -255,7 +258,7 @@ class SignInViewModelTest { coVerify(exactly = 1) { interactor.login(any(), any()) } verify(exactly = 1) { analytics.setUserIdForSession(any()) } verify(exactly = 2) { analytics.logEvent(any(), any()) } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } val uiState = viewModel.uiState.value assertFalse(uiState.showProgress) assert(uiState.loginSuccess) @@ -275,7 +278,7 @@ class SignInViewModelTest { preferencesManager = preferencesManager, validator = validator, analytics = analytics, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, @@ -291,7 +294,7 @@ class SignInViewModelTest { coVerify(exactly = 1) { interactor.login(any(), any()) } verify(exactly = 0) { analytics.setUserIdForSession(any()) } verify(exactly = 1) { analytics.logEvent(any(), any()) } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage val uiState = viewModel.uiState.value @@ -313,7 +316,7 @@ class SignInViewModelTest { preferencesManager = preferencesManager, validator = validator, analytics = analytics, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, @@ -328,7 +331,7 @@ class SignInViewModelTest { coVerify(exactly = 1) { interactor.login(any(), any()) } verify(exactly = 0) { analytics.setUserIdForSession(any()) } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } verify(exactly = 1) { analytics.logEvent(any(), any()) } val message = viewModel.uiMessage.value as UIMessage.SnackBarMessage @@ -351,7 +354,7 @@ class SignInViewModelTest { preferencesManager = preferencesManager, validator = validator, analytics = analytics, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, @@ -366,7 +369,7 @@ class SignInViewModelTest { coVerify(exactly = 1) { interactor.login(any(), any()) } verify(exactly = 0) { analytics.setUserIdForSession(any()) } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } verify(exactly = 1) { analytics.logEvent(any(), any()) } val message = viewModel.uiMessage.value as UIMessage.SnackBarMessage diff --git a/auth/src/test/java/org/openedx/auth/presentation/signup/SignUpViewModelTest.kt b/auth/src/test/java/org/openedx/auth/presentation/signup/SignUpViewModelTest.kt index f304f7363..5be80557c 100644 --- a/auth/src/test/java/org/openedx/auth/presentation/signup/SignUpViewModelTest.kt +++ b/auth/src/test/java/org/openedx/auth/presentation/signup/SignUpViewModelTest.kt @@ -44,7 +44,7 @@ import org.openedx.core.domain.model.AgreementUrls import org.openedx.core.domain.model.RegistrationField import org.openedx.core.domain.model.RegistrationFieldType import org.openedx.core.system.ResourceManager -import org.openedx.core.system.notifier.AppUpgradeNotifier +import org.openedx.core.system.notifier.app.AppNotifier import java.net.UnknownHostException @ExperimentalCoroutinesApi @@ -59,7 +59,7 @@ class SignUpViewModelTest { private val preferencesManager = mockk() private val interactor = mockk() private val analytics = mockk() - private val appUpgradeNotifier = mockk() + private val appNotifier = mockk() private val agreementProvider = mockk() private val oAuthHelper = mockk() private val router = mockk() @@ -111,7 +111,7 @@ class SignUpViewModelTest { every { resourceManager.getString(R.string.core_error_invalid_grant) } returns "Invalid credentials" every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong - every { appUpgradeNotifier.notifier } returns emptyFlow() + every { appNotifier.notifier } returns emptyFlow() every { agreementProvider.getAgreement(false) } returns null every { config.isSocialAuthEnabled() } returns false every { config.getAgreement(Locale.current.language) } returns AgreementUrls() @@ -133,7 +133,7 @@ class SignUpViewModelTest { resourceManager = resourceManager, analytics = analytics, preferencesManager = preferencesManager, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, @@ -162,7 +162,7 @@ class SignUpViewModelTest { coVerify(exactly = 0) { interactor.register(any()) } coVerify(exactly = 0) { interactor.login(any(), any()) } verify(exactly = 0) { analytics.setUserIdForSession(any()) } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } assertEquals(true, viewModel.uiState.value.validationError) assertFalse(viewModel.uiState.value.successLogin) @@ -176,7 +176,7 @@ class SignUpViewModelTest { resourceManager = resourceManager, analytics = analytics, preferencesManager = preferencesManager, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, @@ -210,7 +210,7 @@ class SignUpViewModelTest { coVerify(exactly = 1) { interactor.validateRegistrationFields(any()) } coVerify(exactly = 0) { interactor.register(any()) } coVerify(exactly = 0) { interactor.login(any(), any()) } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } assertFalse(viewModel.uiState.value.validationError) assertFalse(viewModel.uiState.value.successLogin) @@ -225,7 +225,7 @@ class SignUpViewModelTest { resourceManager = resourceManager, analytics = analytics, preferencesManager = preferencesManager, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, @@ -248,7 +248,7 @@ class SignUpViewModelTest { coVerify(exactly = 1) { interactor.validateRegistrationFields(any()) } coVerify(exactly = 0) { interactor.register(any()) } coVerify(exactly = 0) { interactor.login(any(), any()) } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } assertFalse(viewModel.uiState.value.validationError) assertFalse(viewModel.uiState.value.successLogin) @@ -263,7 +263,7 @@ class SignUpViewModelTest { resourceManager = resourceManager, analytics = analytics, preferencesManager = preferencesManager, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, @@ -298,7 +298,7 @@ class SignUpViewModelTest { coVerify(exactly = 1) { interactor.register(any()) } coVerify(exactly = 1) { interactor.login(any(), any()) } verify(exactly = 2) { analytics.logEvent(any(), any()) } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } assertFalse(viewModel.uiState.value.validationError) assertFalse(viewModel.uiState.value.isButtonLoading) @@ -312,7 +312,7 @@ class SignUpViewModelTest { resourceManager = resourceManager, analytics = analytics, preferencesManager = preferencesManager, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, @@ -326,7 +326,7 @@ class SignUpViewModelTest { viewModel.getRegistrationFields() advanceUntilIdle() coVerify(exactly = 1) { interactor.getRegistrationFields() } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } assertFalse(viewModel.uiState.value.isLoading) assertEquals(noInternet, (deferred.await() as? UIMessage.SnackBarMessage)?.message) @@ -339,7 +339,7 @@ class SignUpViewModelTest { resourceManager = resourceManager, analytics = analytics, preferencesManager = preferencesManager, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, @@ -353,7 +353,7 @@ class SignUpViewModelTest { viewModel.getRegistrationFields() advanceUntilIdle() coVerify(exactly = 1) { interactor.getRegistrationFields() } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } assertFalse(viewModel.uiState.value.isLoading) assertEquals(somethingWrong, (deferred.await() as? UIMessage.SnackBarMessage)?.message) @@ -366,7 +366,7 @@ class SignUpViewModelTest { resourceManager = resourceManager, analytics = analytics, preferencesManager = preferencesManager, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, @@ -378,7 +378,7 @@ class SignUpViewModelTest { viewModel.getRegistrationFields() advanceUntilIdle() coVerify(exactly = 1) { interactor.getRegistrationFields() } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } //val fields = viewModel.uiState.value as? SignUpUIState.Fields diff --git a/build.gradle b/build.gradle index 250f56863..c163d3982 100644 --- a/build.gradle +++ b/build.gradle @@ -16,8 +16,8 @@ plugins { id 'com.android.application' version '8.4.0' apply false id 'com.android.library' version '8.4.0' apply false id 'org.jetbrains.kotlin.android' version "$kotlin_version" apply false - id 'com.google.gms.google-services' version '4.3.15' apply false - id "com.google.firebase.crashlytics" version "2.9.6" apply false + id 'com.google.gms.google-services' version '4.4.1' apply false + id "com.google.firebase.crashlytics" version "3.0.1" apply false } tasks.register('clean', Delete) { @@ -35,7 +35,7 @@ ext { media3_version = "1.1.1" youtubeplayer_version = "11.1.0" - firebase_version = "32.1.0" + firebase_version = "33.0.0" retrofit_version = '2.9.0' logginginterceptor_version = '4.9.1' diff --git a/core/src/main/java/org/openedx/core/AppUpdateState.kt b/core/src/main/java/org/openedx/core/AppUpdateState.kt index bf347cd29..6d6a8e357 100644 --- a/core/src/main/java/org/openedx/core/AppUpdateState.kt +++ b/core/src/main/java/org/openedx/core/AppUpdateState.kt @@ -5,7 +5,7 @@ import android.content.Context import android.content.Intent import android.net.Uri import androidx.compose.runtime.mutableStateOf -import org.openedx.core.system.notifier.AppUpgradeEvent +import org.openedx.core.system.notifier.app.AppUpgradeEvent object AppUpdateState { var wasUpdateDialogDisplayed = false diff --git a/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt b/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt index f9cacbd04..29495bae8 100644 --- a/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt +++ b/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt @@ -7,6 +7,7 @@ import org.openedx.core.domain.model.VideoSettings interface CorePreferences { var accessToken: String var refreshToken: String + var pushToken: String var accessTokenExpiresAt: Long var user: User? var videoSettings: VideoSettings diff --git a/core/src/main/java/org/openedx/core/system/notifier/AppUpgradeNotifier.kt b/core/src/main/java/org/openedx/core/system/notifier/AppUpgradeNotifier.kt deleted file mode 100644 index 0f5a274d5..000000000 --- a/core/src/main/java/org/openedx/core/system/notifier/AppUpgradeNotifier.kt +++ /dev/null @@ -1,15 +0,0 @@ -package org.openedx.core.system.notifier - -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.asSharedFlow - -class AppUpgradeNotifier { - - private val channel = MutableSharedFlow(replay = 0, extraBufferCapacity = 0) - - val notifier: Flow = channel.asSharedFlow() - - suspend fun send(event: AppUpgradeEvent) = channel.emit(event) - -} \ No newline at end of file diff --git a/core/src/main/java/org/openedx/core/system/notifier/app/AppEvent.kt b/core/src/main/java/org/openedx/core/system/notifier/app/AppEvent.kt new file mode 100644 index 000000000..7dd8f0407 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/app/AppEvent.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier.app + +interface AppEvent diff --git a/app/src/main/java/org/openedx/app/system/notifier/AppNotifier.kt b/core/src/main/java/org/openedx/core/system/notifier/app/AppNotifier.kt similarity index 67% rename from app/src/main/java/org/openedx/app/system/notifier/AppNotifier.kt rename to core/src/main/java/org/openedx/core/system/notifier/app/AppNotifier.kt index d0c579d8f..804d84a65 100644 --- a/app/src/main/java/org/openedx/app/system/notifier/AppNotifier.kt +++ b/core/src/main/java/org/openedx/core/system/notifier/app/AppNotifier.kt @@ -1,4 +1,4 @@ -package org.openedx.app.system.notifier +package org.openedx.core.system.notifier.app import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -10,6 +10,10 @@ class AppNotifier { val notifier: Flow = channel.asSharedFlow() + suspend fun send(event: SignInEvent) = channel.emit(event) + suspend fun send(event: LogoutEvent) = channel.emit(event) -} \ No newline at end of file + suspend fun send(event: AppUpgradeEvent) = channel.emit(event) + +} diff --git a/core/src/main/java/org/openedx/core/system/notifier/AppUpgradeEvent.kt b/core/src/main/java/org/openedx/core/system/notifier/app/AppUpgradeEvent.kt similarity index 61% rename from core/src/main/java/org/openedx/core/system/notifier/AppUpgradeEvent.kt rename to core/src/main/java/org/openedx/core/system/notifier/app/AppUpgradeEvent.kt index f99086a11..81dba6177 100644 --- a/core/src/main/java/org/openedx/core/system/notifier/AppUpgradeEvent.kt +++ b/core/src/main/java/org/openedx/core/system/notifier/app/AppUpgradeEvent.kt @@ -1,6 +1,6 @@ -package org.openedx.core.system.notifier +package org.openedx.core.system.notifier.app -sealed class AppUpgradeEvent { +sealed class AppUpgradeEvent: AppEvent { object UpgradeRequiredEvent : AppUpgradeEvent() class UpgradeRecommendedEvent(val newVersionName: String) : AppUpgradeEvent() } diff --git a/core/src/main/java/org/openedx/core/system/notifier/app/LogoutEvent.kt b/core/src/main/java/org/openedx/core/system/notifier/app/LogoutEvent.kt new file mode 100644 index 000000000..12154f3f1 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/app/LogoutEvent.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier.app + +class LogoutEvent(val isForced: Boolean) : AppEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/app/SignInEvent.kt b/core/src/main/java/org/openedx/core/system/notifier/app/SignInEvent.kt new file mode 100644 index 000000000..340d04476 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/app/SignInEvent.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier.app + +class SignInEvent : AppEvent 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 49d6b8cae..d83cd0c18 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 @@ -1,6 +1,8 @@ package org.openedx.course.presentation.container +import android.os.Build import android.os.Bundle +import android.util.Log import android.view.View import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.ExperimentalFoundationApi @@ -97,6 +99,12 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { } } + private val pushNotificationPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + Log.d(CourseContainerFragment::class.java.simpleName, "Permission granted: $granted") + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) viewModel.preloadCourseStructure() @@ -130,6 +138,12 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { requireActivity().supportFragmentManager, viewModel.courseName ) + } else { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + pushNotificationPermissionLauncher.launch( + android.Manifest.permission.POST_NOTIFICATIONS + ) + } } } viewModel.errorMessage.observe(viewLifecycleOwner) { diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt index 136b914e8..7f1036e1d 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt @@ -55,6 +55,8 @@ class DashboardGalleryViewModel( val hasInternetConnection: Boolean get() = networkConnection.isOnline() + private var isLoading = false + init { collectDiscoveryNotifier() getCourses() @@ -64,6 +66,7 @@ class DashboardGalleryViewModel( viewModelScope.launch { try { if (networkConnection.isOnline()) { + isLoading = true val pageSize = if (windowSize.isTablet) { PAGE_SIZE_TABLET } else { @@ -92,11 +95,15 @@ class DashboardGalleryViewModel( } } finally { _updating.value = false + isLoading = false } } } fun updateCourses() { + if (isLoading) { + return + } _updating.value = true getCourses() } diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt index e3d6abdf6..2d8e81d6b 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt @@ -82,7 +82,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.presentation.global.app_upgrade.AppUpgradeRecommendedBox -import org.openedx.core.system.notifier.AppUpgradeEvent +import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OfflineModeDialog import org.openedx.core.ui.WindowSize diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListViewModel.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListViewModel.kt index 82814561a..bfafc81c4 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListViewModel.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListViewModel.kt @@ -14,10 +14,10 @@ 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.AppUpgradeEvent -import org.openedx.core.system.notifier.AppUpgradeNotifier +import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.core.system.notifier.CourseDashboardUpdate import org.openedx.core.system.notifier.DiscoveryNotifier +import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.dashboard.domain.interactor.DashboardInteractor class DashboardListViewModel( @@ -27,7 +27,7 @@ class DashboardListViewModel( private val resourceManager: ResourceManager, private val discoveryNotifier: DiscoveryNotifier, private val analytics: DashboardAnalytics, - private val appUpgradeNotifier: AppUpgradeNotifier, + private val appNotifier: AppNotifier ) : BaseViewModel() { private val coursesList = mutableListOf() @@ -82,6 +82,9 @@ class DashboardListViewModel( } fun updateCourses() { + if (isLoading) { + return + } viewModelScope.launch { try { _updating.value = true @@ -167,8 +170,10 @@ class DashboardListViewModel( private fun collectAppUpgradeEvent() { viewModelScope.launch { - appUpgradeNotifier.notifier.collect { event -> - _appUpgradeEvent.value = event + appNotifier.notifier.collect { event -> + if (event is AppUpgradeEvent) { + _appUpgradeEvent.value = event + } } } } 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 6ca20a255..2a1131392 100644 --- a/dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardViewModelTest.kt +++ b/dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardViewModelTest.kt @@ -31,9 +31,9 @@ import org.openedx.core.domain.model.DashboardCourseList import org.openedx.core.domain.model.Pagination import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection -import org.openedx.core.system.notifier.AppUpgradeNotifier import org.openedx.core.system.notifier.CourseDashboardUpdate import org.openedx.core.system.notifier.DiscoveryNotifier +import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.dashboard.domain.interactor.DashboardInteractor import java.net.UnknownHostException @@ -51,7 +51,7 @@ class DashboardViewModelTest { private val networkConnection = mockk() private val discoveryNotifier = mockk() private val analytics = mockk() - private val appUpgradeNotifier = mockk() + private val appNotifier = mockk() private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" @@ -66,7 +66,7 @@ class DashboardViewModelTest { Dispatchers.setMain(dispatcher) every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong - every { appUpgradeNotifier.notifier } returns emptyFlow() + every { appNotifier.notifier } returns emptyFlow() every { config.getApiHostURL() } returns "http://localhost:8000" } @@ -84,7 +84,7 @@ class DashboardViewModelTest { resourceManager, discoveryNotifier, analytics, - appUpgradeNotifier + appNotifier ) every { networkConnection.isOnline() } returns true coEvery { interactor.getEnrolledCourses(any()) } throws UnknownHostException() @@ -92,7 +92,7 @@ class DashboardViewModelTest { coVerify(exactly = 1) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assertEquals(noInternet, message?.message) @@ -108,7 +108,7 @@ class DashboardViewModelTest { resourceManager, discoveryNotifier, analytics, - appUpgradeNotifier + appNotifier ) every { networkConnection.isOnline() } returns true coEvery { interactor.getEnrolledCourses(any()) } throws Exception() @@ -116,7 +116,7 @@ class DashboardViewModelTest { coVerify(exactly = 1) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assertEquals(somethingWrong, message?.message) @@ -132,7 +132,7 @@ class DashboardViewModelTest { resourceManager, discoveryNotifier, analytics, - appUpgradeNotifier + appNotifier ) every { networkConnection.isOnline() } returns true coEvery { interactor.getEnrolledCourses(any()) } returns dashboardCourseList @@ -141,7 +141,7 @@ class DashboardViewModelTest { coVerify(exactly = 1) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } assert(viewModel.uiMessage.value == null) assert(viewModel.uiState.value is DashboardUIState.Courses) @@ -156,7 +156,7 @@ class DashboardViewModelTest { resourceManager, discoveryNotifier, analytics, - appUpgradeNotifier + appNotifier ) every { networkConnection.isOnline() } returns true coEvery { interactor.getEnrolledCourses(any()) } returns dashboardCourseList.copy( @@ -173,7 +173,7 @@ class DashboardViewModelTest { coVerify(exactly = 1) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } assert(viewModel.uiMessage.value == null) assert(viewModel.uiState.value is DashboardUIState.Courses) @@ -190,14 +190,14 @@ class DashboardViewModelTest { resourceManager, discoveryNotifier, analytics, - appUpgradeNotifier + appNotifier ) advanceUntilIdle() coVerify(exactly = 0) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 1) { interactor.getEnrolledCoursesFromCache() } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } assert(viewModel.uiMessage.value == null) assert(viewModel.uiState.value is DashboardUIState.Courses) @@ -214,7 +214,7 @@ class DashboardViewModelTest { resourceManager, discoveryNotifier, analytics, - appUpgradeNotifier + appNotifier ) coEvery { interactor.getEnrolledCourses(any()) } throws UnknownHostException() @@ -223,7 +223,7 @@ class DashboardViewModelTest { coVerify(exactly = 2) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assertEquals(noInternet, message?.message) @@ -242,7 +242,7 @@ class DashboardViewModelTest { resourceManager, discoveryNotifier, analytics, - appUpgradeNotifier + appNotifier ) coEvery { interactor.getEnrolledCourses(any()) } throws Exception() @@ -251,7 +251,7 @@ class DashboardViewModelTest { coVerify(exactly = 2) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assertEquals(somethingWrong, message?.message) @@ -270,7 +270,7 @@ class DashboardViewModelTest { resourceManager, discoveryNotifier, analytics, - appUpgradeNotifier + appNotifier ) viewModel.updateCourses() @@ -278,7 +278,7 @@ class DashboardViewModelTest { coVerify(exactly = 2) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } assert(viewModel.uiMessage.value == null) assert(viewModel.updating.value == false) @@ -303,7 +303,7 @@ class DashboardViewModelTest { resourceManager, discoveryNotifier, analytics, - appUpgradeNotifier + appNotifier ) viewModel.updateCourses() @@ -311,7 +311,7 @@ class DashboardViewModelTest { coVerify(exactly = 2) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } assert(viewModel.uiMessage.value == null) assert(viewModel.updating.value == false) @@ -328,7 +328,7 @@ class DashboardViewModelTest { resourceManager, discoveryNotifier, analytics, - appUpgradeNotifier + appNotifier ) val mockLifeCycleOwner: LifecycleOwner = mockk() @@ -339,7 +339,7 @@ class DashboardViewModelTest { advanceUntilIdle() coVerify(exactly = 1) { interactor.getEnrolledCourses(any()) } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } } } diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt index ee99a5bb3..3a50ab707 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt @@ -63,7 +63,7 @@ import org.openedx.core.UIMessage import org.openedx.core.domain.model.Media import org.openedx.core.presentation.dialog.appupgrade.AppUpgradeDialogFragment import org.openedx.core.presentation.global.app_upgrade.AppUpgradeRecommendedBox -import org.openedx.core.system.notifier.AppUpgradeEvent +import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.core.ui.AuthButtonsPanel import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryViewModel.kt b/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryViewModel.kt index 271e05535..3f1098433 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryViewModel.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryViewModel.kt @@ -15,8 +15,8 @@ import org.openedx.core.data.storage.CorePreferences 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.AppUpgradeEvent -import org.openedx.core.system.notifier.AppUpgradeNotifier +import org.openedx.core.system.notifier.app.AppNotifier +import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.discovery.domain.interactor.DiscoveryInteractor import org.openedx.discovery.domain.model.Course @@ -26,7 +26,7 @@ class NativeDiscoveryViewModel( private val interactor: DiscoveryInteractor, private val resourceManager: ResourceManager, private val analytics: DiscoveryAnalytics, - private val appUpgradeNotifier: AppUpgradeNotifier, + private val appNotifier: AppNotifier, private val corePreferences: CorePreferences, ) : BaseViewModel() { @@ -160,14 +160,13 @@ class NativeDiscoveryViewModel( @OptIn(FlowPreview::class) private fun collectAppUpgradeEvent() { viewModelScope.launch { - appUpgradeNotifier.notifier + appNotifier.notifier .debounce(100) .collect { event -> when (event) { is AppUpgradeEvent.UpgradeRecommendedEvent -> { _appUpgradeEvent.value = event } - is AppUpgradeEvent.UpgradeRequiredEvent -> { _appUpgradeEvent.value = AppUpgradeEvent.UpgradeRequiredEvent } diff --git a/discovery/src/test/java/org/openedx/discovery/presentation/NativeDiscoveryViewModelTest.kt b/discovery/src/test/java/org/openedx/discovery/presentation/NativeDiscoveryViewModelTest.kt index 898a227c3..7360ef131 100644 --- a/discovery/src/test/java/org/openedx/discovery/presentation/NativeDiscoveryViewModelTest.kt +++ b/discovery/src/test/java/org/openedx/discovery/presentation/NativeDiscoveryViewModelTest.kt @@ -27,7 +27,7 @@ import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Pagination import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection -import org.openedx.core.system.notifier.AppUpgradeNotifier +import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.discovery.domain.interactor.DiscoveryInteractor import org.openedx.discovery.domain.model.CourseList import java.net.UnknownHostException @@ -46,7 +46,7 @@ class NativeDiscoveryViewModelTest { private val interactor = mockk() private val networkConnection = mockk() private val analytics = mockk() - private val appUpgradeNotifier = mockk() + private val appNotifier = mockk() private val corePreferences = mockk() private val noInternet = "Slow or no internet connection" @@ -57,7 +57,7 @@ class NativeDiscoveryViewModelTest { Dispatchers.setMain(dispatcher) every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong - every { appUpgradeNotifier.notifier } returns emptyFlow() + every { appNotifier.notifier } returns emptyFlow() every { corePreferences.user } returns null every { config.getApiHostURL() } returns "http://localhost:8000" every { config.isPreLoginExperienceEnabled() } returns false @@ -76,7 +76,7 @@ class NativeDiscoveryViewModelTest { interactor, resourceManager, analytics, - appUpgradeNotifier, + appNotifier, corePreferences ) every { networkConnection.isOnline() } returns true @@ -85,7 +85,7 @@ class NativeDiscoveryViewModelTest { coVerify(exactly = 1) { interactor.getCoursesList(any(), any(), any()) } coVerify(exactly = 0) { interactor.getCoursesListFromCache() } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assertEquals(noInternet, message?.message) @@ -101,7 +101,7 @@ class NativeDiscoveryViewModelTest { interactor, resourceManager, analytics, - appUpgradeNotifier, + appNotifier, corePreferences ) every { networkConnection.isOnline() } returns true @@ -125,7 +125,7 @@ class NativeDiscoveryViewModelTest { interactor, resourceManager, analytics, - appUpgradeNotifier, + appNotifier, corePreferences ) every { networkConnection.isOnline() } returns false @@ -148,7 +148,7 @@ class NativeDiscoveryViewModelTest { interactor, resourceManager, analytics, - appUpgradeNotifier, + appNotifier, corePreferences ) every { networkConnection.isOnline() } returns true @@ -178,7 +178,7 @@ class NativeDiscoveryViewModelTest { interactor, resourceManager, analytics, - appUpgradeNotifier, + appNotifier, corePreferences ) every { networkConnection.isOnline() } returns true @@ -209,7 +209,7 @@ class NativeDiscoveryViewModelTest { interactor, resourceManager, analytics, - appUpgradeNotifier, + appNotifier, corePreferences ) every { networkConnection.isOnline() } returns true @@ -234,7 +234,7 @@ class NativeDiscoveryViewModelTest { interactor, resourceManager, analytics, - appUpgradeNotifier, + appNotifier, corePreferences ) every { networkConnection.isOnline() } returns true @@ -259,7 +259,7 @@ class NativeDiscoveryViewModelTest { interactor, resourceManager, analytics, - appUpgradeNotifier, + appNotifier, corePreferences ) every { networkConnection.isOnline() } returns true @@ -290,7 +290,7 @@ class NativeDiscoveryViewModelTest { interactor, resourceManager, analytics, - appUpgradeNotifier, + appNotifier, corePreferences ) every { networkConnection.isOnline() } returns true diff --git a/discussion/src/main/java/org/openedx/discussion/data/api/DiscussionApi.kt b/discussion/src/main/java/org/openedx/discussion/data/api/DiscussionApi.kt index 75a780d72..4d0343d69 100644 --- a/discussion/src/main/java/org/openedx/discussion/data/api/DiscussionApi.kt +++ b/discussion/src/main/java/org/openedx/discussion/data/api/DiscussionApi.kt @@ -1,5 +1,6 @@ package org.openedx.discussion.data.api +import org.json.JSONObject import org.openedx.core.data.model.BlocksCompletionBody import org.openedx.discussion.data.model.request.* import org.openedx.discussion.data.model.response.CommentResult @@ -50,11 +51,11 @@ interface DiscussionApi { @Query("requested_fields") requestedFields: List = listOf("profile_image") ): CommentsResponse - @GET("/api/discussion/v1/comments/{comment_id}") - suspend fun getThreadComment( - @Path("comment_id") commentId: String, - @Query("requested_fields") requestedFields: List = listOf("profile_image") - ): CommentsResponse + @Headers("Content-type: application/merge-patch+json") + @PATCH("/api/discussion/v1/comments/{response_id}/") + suspend fun getResponse( + @Path("response_id") responseId: String + ): CommentResult @GET("/api/discussion/v1/comments/") suspend fun getThreadQuestionComments( diff --git a/discussion/src/main/java/org/openedx/discussion/data/model/response/CommentsResponse.kt b/discussion/src/main/java/org/openedx/discussion/data/model/response/CommentsResponse.kt index a2248b036..711bab32c 100644 --- a/discussion/src/main/java/org/openedx/discussion/data/model/response/CommentsResponse.kt +++ b/discussion/src/main/java/org/openedx/discussion/data/model/response/CommentsResponse.kt @@ -76,9 +76,9 @@ data class CommentResult( authorLabel ?: "", createdAt, updatedAt, - rawBody, - renderedBody, - TextConverter.textToLinkedImageText(renderedBody), + rawBody ?: "", + renderedBody ?: "", + TextConverter.textToLinkedImageText(renderedBody ?: ""), abuseFlagged, voted, voteCount, diff --git a/discussion/src/main/java/org/openedx/discussion/data/repository/DiscussionRepository.kt b/discussion/src/main/java/org/openedx/discussion/data/repository/DiscussionRepository.kt index 3ee4f74a5..95c603cf5 100644 --- a/discussion/src/main/java/org/openedx/discussion/data/repository/DiscussionRepository.kt +++ b/discussion/src/main/java/org/openedx/discussion/data/repository/DiscussionRepository.kt @@ -12,6 +12,7 @@ import org.openedx.discussion.data.model.request.ReportBody import org.openedx.discussion.data.model.request.ThreadBody import org.openedx.discussion.data.model.request.VoteBody import org.openedx.discussion.domain.model.CommentsData +import org.openedx.discussion.domain.model.DiscussionComment import org.openedx.discussion.domain.model.ThreadsData import org.openedx.discussion.domain.model.Topic @@ -81,10 +82,10 @@ class DiscussionRepository( return api.getThreadComments(threadId, page).mapToDomain() } - suspend fun getThreadComment( - commentId: String - ): CommentsData { - return api.getThreadComment(commentId).mapToDomain() + suspend fun getResponse( + responseId: String + ): DiscussionComment { + return api.getResponse(responseId).mapToDomain() } suspend fun getThreadQuestionComments( diff --git a/discussion/src/main/java/org/openedx/discussion/domain/interactor/DiscussionInteractor.kt b/discussion/src/main/java/org/openedx/discussion/domain/interactor/DiscussionInteractor.kt index 561a75006..90960011c 100644 --- a/discussion/src/main/java/org/openedx/discussion/domain/interactor/DiscussionInteractor.kt +++ b/discussion/src/main/java/org/openedx/discussion/domain/interactor/DiscussionInteractor.kt @@ -2,6 +2,7 @@ package org.openedx.discussion.domain.interactor import org.openedx.discussion.data.repository.DiscussionRepository import org.openedx.discussion.domain.model.CommentsData +import org.openedx.discussion.domain.model.DiscussionComment class DiscussionInteractor( private val repository: DiscussionRepository @@ -41,8 +42,8 @@ class DiscussionInteractor( suspend fun getThreadComments(threadId: String, page: Int) = repository.getThreadComments(threadId, page) - suspend fun getThreadComment(commentId: String): CommentsData = - repository.getThreadComment(commentId) + suspend fun getResponse(responseId: String): DiscussionComment = + repository.getResponse(responseId) suspend fun getThreadQuestionComments(threadId: String, endorsed: Boolean, page: Int) = repository.getThreadQuestionComments(threadId, endorsed, page) diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt index f94064d30..5e044ca46 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt @@ -53,7 +53,7 @@ import androidx.compose.ui.window.Dialog import org.openedx.core.R import org.openedx.core.domain.model.AgreementUrls import org.openedx.core.presentation.global.AppData -import org.openedx.core.system.notifier.AppUpgradeEvent +import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.Toolbar import org.openedx.core.ui.WindowSize diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt index 9715eb774..6e622e2cc 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt @@ -23,8 +23,9 @@ import org.openedx.core.module.DownloadWorkerController import org.openedx.core.presentation.global.AppData import org.openedx.core.system.AppCookieManager import org.openedx.core.system.ResourceManager -import org.openedx.core.system.notifier.AppUpgradeEvent -import org.openedx.core.system.notifier.AppUpgradeNotifier +import org.openedx.core.system.notifier.app.AppNotifier +import org.openedx.core.system.notifier.app.AppUpgradeEvent +import org.openedx.core.system.notifier.app.LogoutEvent import org.openedx.core.utils.EmailUtil import org.openedx.profile.domain.interactor.ProfileInteractor import org.openedx.profile.domain.model.Configuration @@ -44,7 +45,7 @@ class SettingsViewModel( private val workerController: DownloadWorkerController, private val analytics: ProfileAnalytics, private val router: ProfileRouter, - private val appUpgradeNotifier: AppUpgradeNotifier, + private val appNotifier: AppNotifier, private val profileNotifier: ProfileNotifier, ) : BaseViewModel() { @@ -100,6 +101,7 @@ class SettingsViewModel( } } finally { cookieManager.clearWebViewCookie() + appNotifier.send(LogoutEvent(false)) _successLogout.emit(true) } } @@ -107,8 +109,10 @@ class SettingsViewModel( private fun collectAppUpgradeEvent() { viewModelScope.launch { - appUpgradeNotifier.notifier.collect { event -> - _appUpgradeEvent.value = event + appNotifier.notifier.collect { event -> + if (event is AppUpgradeEvent) { + _appUpgradeEvent.value = event + } } } } From 5abf44d32cda055b62271fe5b83b838ac170d9c3 Mon Sep 17 00:00:00 2001 From: Omer Habib <30689349+omerhabib26@users.noreply.github.com> Date: Mon, 8 Jul 2024 15:21:21 +0500 Subject: [PATCH 24/56] fix: update keyboard visibility and imeAction (#350) * fix: update keyboard visibility and imeAction - Hide keyboard on logistration screens - update imeAction for long InputEditFields -LEARNER-10032 * fix: Added error Text for editable fields on SignIn Screen - Add error text in case of empty fields on sign in Screen fix: LEARNER-10032 * fix: Address PR comments --- .../restore/RestorePasswordFragment.kt | 28 ++++++++--- .../presentation/signin/compose/SignInView.kt | 46 +++++++++++++++++-- .../presentation/signup/compose/SignUpView.kt | 7 ++- .../openedx/auth/presentation/ui/AuthUI.kt | 32 +++++++++---- auth/src/main/res/values/strings.xml | 3 ++ .../threads/DiscussionAddThreadFragment.kt | 2 +- .../presentation/edit/EditProfileFragment.kt | 2 +- 7 files changed, 97 insertions(+), 23 deletions(-) diff --git a/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordFragment.kt b/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordFragment.kt index 18cf169bc..84d2d584e 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordFragment.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordFragment.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource @@ -127,9 +128,9 @@ private fun RestorePasswordScreen( ) { val scaffoldState = rememberScaffoldState() val scrollState = rememberScrollState() - var email by rememberSaveable { - mutableStateOf("") - } + var email by rememberSaveable { mutableStateOf("") } + var isEmailError by rememberSaveable { mutableStateOf(false) } + val keyboardController = LocalSoftwareKeyboardController.current Scaffold( scaffoldState = scaffoldState, @@ -269,12 +270,20 @@ private fun RestorePasswordScreen( description = stringResource(id = authR.string.auth_example_email), onValueChanged = { email = it + isEmailError = false }, imeAction = ImeAction.Done, keyboardActions = { - it.clearFocus() - onRestoreButtonClick(email) - } + keyboardController?.hide() + if (email.isNotEmpty()) { + it.clearFocus() + onRestoreButtonClick(email) + } else { + isEmailError = email.isEmpty() + } + }, + isError = isEmailError, + errorMessages = stringResource(id = authR.string.auth_error_empty_email) ) Spacer(Modifier.height(50.dp)) if (uiState == RestorePasswordUIState.Loading) { @@ -292,7 +301,12 @@ private fun RestorePasswordScreen( modifier = buttonWidth.testTag("btn_reset_password"), text = stringResource(id = authR.string.auth_reset_password), onClick = { - onRestoreButtonClick(email) + keyboardController?.hide() + if (email.isNotEmpty()) { + onRestoreButtonClick(email) + } else { + isEmailError = email.isEmpty() + } } ) } diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt index 783a60a99..cb77faa37 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -220,6 +221,9 @@ private fun AuthForm( ) { var login by rememberSaveable { mutableStateOf("") } var password by rememberSaveable { mutableStateOf("") } + val keyboardController = LocalSoftwareKeyboardController.current + var isEmailError by rememberSaveable { mutableStateOf(false) } + var isPasswordError by rememberSaveable { mutableStateOf(false) } Column(horizontalAlignment = Alignment.CenterHorizontally) { LoginTextField( @@ -229,7 +233,11 @@ private fun AuthForm( description = stringResource(id = R.string.auth_enter_email_username), onValueChanged = { login = it - }) + isEmailError = false + }, + isError = isEmailError, + errorMessages = stringResource(id = R.string.auth_error_empty_username_email) + ) Spacer(modifier = Modifier.height(18.dp)) PasswordTextField( @@ -237,10 +245,18 @@ private fun AuthForm( .fillMaxWidth(), onValueChanged = { password = it + isPasswordError = false }, onPressDone = { - onEvent(AuthEvent.SignIn(login = login, password = password)) - } + keyboardController?.hide() + if (password.isNotEmpty()) { + onEvent(AuthEvent.SignIn(login = login, password = password)) + } else { + isEmailError = login.isEmpty() + isPasswordError = password.isEmpty() + } + }, + isError = isPasswordError, ) Row( @@ -282,7 +298,13 @@ private fun AuthForm( textColor = MaterialTheme.appColors.primaryButtonText, backgroundColor = MaterialTheme.appColors.secondaryButtonBackground, onClick = { - onEvent(AuthEvent.SignIn(login = login, password = password)) + keyboardController?.hide() + if (login.isNotEmpty() && password.isNotEmpty()) { + onEvent(AuthEvent.SignIn(login = login, password = password)) + } else { + isEmailError = login.isEmpty() + isPasswordError = password.isEmpty() + } } ) } @@ -294,6 +316,7 @@ private fun AuthForm( isMicrosoftAuthEnabled = state.isMicrosoftAuthEnabled, isSignIn = true, ) { + keyboardController?.hide() onEvent(AuthEvent.SocialSignIn(it)) } } @@ -303,6 +326,7 @@ private fun AuthForm( @Composable private fun PasswordTextField( modifier: Modifier = Modifier, + isError: Boolean, onValueChanged: (String) -> Unit, onPressDone: () -> Unit, ) { @@ -361,9 +385,21 @@ private fun PasswordTextField( focusManager.clearFocus() onPressDone() }, + isError = isError, textStyle = MaterialTheme.appTypography.bodyMedium, - singleLine = true + singleLine = true, ) + if (isError) { + Text( + modifier = Modifier + .testTag("txt_password_error") + .fillMaxWidth() + .padding(top = 4.dp), + text = stringResource(id = R.string.auth_error_empty_password), + style = MaterialTheme.appTypography.bodySmall, + color = MaterialTheme.appColors.error, + ) + } } @Preview(uiMode = UI_MODE_NIGHT_NO) diff --git a/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt b/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt index 2872c579b..e1e31c7b8 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt @@ -442,6 +442,7 @@ internal fun SignUpView( textColor = MaterialTheme.appColors.primaryButtonText, backgroundColor = MaterialTheme.appColors.secondaryButtonBackground, onClick = { + keyboardController?.hide() showErrorMap.clear() onRegisterClick(AuthType.PASSWORD) } @@ -455,6 +456,7 @@ internal fun SignUpView( isMicrosoftAuthEnabled = uiState.isMicrosoftAuthEnabled, isSignIn = false, ) { + keyboardController?.hide() onRegisterClick(it) } } @@ -478,7 +480,10 @@ private fun RegistrationScreenPreview() { SignUpView( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), uiState = SignUpUIState( - allFields = listOf(field, field, field.copy(required = false)), + allFields = listOf(field), + requiredFields = listOf(field, field), + optionalFields = listOf(field, field), + agreementFields = listOf(field), ), uiMessage = null, onBackClick = {}, diff --git a/auth/src/main/java/org/openedx/auth/presentation/ui/AuthUI.kt b/auth/src/main/java/org/openedx/auth/presentation/ui/AuthUI.kt index 16d75492b..9f75a2478 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/ui/AuthUI.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/ui/AuthUI.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions @@ -69,14 +70,15 @@ fun RequiredFields( showErrorMap: MutableMap, selectableNamesMap: MutableMap, onFieldUpdated: (String, String) -> Unit, - onSelectClick: (String, RegistrationField, List) -> Unit + onSelectClick: (String, RegistrationField, List) -> Unit, ) { fields.forEach { field -> when (field.type) { RegistrationFieldType.TEXT, RegistrationFieldType.EMAIL, RegistrationFieldType.CONFIRM_EMAIL, - RegistrationFieldType.PASSWORD -> { + RegistrationFieldType.PASSWORD, + -> { InputRegistrationField( modifier = Modifier.fillMaxWidth(), isErrorShown = showErrorMap[field.name] ?: true, @@ -232,9 +234,11 @@ fun LoginTextField( modifier: Modifier = Modifier, title: String, description: String, + isError: Boolean = false, + errorMessages: String = "", onValueChanged: (String) -> Unit, imeAction: ImeAction = ImeAction.Next, - keyboardActions: (FocusManager) -> Unit = { it.moveFocus(FocusDirection.Down) } + keyboardActions: (FocusManager) -> Unit = { it.moveFocus(FocusDirection.Down) }, ) { var loginTextFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf( @@ -281,8 +285,20 @@ fun LoginTextField( }, textStyle = MaterialTheme.appTypography.bodyMedium, singleLine = true, - modifier = modifier.testTag("tf_email") + modifier = modifier.testTag("tf_email"), + isError = isError ) + if (isError) { + Text( + modifier = Modifier + .testTag("txt_email_error") + .fillMaxWidth() + .padding(top = 4.dp), + text = errorMessages, + style = MaterialTheme.appTypography.bodySmall, + color = MaterialTheme.appColors.error, + ) + } } @Composable @@ -290,7 +306,7 @@ fun InputRegistrationField( modifier: Modifier, isErrorShown: Boolean, registrationField: RegistrationField, - onValueChanged: (String, String, Boolean) -> Unit + onValueChanged: (String, String, Boolean) -> Unit, ) { var inputRegistrationFieldValue by rememberSaveable { mutableStateOf(registrationField.placeholder) @@ -401,7 +417,7 @@ fun SelectableRegisterField( registrationField: RegistrationField, isErrorShown: Boolean, initialValue: String, - onClick: (String, List) -> Unit + onClick: (String, List) -> Unit, ) { val helperTextColor = if (registrationField.errorInstructions.isEmpty()) { MaterialTheme.appColors.textSecondary @@ -489,7 +505,7 @@ fun SelectableRegisterField( fun ExpandableText( modifier: Modifier = Modifier, isExpanded: Boolean, - onClick: (Boolean) -> Unit + onClick: (Boolean) -> Unit, ) { val transitionState = remember { MutableTransitionState(isExpanded).apply { @@ -537,7 +553,7 @@ fun ExpandableText( @Composable internal fun PasswordVisibilityIcon( isPasswordVisible: Boolean, - onClick: () -> Unit + onClick: () -> Unit, ) { val (image, description) = if (isPasswordVisible) { Icons.Filled.VisibilityOff to stringResource(R.string.auth_accessibility_hide_password) diff --git a/auth/src/main/res/values/strings.xml b/auth/src/main/res/values/strings.xml index 642185915..49a8fb68e 100644 --- a/auth/src/main/res/values/strings.xml +++ b/auth/src/main/res/values/strings.xml @@ -22,7 +22,10 @@ We have sent a password recover instructions to your email %s username@domain.com Enter email or username + Please enter your username or e-mail address and try again. + Please enter your e-mail address and try again. Enter password + Please enter your password and try again. Create an account to start learning today! Complete your registration Sign in with Google diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadFragment.kt b/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadFragment.kt index 416140f1e..a211e10b3 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadFragment.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadFragment.kt @@ -395,7 +395,7 @@ private fun DiscussionAddThreadScreen( ), isSingleLine = false, withRequiredMark = true, - imeAction = ImeAction.Done, + imeAction = ImeAction.Default, keyboardActions = { focusManager -> focusManager.clearFocus() keyboardController?.hide() diff --git a/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileFragment.kt index 5fc9e9a78..3800d23ac 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileFragment.kt @@ -1047,7 +1047,7 @@ private fun InputEditField( }, keyboardOptions = KeyboardOptions.Default.copy( keyboardType = keyboardType, - imeAction = ImeAction.Done + imeAction = ImeAction.Default ), keyboardActions = KeyboardActions { keyboardController?.hide() From ba305a09c6e09a9806bea389155b937b756d6c69 Mon Sep 17 00:00:00 2001 From: Igor Date: Mon, 8 Jul 2024 12:27:54 +0200 Subject: [PATCH 25/56] fix: show only one screen with all downloadable content (#352) --- .../outline/CourseOutlineViewModel.kt | 17 ++++++++++------- .../openedx/course/presentation/ui/CourseUI.kt | 3 +-- .../presentation/videos/CourseVideoViewModel.kt | 17 ++++++++++------- 3 files changed, 21 insertions(+), 16 deletions(-) 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 6ea080957..b65b3b62a 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 @@ -388,14 +388,17 @@ class CourseOutlineViewModel( } } - fun downloadBlocks(blocksIds: List, fragmentManager: FragmentManager, context: Context) { + fun downloadBlocks( + blocksIds: List, + fragmentManager: FragmentManager, + context: Context + ) { + if (blocksIds.find { isBlockDownloading(it) } != null) { + courseRouter.navigateToDownloadQueue(fm = fragmentManager) + return + } blocksIds.forEach { blockId -> - if (isBlockDownloading(blockId)) { - courseRouter.navigateToDownloadQueue( - fm = fragmentManager, - getDownloadableChildren(blockId) ?: arrayListOf() - ) - } else if (isBlockDownloaded(blockId)) { + if (isBlockDownloaded(blockId)) { removeDownloadModels(blockId) } else { saveDownloadModels( 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 c187af0ad..7135bb8c6 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 @@ -244,8 +244,7 @@ fun OfflineQueueCard( maxLines = 1 ) - val progress = progressValue.toFloat() / progressSize - + val progress = if (progressSize == 0L) 0f else progressValue.toFloat() / progressSize LinearProgressIndicator( modifier = Modifier .fillMaxWidth() 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 49f3b6120..a5bf069cd 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 @@ -214,14 +214,17 @@ class CourseVideoViewModel( return resultBlocks.toList() } - fun downloadBlocks(blocksIds: List, fragmentManager: FragmentManager, context: Context) { + fun downloadBlocks( + blocksIds: List, + fragmentManager: FragmentManager, + context: Context + ) { + if (blocksIds.find { isBlockDownloading(it) } != null) { + courseRouter.navigateToDownloadQueue(fm = fragmentManager) + return + } blocksIds.forEach { blockId -> - if (isBlockDownloading(blockId)) { - courseRouter.navigateToDownloadQueue( - fm = fragmentManager, - getDownloadableChildren(blockId) ?: arrayListOf() - ) - } else if (isBlockDownloaded(blockId)) { + if (isBlockDownloaded(blockId)) { removeDownloadModels(blockId) } else { saveDownloadModels( From 02a83ede97c7cb4c8b2fa0e0e25416229039daf9 Mon Sep 17 00:00:00 2001 From: Hamza Israr <71447999+HamzaIsrar12@users.noreply.github.com> Date: Mon, 8 Jul 2024 16:29:02 +0500 Subject: [PATCH 26/56] fix: Video Subtitles on Native and Youtube Player (#349) * fix: Video Subtitles on Native and Youtube Player * chore: UI Changes --- .../java/org/openedx/course/presentation/ui/CourseUI.kt | 9 ++++++++- .../course/presentation/unit/video/VideoUnitFragment.kt | 1 - .../course/presentation/unit/video/VideoUnitViewModel.kt | 2 +- .../course/presentation/unit/video/VideoViewModel.kt | 2 +- 4 files changed, 10 insertions(+), 4 deletions(-) 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 7135bb8c6..b111dd1f0 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 @@ -69,6 +69,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow @@ -562,6 +563,11 @@ fun VideoSubtitles( } else { MaterialTheme.appColors.textFieldBorder } + val fontWeight = if (currentIndex == index) { + FontWeight.SemiBold + } else { + FontWeight.Normal + } Text( modifier = Modifier .fillMaxWidth() @@ -570,7 +576,8 @@ fun VideoSubtitles( }, text = Jsoup.parse(item.content).text(), color = textColor, - style = MaterialTheme.appTypography.bodyMedium + style = MaterialTheme.appTypography.bodyMedium, + fontWeight = fontWeight, ) Spacer(Modifier.height(16.dp)) } diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitFragment.kt index 2e078f4c6..d92b3b067 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitFragment.kt @@ -91,7 +91,6 @@ class VideoUnitFragment : Fragment(R.layout.fragment_video_unit) { viewModel.isDownloaded = getBoolean(ARG_DOWNLOADED) } viewModel.downloadSubtitles() - handler.removeCallbacks(videoTimeRunnable) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitViewModel.kt index e28e723f6..5779b96da 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitViewModel.kt @@ -100,8 +100,8 @@ open class VideoUnitViewModel( open fun markBlockCompleted(blockId: String, medium: String) { - logLoadedCompletedEvent(videoUrl, false, getCurrentVideoTime(), medium) if (!isBlockAlreadyCompleted) { + logLoadedCompletedEvent(videoUrl, false, getCurrentVideoTime(), medium) viewModelScope.launch { try { isBlockAlreadyCompleted = true diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoViewModel.kt index a4063393a..4ae600eb8 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoViewModel.kt @@ -40,8 +40,8 @@ class VideoViewModel( } fun markBlockCompleted(blockId: String, medium: String) { - logLoadedCompletedEvent(videoUrl, false, currentVideoTime, medium) if (!isBlockAlreadyCompleted) { + logLoadedCompletedEvent(videoUrl, false, currentVideoTime, medium) viewModelScope.launch { try { isBlockAlreadyCompleted = true From 9f0740fa4f64f4e27a3f1d92af6a45bdb6cd1e1c Mon Sep 17 00:00:00 2001 From: Hamza Israr <71447999+HamzaIsrar12@users.noreply.github.com> Date: Tue, 9 Jul 2024 18:34:28 +0500 Subject: [PATCH 27/56] feat: Handle Branch Deeplinks from Braze Push Notification (#353) * feat: Handle Branch Deeplinks from Braze Push Notification Fixes: LEARNER-10054 * refactor: Update class name to BranchBrazeDeeplinkHandler --- .../main/java/org/openedx/app/AppActivity.kt | 39 +++++++++---------- .../main/java/org/openedx/app/OpenEdXApp.kt | 7 ++++ .../deeplink/BranchBrazeDeeplinkHandler.kt | 26 +++++++++++++ 3 files changed, 51 insertions(+), 21 deletions(-) create mode 100644 app/src/main/java/org/openedx/app/deeplink/BranchBrazeDeeplinkHandler.kt diff --git a/app/src/main/java/org/openedx/app/AppActivity.kt b/app/src/main/java/org/openedx/app/AppActivity.kt index c8d4c9259..e03d9f2cd 100644 --- a/app/src/main/java/org/openedx/app/AppActivity.kt +++ b/app/src/main/java/org/openedx/app/AppActivity.kt @@ -59,6 +59,20 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { private var _windowSize = WindowSize(WindowType.Compact, WindowType.Compact) + private val branchCallback = + BranchUniversalReferralInitListener { branchUniversalObject, _, error -> + if (branchUniversalObject?.contentMetadata?.customMetadata != null) { + branchLogger.i { "Branch init complete." } + branchLogger.i { branchUniversalObject.contentMetadata.customMetadata.toString() } + viewModel.makeExternalRoute( + fm = supportFragmentManager, + deepLink = DeepLink(branchUniversalObject.contentMetadata.customMetadata) + ) + } else if (error != null) { + branchLogger.e { "Branch init failed. Caused by -" + error.message } + } + } + override fun onSaveInstanceState(outState: Bundle) { outState.putInt(TOP_INSET, topInset) outState.putInt(BOTTOM_INSET, bottomInset) @@ -152,21 +166,8 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { super.onStart() if (viewModel.isBranchEnabled) { - val callback = BranchUniversalReferralInitListener { branchUniversalObject, _, error -> - if (branchUniversalObject?.contentMetadata?.customMetadata != null) { - branchLogger.i { "Branch init complete." } - branchLogger.i { branchUniversalObject.contentMetadata.customMetadata.toString() } - viewModel.makeExternalRoute( - fm = supportFragmentManager, - deepLink = DeepLink(branchUniversalObject.contentMetadata.customMetadata) - ) - } else if (error != null) { - branchLogger.e { "Branch init failed. Caused by -" + error.message } - } - } - Branch.sessionBuilder(this) - .withCallback(callback) + .withCallback(branchCallback) .withData(this.intent.data) .init() } @@ -183,13 +184,9 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { if (viewModel.isBranchEnabled) { if (intent?.getBooleanExtra(BRANCH_FORCE_NEW_SESSION, false) == true) { - Branch.sessionBuilder(this).withCallback { referringParams, error -> - if (error != null) { - branchLogger.e { error.message } - } else if (referringParams != null) { - branchLogger.i { referringParams.toString() } - } - }.reInit() + Branch.sessionBuilder(this) + .withCallback(branchCallback) + .reInit() } } } diff --git a/app/src/main/java/org/openedx/app/OpenEdXApp.kt b/app/src/main/java/org/openedx/app/OpenEdXApp.kt index 7d1b81d32..ccf20d5b2 100644 --- a/app/src/main/java/org/openedx/app/OpenEdXApp.kt +++ b/app/src/main/java/org/openedx/app/OpenEdXApp.kt @@ -3,11 +3,13 @@ package org.openedx.app import android.app.Application import com.braze.Braze import com.braze.configuration.BrazeConfig +import com.braze.ui.BrazeDeeplinkHandler import com.google.firebase.FirebaseApp import io.branch.referral.Branch import org.koin.android.ext.android.inject import org.koin.android.ext.koin.androidContext import org.koin.core.context.startKoin +import org.openedx.app.deeplink.BranchBrazeDeeplinkHandler import org.openedx.app.di.appModule import org.openedx.app.di.networkingModule import org.openedx.app.di.screenModule @@ -36,6 +38,7 @@ class OpenEdXApp : Application() { Branch.enableTestMode() Branch.enableLogging() } + Branch.expectDelayedSessionInitialization(true) Branch.getAutoInstance(this) } @@ -50,6 +53,10 @@ class OpenEdXApp : Application() { .setIsFirebaseMessagingServiceOnNewTokenRegistrationEnabled(true) .build() Braze.configure(this, brazeConfig) + + if (config.getBranchConfig().enabled) { + BrazeDeeplinkHandler.setBrazeDeeplinkHandler(BranchBrazeDeeplinkHandler()) + } } } } diff --git a/app/src/main/java/org/openedx/app/deeplink/BranchBrazeDeeplinkHandler.kt b/app/src/main/java/org/openedx/app/deeplink/BranchBrazeDeeplinkHandler.kt new file mode 100644 index 000000000..967c3768b --- /dev/null +++ b/app/src/main/java/org/openedx/app/deeplink/BranchBrazeDeeplinkHandler.kt @@ -0,0 +1,26 @@ +package org.openedx.app.deeplink + +import android.content.Context +import android.content.Intent +import android.net.Uri +import com.braze.ui.BrazeDeeplinkHandler +import com.braze.ui.actions.UriAction +import org.openedx.app.AppActivity + +internal class BranchBrazeDeeplinkHandler : BrazeDeeplinkHandler() { + override fun gotoUri(context: Context, uriAction: UriAction) { + val deeplink = uriAction.uri.toString() + + if (deeplink.contains("app.link")) { + val intent = Intent(context, AppActivity::class.java).apply { + action = Intent.ACTION_VIEW + data = Uri.parse(deeplink) + flags = Intent.FLAG_ACTIVITY_NEW_TASK + putExtra("branch_force_new_session", true) + } + context.startActivity(intent) + } else { + super.gotoUri(context, uriAction) + } + } +} From 6efdd76c62f07acbc404db377c43cba67a8e8caa Mon Sep 17 00:00:00 2001 From: Hamza Israr <71447999+HamzaIsrar12@users.noreply.github.com> Date: Tue, 9 Jul 2024 18:36:19 +0500 Subject: [PATCH 28/56] feat: Fullstory Analytics SDK Implementation (#347) * feat: Fullstory Analytics SDK Implementation We have introduced the Fullstory Analytics Provider, which includes three main methods: Identify: This method identifies the user by passing a userID (uid). Additionally, it includes a displayName for use on the Fullstory dashboard. Event: This method records custom app events. Page: This method functions similarly to a screen event, tracking page views. Fixes: LEARNER-10041 * feat: Add screen event method to the Analytics Manager Fixes: LEARNER-10041 * fix: Course Home Tabs Events Fixes: LEARNER-10041 * chore: Discovery Screen Events Fixes: LEARNER-10041 * chore: Main Dashboard Screen Events Fixes: LEARNER-10041 * chore: Auth Screen Events Fixes: LEARNER-10041 * chore: Profile Screen Events Fixes: LEARNER-10041 * chore: Course Screen Events Fixes: LEARNER-10041 * fix: PLS Banner Multiple Events Fixes: LEARNER-10041 * chore: Logistration Screen Event Fixes: LEARNER-10041 * refactor: Optimize code Fixes: LEARNER-10041 --- app/build.gradle | 16 ++++++++ .../java/org/openedx/app/AnalyticsManager.kt | 12 ++++++ .../main/java/org/openedx/app/AppAnalytics.kt | 9 +--- .../main/java/org/openedx/app/MainFragment.kt | 1 - .../java/org/openedx/app/MainViewModel.kt | 16 +++----- .../app/analytics/FirebaseAnalytics.kt | 1 + .../app/analytics/FullstoryAnalytics.kt | 41 +++++++++++++++++++ .../java/org/openedx/app/di/ScreenModule.kt | 2 +- .../auth/presentation/AuthAnalytics.kt | 13 ++++++ .../logistration/LogistrationViewModel.kt | 14 +++++++ .../presentation/signin/SignInViewModel.kt | 11 +++++ .../presentation/signup/SignUpViewModel.kt | 11 +++++ .../signin/SignInViewModelTest.kt | 7 ++++ .../signup/SignUpViewModelTest.kt | 5 +++ core/build.gradle | 4 ++ .../java/org/openedx/core/config/Config.kt | 5 +++ .../openedx/core/config/FullstoryConfig.kt | 11 +++++ .../java/org/openedx/core/ui/ComposeCommon.kt | 2 +- .../course/presentation/CourseAnalytics.kt | 1 + .../container/CourseContainerViewModel.kt | 4 +- .../presentation/dates/CourseDatesScreen.kt | 13 +++++- .../handouts/HandoutsViewModel.kt | 4 +- .../container/CourseContainerViewModelTest.kt | 16 ++++---- .../presentation/DashboardAnalytics.kt | 16 ++++++++ .../learn/presentation/LearnFragment.kt | 11 +++++ .../learn/presentation/LearnViewModel.kt | 21 ++++++++++ default_config/dev/config.yaml | 4 ++ default_config/prod/config.yaml | 4 ++ default_config/stage/config.yaml | 4 ++ .../presentation/DiscoveryAnalytics.kt | 1 + .../presentation/WebViewDiscoveryViewModel.kt | 2 +- .../presentation/info/CourseInfoViewModel.kt | 33 ++++++++++----- .../profile/presentation/ProfileAnalytics.kt | 5 +++ .../presentation/edit/EditProfileViewModel.kt | 17 ++++++++ .../edit/EditProfileViewModelTest.kt | 2 + settings.gradle | 4 ++ 36 files changed, 297 insertions(+), 46 deletions(-) create mode 100644 app/src/main/java/org/openedx/app/analytics/FullstoryAnalytics.kt create mode 100644 core/src/main/java/org/openedx/core/config/FullstoryConfig.kt diff --git a/app/build.gradle b/app/build.gradle index e0992b266..659730ff0 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -3,11 +3,15 @@ def appId = config.getOrDefault("APPLICATION_ID", "org.openedx.app") def themeDirectory = config.getOrDefault("THEME_DIRECTORY", "openedx") def firebaseConfig = config.get('FIREBASE') def firebaseEnabled = firebaseConfig?.getOrDefault('ENABLED', false) +def fullstoryConfig = config.get("FULLSTORY") +def fullstoryEnabled = fullstoryConfig?.getOrDefault('ENABLED', false) apply plugin: 'com.android.application' apply plugin: 'org.jetbrains.kotlin.android' apply plugin: 'kotlin-parcelize' apply plugin: 'kotlin-kapt' +apply plugin: 'fullstory' + if (firebaseEnabled) { apply plugin: 'com.google.gms.google-services' apply plugin: 'com.google.firebase.crashlytics' @@ -25,6 +29,18 @@ if (firebaseEnabled) { preBuild.dependsOn(removeGoogleServicesJson) } +if (fullstoryEnabled) { + def fullstoryOrgId = fullstoryConfig?.get("ORG_ID") + + fullstory { + org fullstoryOrgId + composeEnabled true + composeSelectorVersion 4 + enabledVariants 'debug|release' + logcatLevel 'error' + } +} + android { compileSdk 34 diff --git a/app/src/main/java/org/openedx/app/AnalyticsManager.kt b/app/src/main/java/org/openedx/app/AnalyticsManager.kt index 356a23459..9d8169863 100644 --- a/app/src/main/java/org/openedx/app/AnalyticsManager.kt +++ b/app/src/main/java/org/openedx/app/AnalyticsManager.kt @@ -3,6 +3,7 @@ package org.openedx.app import android.content.Context import org.openedx.app.analytics.Analytics import org.openedx.app.analytics.FirebaseAnalytics +import org.openedx.app.analytics.FullstoryAnalytics import org.openedx.app.analytics.SegmentAnalytics import org.openedx.auth.presentation.AuthAnalytics import org.openedx.core.config.Config @@ -29,10 +30,15 @@ class AnalyticsManager( if (config.getFirebaseConfig().enabled) { addAnalyticsTracker(FirebaseAnalytics(context = context)) } + val segmentConfig = config.getSegmentConfig() if (segmentConfig.enabled && segmentConfig.segmentWriteKey.isNotBlank()) { addAnalyticsTracker(SegmentAnalytics(context = context, config = config)) } + + if (config.getFullstoryConfig().isEnabled) { + addAnalyticsTracker(FullstoryAnalytics()) + } } private fun addAnalyticsTracker(analytic: Analytics) { @@ -45,6 +51,12 @@ class AnalyticsManager( } } + override fun logScreenEvent(screenName: String, params: Map) { + services.forEach { analytics -> + analytics.logScreenEvent(screenName, params) + } + } + override fun logEvent(event: String, params: Map) { services.forEach { analytics -> analytics.logEvent(event, params) diff --git a/app/src/main/java/org/openedx/app/AppAnalytics.kt b/app/src/main/java/org/openedx/app/AppAnalytics.kt index 51278ef13..a122e79c1 100644 --- a/app/src/main/java/org/openedx/app/AppAnalytics.kt +++ b/app/src/main/java/org/openedx/app/AppAnalytics.kt @@ -4,6 +4,7 @@ interface AppAnalytics { fun logoutEvent(force: Boolean) fun setUserIdForSession(userId: Long) fun logEvent(event: String, params: Map) + fun logScreenEvent(screenName: String, params: Map) } enum class AppAnalyticsEvent(val eventName: String, val biValue: String) { @@ -15,14 +16,6 @@ enum class AppAnalyticsEvent(val eventName: String, val biValue: String) { "MainDashboard:Discover", "edx.bi.app.main_dashboard.discover" ), - MY_COURSES( - "MainDashboard:My Courses", - "edx.bi.app.main_dashboard.my_course" - ), - MY_PROGRAMS( - "MainDashboard:My Programs", - "edx.bi.app.main_dashboard.my_program" - ), PROFILE( "MainDashboard:Profile", "edx.bi.app.main_dashboard.profile" diff --git a/app/src/main/java/org/openedx/app/MainFragment.kt b/app/src/main/java/org/openedx/app/MainFragment.kt index 62857ee9f..4011b3a04 100644 --- a/app/src/main/java/org/openedx/app/MainFragment.kt +++ b/app/src/main/java/org/openedx/app/MainFragment.kt @@ -46,7 +46,6 @@ class MainFragment : Fragment(R.layout.fragment_main) { binding.bottomNavView.setOnItemSelectedListener { when (it.itemId) { R.id.fragmentLearn -> { - viewModel.logMyCoursesTabClickedEvent() binding.viewPager.setCurrentItem(0, false) } diff --git a/app/src/main/java/org/openedx/app/MainViewModel.kt b/app/src/main/java/org/openedx/app/MainViewModel.kt index f3d62c04f..5cef29361 100644 --- a/app/src/main/java/org/openedx/app/MainViewModel.kt +++ b/app/src/main/java/org/openedx/app/MainViewModel.kt @@ -14,7 +14,6 @@ import org.openedx.core.BaseViewModel import org.openedx.core.config.Config import org.openedx.core.system.notifier.DiscoveryNotifier import org.openedx.core.system.notifier.NavigationToDiscovery -import org.openedx.dashboard.presentation.DashboardRouter import org.openedx.discovery.presentation.DiscoveryNavigator class MainViewModel( @@ -51,20 +50,17 @@ class MainViewModel( } fun logDiscoveryTabClickedEvent() { - logEvent(AppAnalyticsEvent.DISCOVER) - } - - fun logMyCoursesTabClickedEvent() { - logEvent(AppAnalyticsEvent.MY_COURSES) + logScreenEvent(AppAnalyticsEvent.DISCOVER) } fun logProfileTabClickedEvent() { - logEvent(AppAnalyticsEvent.PROFILE) + logScreenEvent(AppAnalyticsEvent.PROFILE) } - private fun logEvent(event: AppAnalyticsEvent) { - analytics.logEvent(event.eventName, - buildMap { + private fun logScreenEvent(event: AppAnalyticsEvent) { + analytics.logScreenEvent( + screenName = event.eventName, + params = buildMap { put(AppAnalyticsKey.NAME.key, event.biValue) } ) diff --git a/app/src/main/java/org/openedx/app/analytics/FirebaseAnalytics.kt b/app/src/main/java/org/openedx/app/analytics/FirebaseAnalytics.kt index 503f3d1ef..17d3b3b62 100644 --- a/app/src/main/java/org/openedx/app/analytics/FirebaseAnalytics.kt +++ b/app/src/main/java/org/openedx/app/analytics/FirebaseAnalytics.kt @@ -16,6 +16,7 @@ class FirebaseAnalytics(context: Context) : Analytics { } override fun logScreenEvent(screenName: String, params: Map) { + tracker.logEvent(screenName, params.toBundle()) logger.d { "Firebase Analytics log Screen Event: $screenName + $params" } } diff --git a/app/src/main/java/org/openedx/app/analytics/FullstoryAnalytics.kt b/app/src/main/java/org/openedx/app/analytics/FullstoryAnalytics.kt new file mode 100644 index 000000000..11aa26bc7 --- /dev/null +++ b/app/src/main/java/org/openedx/app/analytics/FullstoryAnalytics.kt @@ -0,0 +1,41 @@ +package org.openedx.app.analytics + +import com.fullstory.FS +import com.fullstory.FSSessionData +import org.openedx.core.utils.Logger + +class FullstoryAnalytics : Analytics { + + private val logger = Logger(TAG) + + init { + FS.setReadyListener { sessionData: FSSessionData -> + val sessionUrl = sessionData.currentSessionURL + logger.d { "FullStory Session URL is: $sessionUrl" } + } + } + + override fun logScreenEvent(screenName: String, params: Map) { + logger.d { "Page : $screenName $params" } + FS.page(screenName, params).start() + } + + override fun logEvent(eventName: String, params: Map) { + logger.d { "Event: $eventName $params" } + FS.page(eventName, params).start() + } + + override fun logUserId(userId: Long) { + logger.d { "Identify: $userId" } + FS.identify( + userId.toString(), mapOf( + DISPLAY_NAME to userId + ) + ) + } + + private companion object { + const val TAG = "FullstoryAnalytics" + private const val DISPLAY_NAME = "displayName" + } +} 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 3fb4667df..429d048b9 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -147,7 +147,7 @@ val screenModule = module { ) } viewModel { AllEnrolledCoursesViewModel(get(), get(), get(), get(), get(), get(), get()) } - viewModel { LearnViewModel(get(), get()) } + viewModel { LearnViewModel(get(), get(), get()) } factory { DiscoveryRepository(get(), get(), get()) } factory { DiscoveryInteractor(get()) } diff --git a/auth/src/main/java/org/openedx/auth/presentation/AuthAnalytics.kt b/auth/src/main/java/org/openedx/auth/presentation/AuthAnalytics.kt index e87ad9674..40125a18e 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/AuthAnalytics.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/AuthAnalytics.kt @@ -3,9 +3,14 @@ package org.openedx.auth.presentation interface AuthAnalytics { fun setUserIdForSession(userId: Long) fun logEvent(event: String, params: Map) + fun logScreenEvent(screenName: String, params: Map) } enum class AuthAnalyticsEvent(val eventName: String, val biValue: String) { + Logistration( + "Logistration", + "edx.bi.app.logistration" + ), DISCOVERY_COURSES_SEARCH( "Logistration:Courses Search", "edx.bi.app.logistration.courses_search" @@ -14,6 +19,14 @@ enum class AuthAnalyticsEvent(val eventName: String, val biValue: String) { "Logistration:Explore All Courses", "edx.bi.app.logistration.explore.all.courses" ), + SIGN_IN( + "Logistration:Sign In", + "edx.bi.app.logistration.signin" + ), + REGISTER( + "Logistration:Register", + "edx.bi.app.logistration.register" + ), REGISTER_CLICKED( "Logistration:Register Clicked", "edx.bi.app.logistration.register.clicked" diff --git a/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationViewModel.kt b/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationViewModel.kt index e48a5e8be..3306ccfa3 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationViewModel.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationViewModel.kt @@ -18,6 +18,10 @@ class LogistrationViewModel( private val discoveryTypeWebView get() = config.getDiscoveryConfig().isViewTypeWebView() + init { + logLogistrationScreenEvent() + } + fun navigateToSignIn(parentFragmentManager: FragmentManager) { router.navigateToSignIn(parentFragmentManager, courseId, null) logEvent(AuthAnalyticsEvent.SIGN_IN_CLICKED) @@ -62,4 +66,14 @@ class LogistrationViewModel( } ) } + + private fun logLogistrationScreenEvent() { + val event = AuthAnalyticsEvent.Logistration + analytics.logScreenEvent( + screenName = event.eventName, + params = buildMap { + put(AuthAnalyticsKey.NAME.key, event.biValue) + } + ) + } } diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt index 9fbd8c2fe..dd03bdaae 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt @@ -78,6 +78,7 @@ class SignInViewModel( init { collectAppUpgradeEvent() + logSignInScreenEvent() } fun login(username: String, password: String) { @@ -245,4 +246,14 @@ class SignInViewModel( } ) } + + private fun logSignInScreenEvent() { + val event = AuthAnalyticsEvent.SIGN_IN + analytics.logScreenEvent( + screenName = event.eventName, + params = buildMap { + put(AuthAnalyticsKey.NAME.key, event.biValue) + } + ) + } } diff --git a/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpViewModel.kt b/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpViewModel.kt index 42b6bf2d1..0826fca5c 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpViewModel.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpViewModel.kt @@ -73,6 +73,7 @@ class SignUpViewModel( init { collectAppUpgradeEvent() + logRegisterScreenEvent() } fun getRegistrationFields() { @@ -324,4 +325,14 @@ class SignUpViewModel( } ) } + + private fun logRegisterScreenEvent() { + val event = AuthAnalyticsEvent.REGISTER + analytics.logScreenEvent( + screenName = event.eventName, + params = buildMap { + put(AuthAnalyticsKey.NAME.key, event.biValue) + } + ) + } } diff --git a/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt b/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt index d35e34040..a46b371c8 100644 --- a/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt +++ b/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt @@ -87,6 +87,7 @@ class SignInViewModelTest { every { config.getFacebookConfig() } returns FacebookConfig() every { config.getGoogleConfig() } returns GoogleConfig() every { config.getMicrosoftConfig() } returns MicrosoftConfig() + every { analytics.logScreenEvent(any(), any()) } returns Unit } @After @@ -119,6 +120,7 @@ class SignInViewModelTest { coVerify(exactly = 0) { interactor.login(any(), any()) } verify(exactly = 0) { analytics.setUserIdForSession(any()) } verify(exactly = 1) { analytics.logEvent(any(), any()) } + verify(exactly = 1) { analytics.logScreenEvent(any(), any()) } val message = viewModel.uiMessage.value as UIMessage.SnackBarMessage val uiState = viewModel.uiState.value @@ -220,6 +222,7 @@ class SignInViewModelTest { coVerify(exactly = 0) { interactor.login(any(), any()) } verify(exactly = 0) { analytics.setUserIdForSession(any()) } verify(exactly = 1) { analytics.logEvent(any(), any()) } + verify(exactly = 1) { analytics.logScreenEvent(any(), any()) } val message = viewModel.uiMessage.value as UIMessage.SnackBarMessage val uiState = viewModel.uiState.value @@ -258,6 +261,7 @@ class SignInViewModelTest { coVerify(exactly = 1) { interactor.login(any(), any()) } verify(exactly = 1) { analytics.setUserIdForSession(any()) } verify(exactly = 2) { analytics.logEvent(any(), any()) } + verify(exactly = 1) { analytics.logScreenEvent(any(), any()) } verify(exactly = 1) { appNotifier.notifier } val uiState = viewModel.uiState.value assertFalse(uiState.showProgress) @@ -294,6 +298,7 @@ class SignInViewModelTest { coVerify(exactly = 1) { interactor.login(any(), any()) } verify(exactly = 0) { analytics.setUserIdForSession(any()) } verify(exactly = 1) { analytics.logEvent(any(), any()) } + verify(exactly = 1) { analytics.logScreenEvent(any(), any()) } verify(exactly = 1) { appNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage @@ -333,6 +338,7 @@ class SignInViewModelTest { verify(exactly = 0) { analytics.setUserIdForSession(any()) } verify(exactly = 1) { appNotifier.notifier } verify(exactly = 1) { analytics.logEvent(any(), any()) } + verify(exactly = 1) { analytics.logScreenEvent(any(), any()) } val message = viewModel.uiMessage.value as UIMessage.SnackBarMessage val uiState = viewModel.uiState.value @@ -371,6 +377,7 @@ class SignInViewModelTest { verify(exactly = 0) { analytics.setUserIdForSession(any()) } verify(exactly = 1) { appNotifier.notifier } verify(exactly = 1) { analytics.logEvent(any(), any()) } + verify(exactly = 1) { analytics.logScreenEvent(any(), any()) } val message = viewModel.uiMessage.value as UIMessage.SnackBarMessage val uiState = viewModel.uiState.value diff --git a/auth/src/test/java/org/openedx/auth/presentation/signup/SignUpViewModelTest.kt b/auth/src/test/java/org/openedx/auth/presentation/signup/SignUpViewModelTest.kt index 5be80557c..f61e1053e 100644 --- a/auth/src/test/java/org/openedx/auth/presentation/signup/SignUpViewModelTest.kt +++ b/auth/src/test/java/org/openedx/auth/presentation/signup/SignUpViewModelTest.kt @@ -119,6 +119,7 @@ class SignUpViewModelTest { every { config.getGoogleConfig() } returns GoogleConfig() every { config.getMicrosoftConfig() } returns MicrosoftConfig() every { config.getMicrosoftConfig() } returns MicrosoftConfig() + every { analytics.logScreenEvent(any(), any()) } returns Unit } @After @@ -159,6 +160,7 @@ class SignUpViewModelTest { advanceUntilIdle() coVerify(exactly = 1) { interactor.validateRegistrationFields(any()) } verify(exactly = 1) { analytics.logEvent(any(), any()) } + verify(exactly = 1) { analytics.logScreenEvent(any(), any()) } coVerify(exactly = 0) { interactor.register(any()) } coVerify(exactly = 0) { interactor.login(any(), any()) } verify(exactly = 0) { analytics.setUserIdForSession(any()) } @@ -206,6 +208,7 @@ class SignUpViewModelTest { viewModel.register() advanceUntilIdle() verify(exactly = 1) { analytics.logEvent(any(), any()) } + verify(exactly = 1) { analytics.logScreenEvent(any(), any()) } verify(exactly = 0) { analytics.setUserIdForSession(any()) } coVerify(exactly = 1) { interactor.validateRegistrationFields(any()) } coVerify(exactly = 0) { interactor.register(any()) } @@ -245,6 +248,7 @@ class SignUpViewModelTest { advanceUntilIdle() verify(exactly = 0) { analytics.setUserIdForSession(any()) } verify(exactly = 1) { analytics.logEvent(any(), any()) } + verify(exactly = 1) { analytics.logScreenEvent(any(), any()) } coVerify(exactly = 1) { interactor.validateRegistrationFields(any()) } coVerify(exactly = 0) { interactor.register(any()) } coVerify(exactly = 0) { interactor.login(any(), any()) } @@ -298,6 +302,7 @@ class SignUpViewModelTest { coVerify(exactly = 1) { interactor.register(any()) } coVerify(exactly = 1) { interactor.login(any(), any()) } verify(exactly = 2) { analytics.logEvent(any(), any()) } + verify(exactly = 1) { analytics.logScreenEvent(any(), any()) } verify(exactly = 1) { appNotifier.notifier } assertFalse(viewModel.uiState.value.validationError) diff --git a/core/build.gradle b/core/build.gradle index 2360efd4d..c18b5ad0c 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -114,6 +114,10 @@ dependencies { api "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version" api "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version" + // Fullstory + api 'com.fullstory:instrumentation-full:1.47.0@aar' + api 'com.fullstory:compose:1.47.0@aar' + // Room api "androidx.room:room-runtime:$room_version" api "androidx.room:room-ktx:$room_version" 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 57f91ef88..528ff4cc8 100644 --- a/core/src/main/java/org/openedx/core/config/Config.kt +++ b/core/src/main/java/org/openedx/core/config/Config.kt @@ -65,6 +65,10 @@ class Config(context: Context) { return getObjectOrNewInstance(SEGMENT_IO, SegmentConfig::class.java) } + fun getFullstoryConfig(): FullstoryConfig { + return getObjectOrNewInstance(FULLSTORY, FullstoryConfig::class.java) + } + fun getBrazeConfig(): BrazeConfig { return getObjectOrNewInstance(BRAZE, BrazeConfig::class.java) } @@ -158,6 +162,7 @@ class Config(context: Context) { private const val SOCIAL_AUTH_ENABLED = "SOCIAL_AUTH_ENABLED" private const val FIREBASE = "FIREBASE" private const val SEGMENT_IO = "SEGMENT_IO" + private const val FULLSTORY = "FULLSTORY" private const val BRAZE = "BRAZE" private const val FACEBOOK = "FACEBOOK" private const val GOOGLE = "GOOGLE" diff --git a/core/src/main/java/org/openedx/core/config/FullstoryConfig.kt b/core/src/main/java/org/openedx/core/config/FullstoryConfig.kt new file mode 100644 index 000000000..00bc00e81 --- /dev/null +++ b/core/src/main/java/org/openedx/core/config/FullstoryConfig.kt @@ -0,0 +1,11 @@ +package org.openedx.core.config + +import com.google.gson.annotations.SerializedName + +data class FullstoryConfig( + @SerializedName("ENABLED") + val isEnabled: Boolean = false, + + @SerializedName("ORG_ID") + private val orgId: String = "" +) 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 6c57df741..26806897f 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt @@ -1253,9 +1253,9 @@ fun RoundTabsBar( .then(border) .clickable { scope.launch { + onTabClicked(index) pagerState.scrollToPage(index) rowState.animateScrollToItem(index) - onTabClicked(index) } } .padding(horizontal = 16.dp), diff --git a/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt b/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt index 8151226c0..0dbe660e5 100644 --- a/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt +++ b/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt @@ -38,6 +38,7 @@ interface CourseAnalytics { fun finishVerticalBackClickedEvent(courseId: String, courseName: String) fun logEvent(event: String, params: Map) + fun logScreenEvent(screenName: String, params: Map) } enum class CourseAnalyticsEvent(val eventName: String, val biValue: String) { 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 97045561e..60813d29a 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 @@ -420,8 +420,8 @@ class CourseContainerViewModel( } private fun logCourseContainerEvent(event: CourseAnalyticsEvent) { - courseAnalytics.logEvent( - event = event.eventName, + courseAnalytics.logScreenEvent( + screenName = event.eventName, params = buildMap { put(CourseAnalyticsKey.NAME.key, event.biValue) put(CourseAnalyticsKey.COURSE_ID.key, courseId) 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 7381402b2..76197b93c 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 @@ -41,6 +41,7 @@ import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState @@ -212,6 +213,17 @@ private fun CourseDatesUI( HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) + val isPLSBannerAvailable = (uiState as? DatesUIState.Dates) + ?.courseDatesResult + ?.courseBanner + ?.isBannerAvailableForUserType(isSelfPaced) + + LaunchedEffect(key1 = isPLSBannerAvailable) { + if (isPLSBannerAvailable == true) { + onPLSBannerViewed() + } + } + Box( modifier = Modifier .fillMaxSize() @@ -249,7 +261,6 @@ private fun CourseDatesUI( if (courseBanner.isBannerAvailableForUserType(isSelfPaced)) { item { - onPLSBannerViewed() if (windowSize.isTablet) { CourseDatesBannerTablet( modifier = Modifier.padding(top = 16.dp), diff --git a/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsViewModel.kt b/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsViewModel.kt index 66ba39293..92aaa139d 100644 --- a/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsViewModel.kt @@ -98,8 +98,8 @@ class HandoutsViewModel( } fun logEvent(event: CourseAnalyticsEvent) { - courseAnalytics.logEvent( - event = event.eventName, + courseAnalytics.logScreenEvent( + screenName = event.eventName, params = buildMap { put(CourseAnalyticsKey.NAME.key, event.biValue) put(CourseAnalyticsKey.COURSE_ID.key, courseId) 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 938d850d2..f049e3751 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 @@ -171,12 +171,12 @@ class CourseContainerViewModelTest { ) every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseStructure(any(), any()) } throws UnknownHostException() - every { analytics.logEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } returns Unit + every { analytics.logScreenEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } returns Unit viewModel.preloadCourseStructure() advanceUntilIdle() coVerify(exactly = 1) { interactor.getCourseStructure(any(), any()) } - verify(exactly = 1) { analytics.logEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } + verify(exactly = 1) { analytics.logScreenEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } val message = viewModel.errorMessage.value assertEquals(noInternet, message) @@ -205,12 +205,12 @@ class CourseContainerViewModelTest { ) every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseStructure(any(), any()) } throws Exception() - every { analytics.logEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } returns Unit + every { analytics.logScreenEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } returns Unit viewModel.preloadCourseStructure() advanceUntilIdle() coVerify(exactly = 1) { interactor.getCourseStructure(any(), any()) } - verify(exactly = 1) { analytics.logEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } + verify(exactly = 1) { analytics.logScreenEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } val message = viewModel.errorMessage.value assertEquals(somethingWrong, message) @@ -239,12 +239,12 @@ class CourseContainerViewModelTest { ) every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseStructure(any(), any()) } returns courseStructure - every { analytics.logEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } returns Unit + every { analytics.logScreenEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } returns Unit viewModel.preloadCourseStructure() advanceUntilIdle() coVerify(exactly = 1) { interactor.getCourseStructure(any(), any()) } - verify(exactly = 1) { analytics.logEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } + verify(exactly = 1) { analytics.logScreenEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } assert(viewModel.errorMessage.value == null) assert(!viewModel.refreshing.value) @@ -272,7 +272,7 @@ class CourseContainerViewModelTest { ) every { networkConnection.isOnline() } returns false coEvery { interactor.getCourseStructure(any(), any()) } returns courseStructure - every { analytics.logEvent(any(), any()) } returns Unit + every { analytics.logScreenEvent(any(), any()) } returns Unit coEvery { courseApi.getCourseStructure(any(), any(), any(), any()) } returns courseStructureModel @@ -280,7 +280,7 @@ class CourseContainerViewModelTest { advanceUntilIdle() coVerify(exactly = 0) { courseApi.getCourseStructure(any(), any(), any(), any()) } - verify(exactly = 1) { analytics.logEvent(any(), any()) } + verify(exactly = 1) { analytics.logScreenEvent(any(), any()) } assert(viewModel.errorMessage.value == null) assert(!viewModel.refreshing.value) diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardAnalytics.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardAnalytics.kt index 6a69e7a65..cf7097a64 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardAnalytics.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardAnalytics.kt @@ -1,5 +1,21 @@ package org.openedx.dashboard.presentation interface DashboardAnalytics { + fun logScreenEvent(screenName: String, params: Map) fun dashboardCourseClickedEvent(courseId: String, courseName: String) } + +enum class DashboardAnalyticsEvent(val eventName: String, val biValue: String) { + MY_COURSES( + "MainDashboard:My Courses", + "edx.bi.app.main_dashboard.my_course" + ), + MY_PROGRAMS( + "MainDashboard:My Programs", + "edx.bi.app.main_dashboard.my_program" + ), +} + +enum class DashboardAnalyticsKey(val key: String) { + NAME("name"), +} diff --git a/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt b/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt index 1fc574f41..b1f4bbbb7 100644 --- a/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt +++ b/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt @@ -93,6 +93,17 @@ class LearnFragment : Fragment(R.layout.fragment_learn) { } binding.viewPager.adapter = adapter binding.viewPager.setUserInputEnabled(false) + + binding.viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { + override fun onPageSelected(position: Int) { + super.onPageSelected(position) + if (LearnType.COURSES.ordinal == position) { + viewModel.logMyCoursesTabClickedEvent() + } else { + viewModel.logMyProgramsTabClickedEvent() + } + } + }) } companion object { diff --git a/dashboard/src/main/java/org/openedx/learn/presentation/LearnViewModel.kt b/dashboard/src/main/java/org/openedx/learn/presentation/LearnViewModel.kt index 62ee774cb..9a2d17f1e 100644 --- a/dashboard/src/main/java/org/openedx/learn/presentation/LearnViewModel.kt +++ b/dashboard/src/main/java/org/openedx/learn/presentation/LearnViewModel.kt @@ -4,11 +4,15 @@ import androidx.fragment.app.FragmentManager import org.openedx.DashboardNavigator import org.openedx.core.BaseViewModel import org.openedx.core.config.Config +import org.openedx.dashboard.presentation.DashboardAnalytics +import org.openedx.dashboard.presentation.DashboardAnalyticsEvent +import org.openedx.dashboard.presentation.DashboardAnalyticsKey import org.openedx.dashboard.presentation.DashboardRouter class LearnViewModel( private val config: Config, private val dashboardRouter: DashboardRouter, + private val analytics: DashboardAnalytics, ) : BaseViewModel() { private val dashboardType get() = config.getDashboardConfig().getType() @@ -21,4 +25,21 @@ class LearnViewModel( val getDashboardFragment get() = DashboardNavigator(dashboardType).getDashboardFragment() val getProgramFragment get() = dashboardRouter.getProgramFragment() + + fun logMyCoursesTabClickedEvent() { + logScreenEvent(DashboardAnalyticsEvent.MY_COURSES) + } + + fun logMyProgramsTabClickedEvent() { + logScreenEvent(DashboardAnalyticsEvent.MY_PROGRAMS) + } + + private fun logScreenEvent(event: DashboardAnalyticsEvent) { + analytics.logScreenEvent( + screenName = event.eventName, + params = buildMap { + put(DashboardAnalyticsKey.NAME.key, event.biValue) + } + ) + } } diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index eee22e36d..a97d7c351 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -69,6 +69,10 @@ BRANCH: HOST: '' ALTERNATE_HOST: '' +FULLSTORY: + ENABLED: false + ORG_ID: '' + #Platform names PLATFORM_NAME: "OpenEdX" PLATFORM_FULL_NAME: "OpenEdX" diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml index eee22e36d..a97d7c351 100644 --- a/default_config/prod/config.yaml +++ b/default_config/prod/config.yaml @@ -69,6 +69,10 @@ BRANCH: HOST: '' ALTERNATE_HOST: '' +FULLSTORY: + ENABLED: false + ORG_ID: '' + #Platform names PLATFORM_NAME: "OpenEdX" PLATFORM_FULL_NAME: "OpenEdX" diff --git a/default_config/stage/config.yaml b/default_config/stage/config.yaml index eee22e36d..a97d7c351 100644 --- a/default_config/stage/config.yaml +++ b/default_config/stage/config.yaml @@ -69,6 +69,10 @@ BRANCH: HOST: '' ALTERNATE_HOST: '' +FULLSTORY: + ENABLED: false + ORG_ID: '' + #Platform names PLATFORM_NAME: "OpenEdX" PLATFORM_FULL_NAME: "OpenEdX" diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryAnalytics.kt b/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryAnalytics.kt index 4540a0d7f..23994a3fb 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryAnalytics.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryAnalytics.kt @@ -5,6 +5,7 @@ interface DiscoveryAnalytics { fun discoveryCourseSearchEvent(label: String, coursesCount: Int) fun discoveryCourseClickedEvent(courseId: String, courseName: String) fun logEvent(event: String, params: Map) + fun logScreenEvent(screenName: String, params: Map) } enum class DiscoveryAnalyticsEvent(val eventName: String, val biValue: String) { diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryViewModel.kt b/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryViewModel.kt index f86eef2b8..e786a3970 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryViewModel.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryViewModel.kt @@ -77,7 +77,7 @@ class WebViewDiscoveryViewModel( event: DiscoveryAnalyticsEvent, courseId: String, ) { - analytics.logEvent( + analytics.logScreenEvent( event.eventName, buildMap { put(DiscoveryAnalyticsKey.NAME.key, event.biValue) diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoViewModel.kt b/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoViewModel.kt index 6d41ac4b1..487027e8f 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoViewModel.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoViewModel.kt @@ -146,11 +146,11 @@ class CourseInfoViewModel( } fun courseInfoClickedEvent(courseId: String) { - logEvent(DiscoveryAnalyticsEvent.COURSE_INFO, courseId) + logScreenEvent(DiscoveryAnalyticsEvent.COURSE_INFO, courseId) } fun programInfoClickedEvent(courseId: String) { - logEvent(DiscoveryAnalyticsEvent.PROGRAM_INFO, courseId) + logScreenEvent(DiscoveryAnalyticsEvent.PROGRAM_INFO, courseId) } fun courseEnrollClickedEvent(courseId: String) { @@ -165,15 +165,26 @@ class CourseInfoViewModel( event: DiscoveryAnalyticsEvent, courseId: String, ) { - analytics.logEvent( - event.eventName, - buildMap { - put(DiscoveryAnalyticsKey.NAME.key, event.biValue) - put(DiscoveryAnalyticsKey.COURSE_ID.key, courseId) - put(DiscoveryAnalyticsKey.CATEGORY.key, CoreAnalyticsKey.DISCOVERY.key) - put(DiscoveryAnalyticsKey.CONVERSION.key, courseId) - } - ) + analytics.logEvent(event.eventName, buildEventDataMap(event, courseId)) + } + + private fun logScreenEvent( + event: DiscoveryAnalyticsEvent, + courseId: String, + ) { + analytics.logScreenEvent(event.eventName, buildEventDataMap(event, courseId)) + } + + private fun buildEventDataMap( + event: DiscoveryAnalyticsEvent, + courseId: String, + ): Map { + return buildMap { + put(DiscoveryAnalyticsKey.NAME.key, event.biValue) + put(DiscoveryAnalyticsKey.COURSE_ID.key, courseId) + put(DiscoveryAnalyticsKey.CATEGORY.key, CoreAnalyticsKey.DISCOVERY.key) + put(DiscoveryAnalyticsKey.CONVERSION.key, courseId) + } } companion object { diff --git a/profile/src/main/java/org/openedx/profile/presentation/ProfileAnalytics.kt b/profile/src/main/java/org/openedx/profile/presentation/ProfileAnalytics.kt index 2422ba505..684fc309e 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/ProfileAnalytics.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/ProfileAnalytics.kt @@ -2,9 +2,14 @@ package org.openedx.profile.presentation interface ProfileAnalytics { fun logEvent(event: String, params: Map) + fun logScreenEvent(screenName: String, params: Map) } enum class ProfileAnalyticsEvent(val eventName: String, val biValue: String) { + EDIT_PROFILE( + "Profile:Edit Profile", + "edx.bi.app.profile.edit" + ), EDIT_CLICKED( "Profile:Edit Clicked", "edx.bi.app.profile.edit.clicked" diff --git a/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileViewModel.kt index 64cf9789f..211ce2794 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileViewModel.kt @@ -67,6 +67,9 @@ class EditProfileViewModel( val showLeaveDialog: LiveData get() = _showLeaveDialog + init { + logProfileScreenEvent(ProfileAnalyticsEvent.EDIT_PROFILE) + } fun updateAccount(fields: Map) { _uiState.value = EditProfileUIState(account, true, isLimitedProfile) @@ -156,4 +159,18 @@ class EditProfileViewModel( } ) } + + private fun logProfileScreenEvent( + event: ProfileAnalyticsEvent, + params: Map = emptyMap(), + ) { + analytics.logScreenEvent( + screenName = event.eventName, + params = buildMap { + put(ProfileAnalyticsKey.NAME.key, event.biValue) + put(ProfileAnalyticsKey.CATEGORY.key, ProfileAnalyticsKey.PROFILE.key) + putAll(params) + } + ) + } } diff --git a/profile/src/test/java/org/openedx/profile/presentation/edit/EditProfileViewModelTest.kt b/profile/src/test/java/org/openedx/profile/presentation/edit/EditProfileViewModelTest.kt index bfe6bb0b3..a9b5b0c31 100644 --- a/profile/src/test/java/org/openedx/profile/presentation/edit/EditProfileViewModelTest.kt +++ b/profile/src/test/java/org/openedx/profile/presentation/edit/EditProfileViewModelTest.kt @@ -64,6 +64,7 @@ class EditProfileViewModelTest { Dispatchers.setMain(dispatcher) every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong + every { analytics.logScreenEvent(any(), any()) } returns Unit } @After @@ -172,6 +173,7 @@ class EditProfileViewModelTest { advanceUntilIdle() verify(exactly = 1) { analytics.logEvent(any(), any()) } + verify(exactly = 1) { analytics.logScreenEvent(any(), any()) } coVerify(exactly = 1) { interactor.updateAccount(any()) } coVerify(exactly = 1) { interactor.setProfileImage(any(), any()) } diff --git a/settings.gradle b/settings.gradle index 66cb04c11..8f539415d 100644 --- a/settings.gradle +++ b/settings.gradle @@ -3,6 +3,7 @@ pluginManagement { gradlePluginPortal() google() mavenCentral() + maven { url "https://maven.fullstory.com" } } buildscript { repositories { @@ -10,9 +11,11 @@ pluginManagement { maven { url = uri("https://storage.googleapis.com/r8-releases/raw") } + maven { url "https://maven.fullstory.com" } } dependencies { classpath("com.android.tools:r8:8.2.26") + classpath 'com.fullstory:gradle-plugin-local:1.47.0' } } } @@ -21,6 +24,7 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + maven { url "https://maven.fullstory.com" } maven { url "http://appboy.github.io/appboy-android-sdk/sdk" allowInsecureProtocol = true From 559c7e44fa8dfaf3cb533adaf1a4d1ec5da04efe Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk <141041606+PavloNetrebchuk@users.noreply.github.com> Date: Thu, 18 Jul 2024 10:25:28 +0300 Subject: [PATCH 29/56] feat: [FC-0047] Calendar synchronization (#330) * feat: Created calendar setting screen * feat: CalendarAccessDialog * feat: NewCalendarDialog * fix: Fixes according to PR feedback * feat: Create calendar * fix: Fixes according to PR feedback * feat: Creating calendar * feat: Creating a calendar, tryToSyncCalendar logic, CalendarUIState, start CalendarSyncService * feat: CalendarSettingsView * feat: Removed calendar logic from course home, edit calendar dialog * feat: Add events to calendar * fix: Save CourseCalendarEventEntity to DB * feat: CoursesToSyncFragment UI * feat: All Calendar sync logic * feat: DisableCalendarSyncDialogFragment * refactor: Calendar repository refactoring, minor UI fixes * refactor: Calendar Manager refactor * fix: Fixes according to code review feedback * feat: Save calendar on logout, remove on different user login * feat: Notification for CalendarSyncWorker, junit test fix * feat: removeUnenrolledCourseEvents * feat: Calendar sync state on CourseDatesScreen * fix: Crash CoursesToSyncFragment in offline mode * fix: Fixes according to QA feedback * fix: Calendar Permission crash * fix: Fixes according to PR feedback --- app/src/main/AndroidManifest.xml | 1 + .../main/java/org/openedx/app/AppActivity.kt | 4 + .../main/java/org/openedx/app/AppRouter.kt | 8 +- .../main/java/org/openedx/app/AppViewModel.kt | 2 +- .../app/data/storage/PreferencesManager.kt | 44 +- .../main/java/org/openedx/app/di/AppModule.kt | 24 +- .../java/org/openedx/app/di/ScreenModule.kt | 28 +- .../java/org/openedx/app/room/AppDatabase.kt | 8 +- .../org/openedx/app/room/DatabaseManager.kt | 26 + .../test/java/org/openedx/AppViewModelTest.kt | 6 +- .../presentation/signin/SignInViewModel.kt | 8 + .../signin/SignInViewModelTest.kt | 23 + .../java/org/openedx/core/CalendarRouter.kt | 8 + .../java/org/openedx/core/DatabaseManager.kt | 5 + .../org/openedx/core/data/api/CourseApi.kt | 6 + .../core/data/model/EnrollmentStatus.kt | 19 + .../model/room/CourseCalendarEventEntity.kt | 21 + .../model/room/CourseCalendarStateEntity.kt | 24 + .../core/data/storage/CalendarPreferences.kt | 10 + .../core/data/storage/CorePreferences.kt | 2 +- .../domain/interactor/CalendarInteractor.kt | 60 +++ .../openedx/core/domain/model/CalendarData.kt | 10 + .../core/domain/model/CourseCalendarEvent.kt | 6 + .../core/domain/model/CourseCalendarState.kt | 7 + .../core/domain/model/CourseDateBlock.kt | 20 + .../core/domain/model/EnrollmentStatus.kt | 7 + .../org/openedx/core/module/db/CalendarDao.kt | 58 +++ .../org/openedx/core/module/db/DownloadDao.kt | 9 +- .../calendarsync/CalendarSyncState.kt | 52 ++ .../core/repository/CalendarRepository.kt | 77 +++ .../openedx/core/system/CalendarManager.kt | 243 +++------- .../system/notifier/AppUpgradeNotifier.kt | 0 .../notifier/calendar/CalendarCreated.kt | 3 + .../system/notifier/calendar/CalendarEvent.kt | 3 + .../notifier/calendar/CalendarNotifier.kt | 14 + .../notifier/calendar/CalendarSyncDisabled.kt | 3 + .../notifier/calendar/CalendarSyncFailed.kt | 3 + .../notifier/calendar/CalendarSyncOffline.kt | 3 + .../notifier/calendar/CalendarSynced.kt | 3 + .../notifier/calendar/CalendarSyncing.kt | 3 + .../core/worker/CalendarSyncScheduler.kt | 39 ++ .../openedx/core/worker/CalendarSyncWorker.kt | 224 +++++++++ .../src/main/res/drawable/core_ic_book.xml | 0 core/src/main/res/values/strings.xml | 7 + .../openedx/course/data/storage/CourseDao.kt | 7 +- .../container/CourseContainerFragment.kt | 83 ---- .../container/CourseContainerViewModel.kt | 158 +------ .../presentation/dates/CourseDatesScreen.kt | 81 +++- .../dates/CourseDatesViewModel.kt | 124 ++--- .../presentation/dates/DashboardUIState.kt | 10 +- .../container/CourseContainerViewModelTest.kt | 31 +- .../dates/CourseDatesViewModelTest.kt | 75 ++- .../presentation/AllEnrolledCoursesView.kt | 2 +- .../presentation/DashboardGalleryView.kt | 4 +- .../detail/CourseDetailsViewModel.kt | 3 + .../detail/CourseDetailsViewModelTest.kt | 30 +- .../data/repository/ProfileRepository.kt | 8 +- .../profile/presentation/ProfileRouter.kt | 2 +- .../calendar/CalendarAccessDialogFragment.kt | 4 + .../presentation/calendar/CalendarFragment.kt | 240 ++-------- .../calendar/CalendarSetUpView.kt | 213 +++++++++ .../calendar/CalendarSettingsView.kt | 323 +++++++++++++ .../presentation/calendar/CalendarUIState.kt | 12 + .../calendar/CalendarViewModel.kt | 127 ++++- .../calendar/CoursesToSyncFragment.kt | 443 ++++++++++++++++++ .../calendar/CoursesToSyncUIState.kt | 11 + .../calendar/CoursesToSyncViewModel.kt | 92 ++++ .../DisableCalendarSyncDialogFragment.kt | 194 ++++++++ .../DisableCalendarSyncDialogViewModel.kt | 27 ++ .../calendar/NewCalendarDialogFragment.kt | 71 ++- .../calendar/NewCalendarDialogType.kt | 9 + .../calendar/NewCalendarDialogViewModel.kt | 62 +++ .../presentation/calendar/SyncCourseTab.kt | 12 + .../delete/DeleteProfileViewModel.kt | 4 +- .../presentation/edit/EditProfileViewModel.kt | 4 +- .../manageaccount/ManageAccountViewModel.kt | 4 +- .../presentation/profile/ProfileViewModel.kt | 4 +- .../settings/SettingsViewModel.kt | 24 +- .../system/notifier/AccountDeactivated.kt | 3 - .../profile/system/notifier/AccountUpdated.kt | 3 - .../profile/system/notifier/ProfileEvent.kt | 3 - .../notifier/account/AccountDeactivated.kt | 5 + .../system/notifier/account/AccountUpdated.kt | 5 + .../system/notifier/profile/ProfileEvent.kt | 3 + .../notifier/{ => profile}/ProfileNotifier.kt | 5 +- profile/src/main/res/values/strings.xml | 18 + .../edit/EditProfileViewModelTest.kt | 30 +- .../profile/ProfileViewModelTest.kt | 4 +- 88 files changed, 2826 insertions(+), 882 deletions(-) create mode 100644 app/src/main/java/org/openedx/app/room/DatabaseManager.kt create mode 100644 core/src/main/java/org/openedx/core/CalendarRouter.kt create mode 100644 core/src/main/java/org/openedx/core/DatabaseManager.kt create mode 100644 core/src/main/java/org/openedx/core/data/model/EnrollmentStatus.kt create mode 100644 core/src/main/java/org/openedx/core/data/model/room/CourseCalendarEventEntity.kt create mode 100644 core/src/main/java/org/openedx/core/data/model/room/CourseCalendarStateEntity.kt create mode 100644 core/src/main/java/org/openedx/core/data/storage/CalendarPreferences.kt create mode 100644 core/src/main/java/org/openedx/core/domain/interactor/CalendarInteractor.kt create mode 100644 core/src/main/java/org/openedx/core/domain/model/CalendarData.kt create mode 100644 core/src/main/java/org/openedx/core/domain/model/CourseCalendarEvent.kt create mode 100644 core/src/main/java/org/openedx/core/domain/model/CourseCalendarState.kt create mode 100644 core/src/main/java/org/openedx/core/domain/model/EnrollmentStatus.kt create mode 100644 core/src/main/java/org/openedx/core/module/db/CalendarDao.kt create mode 100644 core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncState.kt create mode 100644 core/src/main/java/org/openedx/core/repository/CalendarRepository.kt create mode 100644 core/src/main/java/org/openedx/core/system/notifier/AppUpgradeNotifier.kt create mode 100644 core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarCreated.kt create mode 100644 core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarEvent.kt create mode 100644 core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarNotifier.kt create mode 100644 core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarSyncDisabled.kt create mode 100644 core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarSyncFailed.kt create mode 100644 core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarSyncOffline.kt create mode 100644 core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarSynced.kt create mode 100644 core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarSyncing.kt create mode 100644 core/src/main/java/org/openedx/core/worker/CalendarSyncScheduler.kt create mode 100644 core/src/main/java/org/openedx/core/worker/CalendarSyncWorker.kt rename dashboard/src/main/res/drawable/dashboard_ic_book.xml => core/src/main/res/drawable/core_ic_book.xml (100%) create mode 100644 profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarSetUpView.kt create mode 100644 profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarSettingsView.kt create mode 100644 profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarUIState.kt create mode 100644 profile/src/main/java/org/openedx/profile/presentation/calendar/CoursesToSyncFragment.kt create mode 100644 profile/src/main/java/org/openedx/profile/presentation/calendar/CoursesToSyncUIState.kt create mode 100644 profile/src/main/java/org/openedx/profile/presentation/calendar/CoursesToSyncViewModel.kt create mode 100644 profile/src/main/java/org/openedx/profile/presentation/calendar/DisableCalendarSyncDialogFragment.kt create mode 100644 profile/src/main/java/org/openedx/profile/presentation/calendar/DisableCalendarSyncDialogViewModel.kt create mode 100644 profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogType.kt create mode 100644 profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogViewModel.kt create mode 100644 profile/src/main/java/org/openedx/profile/presentation/calendar/SyncCourseTab.kt delete mode 100644 profile/src/main/java/org/openedx/profile/system/notifier/AccountDeactivated.kt delete mode 100644 profile/src/main/java/org/openedx/profile/system/notifier/AccountUpdated.kt delete mode 100644 profile/src/main/java/org/openedx/profile/system/notifier/ProfileEvent.kt create mode 100644 profile/src/main/java/org/openedx/profile/system/notifier/account/AccountDeactivated.kt create mode 100644 profile/src/main/java/org/openedx/profile/system/notifier/account/AccountUpdated.kt create mode 100644 profile/src/main/java/org/openedx/profile/system/notifier/profile/ProfileEvent.kt rename profile/src/main/java/org/openedx/profile/system/notifier/{ => profile}/ProfileNotifier.kt (70%) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a3921ac64..831fe4a86 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -114,6 +114,7 @@ + diff --git a/app/src/main/java/org/openedx/app/AppActivity.kt b/app/src/main/java/org/openedx/app/AppActivity.kt index e03d9f2cd..c12e23bf8 100644 --- a/app/src/main/java/org/openedx/app/AppActivity.kt +++ b/app/src/main/java/org/openedx/app/AppActivity.kt @@ -29,6 +29,7 @@ import org.openedx.core.presentation.global.WindowSizeHolder import org.openedx.core.ui.WindowSize import org.openedx.core.ui.WindowType import org.openedx.core.utils.Logger +import org.openedx.core.worker.CalendarSyncScheduler import org.openedx.profile.presentation.ProfileRouter import org.openedx.whatsnew.WhatsNewManager import org.openedx.whatsnew.presentation.whatsnew.WhatsNewFragment @@ -50,6 +51,7 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { private val whatsNewManager by inject() private val corePreferencesManager by inject() private val profileRouter by inject() + private val calendarSyncScheduler by inject() private val branchLogger = Logger(BRANCH_TAG) @@ -160,6 +162,8 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { viewModel.logoutUser.observe(this) { profileRouter.restartApp(supportFragmentManager, viewModel.isLogistrationEnabled) } + + calendarSyncScheduler.scheduleDailySync() } override fun onStart() { diff --git a/app/src/main/java/org/openedx/app/AppRouter.kt b/app/src/main/java/org/openedx/app/AppRouter.kt index 99eb919dc..0b64fb94f 100644 --- a/app/src/main/java/org/openedx/app/AppRouter.kt +++ b/app/src/main/java/org/openedx/app/AppRouter.kt @@ -8,6 +8,7 @@ import org.openedx.auth.presentation.logistration.LogistrationFragment import org.openedx.auth.presentation.restore.RestorePasswordFragment import org.openedx.auth.presentation.signin.SignInFragment import org.openedx.auth.presentation.signup.SignUpFragment +import org.openedx.core.CalendarRouter import org.openedx.core.FragmentViewType import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.presentation.global.app_upgrade.AppUpgradeRouter @@ -46,6 +47,7 @@ import org.openedx.profile.domain.model.Account import org.openedx.profile.presentation.ProfileRouter import org.openedx.profile.presentation.anothersaccount.AnothersProfileFragment import org.openedx.profile.presentation.calendar.CalendarFragment +import org.openedx.profile.presentation.calendar.CoursesToSyncFragment import org.openedx.profile.presentation.delete.DeleteProfileFragment import org.openedx.profile.presentation.edit.EditProfileFragment import org.openedx.profile.presentation.manageaccount.ManageAccountFragment @@ -56,7 +58,7 @@ import org.openedx.whatsnew.WhatsNewRouter import org.openedx.whatsnew.presentation.whatsnew.WhatsNewFragment class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, DiscussionRouter, - ProfileRouter, AppUpgradeRouter, WhatsNewRouter { + ProfileRouter, AppUpgradeRouter, WhatsNewRouter, CalendarRouter { //region AuthRouter override fun navigateToMain( @@ -411,6 +413,10 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di override fun navigateToCalendarSettings(fm: FragmentManager) { replaceFragmentWithBackStack(fm, CalendarFragment()) } + + override fun navigateToCoursesToSync(fm: FragmentManager) { + replaceFragmentWithBackStack(fm, CoursesToSyncFragment()) + } //endregion fun getVisibleFragment(fm: FragmentManager): Fragment? { diff --git a/app/src/main/java/org/openedx/app/AppViewModel.kt b/app/src/main/java/org/openedx/app/AppViewModel.kt index 20b3b0c97..43faf506f 100644 --- a/app/src/main/java/org/openedx/app/AppViewModel.kt +++ b/app/src/main/java/org/openedx/app/AppViewModel.kt @@ -104,7 +104,7 @@ class AppViewModel( if (System.currentTimeMillis() - logoutHandledAt > 5000) { if (event.isForced) { logoutHandledAt = System.currentTimeMillis() - preferencesManager.clear() + preferencesManager.clearCorePreferences() withContext(dispatcher) { room.clearAllTables() } diff --git a/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt b/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt index 473340beb..ae36968d2 100644 --- a/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt +++ b/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt @@ -4,19 +4,21 @@ import android.content.Context import com.google.gson.Gson import org.openedx.app.BuildConfig import org.openedx.core.data.model.User +import org.openedx.core.data.storage.CalendarPreferences import org.openedx.core.data.storage.CorePreferences import org.openedx.core.data.storage.InAppReviewPreferences import org.openedx.core.domain.model.AppConfig import org.openedx.core.domain.model.VideoQuality import org.openedx.core.domain.model.VideoSettings import org.openedx.core.extension.replaceSpace +import org.openedx.core.system.CalendarManager import org.openedx.course.data.storage.CoursePreferences import org.openedx.profile.data.model.Account import org.openedx.profile.data.storage.ProfilePreferences import org.openedx.whatsnew.data.storage.WhatsNewPreferences class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences, - WhatsNewPreferences, InAppReviewPreferences, CoursePreferences { + WhatsNewPreferences, InAppReviewPreferences, CoursePreferences, CalendarPreferences { private val sharedPreferences = context.getSharedPreferences(BuildConfig.APPLICATION_ID, Context.MODE_PRIVATE) @@ -37,7 +39,7 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences }.apply() } - private fun getLong(key: String): Long = sharedPreferences.getLong(key, 0L) + private fun getLong(key: String, defValue: Long = 0): Long = sharedPreferences.getLong(key, defValue) private fun saveBoolean(key: String, value: Boolean) { sharedPreferences.edit().apply { @@ -49,7 +51,7 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences return sharedPreferences.getBoolean(key, defValue) } - override fun clear() { + override fun clearCorePreferences() { sharedPreferences.edit().apply { remove(ACCESS_TOKEN) remove(REFRESH_TOKEN) @@ -59,6 +61,14 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences }.apply() } + override fun clearCalendarPreferences() { + sharedPreferences.edit().apply { + remove(CALENDAR_ID) + remove(IS_CALENDAR_SYNC_ENABLED) + remove(HIDE_INACTIVE_COURSES) + }.apply() + } + override var accessToken: String set(value) { saveString(ACCESS_TOKEN, value) @@ -83,6 +93,12 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences } get() = getLong(EXPIRES_IN) + override var calendarId: Long + set(value) { + saveLong(CALENDAR_ID, value) + } + get() = getLong(CALENDAR_ID, CalendarManager.CALENDAR_DOES_NOT_EXIST) + override var user: User? set(value) { val userJson = Gson().toJson(value) @@ -165,6 +181,24 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences } get() = getBoolean(RESET_APP_DIRECTORY, true) + override var isCalendarSyncEnabled: Boolean + set(value) { + saveBoolean(IS_CALENDAR_SYNC_ENABLED, value) + } + get() = getBoolean(IS_CALENDAR_SYNC_ENABLED, true) + + override var calendarUser: String + set(value) { + saveString(CALENDAR_USER, value) + } + get() = getString(CALENDAR_USER) + + override var isHideInactiveCourses: Boolean + set(value) { + saveBoolean(HIDE_INACTIVE_COURSES, value) + } + get() = getBoolean(HIDE_INACTIVE_COURSES, true) + override fun setCalendarSyncEventsDialogShown(courseName: String) { saveBoolean(courseName.replaceSpace("_"), true) } @@ -186,6 +220,10 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences private const val VIDEO_SETTINGS_STREAMING_QUALITY = "video_settings_streaming_quality" private const val VIDEO_SETTINGS_DOWNLOAD_QUALITY = "video_settings_download_quality" private const val APP_CONFIG = "app_config" + private const val CALENDAR_ID = "CALENDAR_ID" private const val RESET_APP_DIRECTORY = "reset_app_directory" + private const val IS_CALENDAR_SYNC_ENABLED = "IS_CALENDAR_SYNC_ENABLED" + private const val HIDE_INACTIVE_COURSES = "HIDE_INACTIVE_COURSES" + private const val CALENDAR_USER = "CALENDAR_USER" } } 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 9e3a1709d..795049d31 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -12,12 +12,13 @@ import org.koin.core.qualifier.named import org.koin.dsl.module import org.openedx.app.AnalyticsManager import org.openedx.app.AppAnalytics -import org.openedx.app.deeplink.DeepLinkRouter import org.openedx.app.AppRouter import org.openedx.app.BuildConfig import org.openedx.app.data.storage.PreferencesManager +import org.openedx.app.deeplink.DeepLinkRouter import org.openedx.app.room.AppDatabase import org.openedx.app.room.DATABASE_NAME +import org.openedx.app.room.DatabaseManager import org.openedx.auth.presentation.AgreementProvider import org.openedx.auth.presentation.AuthAnalytics import org.openedx.auth.presentation.AuthRouter @@ -25,9 +26,11 @@ import org.openedx.auth.presentation.sso.FacebookAuthHelper import org.openedx.auth.presentation.sso.GoogleAuthHelper import org.openedx.auth.presentation.sso.MicrosoftAuthHelper import org.openedx.auth.presentation.sso.OAuthHelper +import org.openedx.core.CalendarRouter import org.openedx.core.ImageProcessor import org.openedx.core.config.Config import org.openedx.core.data.model.CourseEnrollments +import org.openedx.core.data.storage.CalendarPreferences import org.openedx.core.data.storage.CorePreferences import org.openedx.core.data.storage.InAppReviewPreferences import org.openedx.core.module.DownloadWorkerController @@ -48,7 +51,9 @@ 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.system.notifier.app.AppNotifier +import org.openedx.core.system.notifier.calendar.CalendarNotifier import org.openedx.core.utils.FileUtil +import org.openedx.core.worker.CalendarSyncScheduler import org.openedx.course.data.storage.CoursePreferences import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseRouter @@ -62,11 +67,12 @@ import org.openedx.discussion.system.notifier.DiscussionNotifier import org.openedx.profile.data.storage.ProfilePreferences import org.openedx.profile.presentation.ProfileAnalytics import org.openedx.profile.presentation.ProfileRouter -import org.openedx.profile.system.notifier.ProfileNotifier +import org.openedx.profile.system.notifier.profile.ProfileNotifier import org.openedx.whatsnew.WhatsNewManager import org.openedx.whatsnew.WhatsNewRouter import org.openedx.whatsnew.data.storage.WhatsNewPreferences import org.openedx.whatsnew.presentation.WhatsNewAnalytics +import org.openedx.core.DatabaseManager as IDatabaseManager val appModule = module { @@ -77,11 +83,14 @@ val appModule = module { single { get() } single { get() } single { get() } + single { get() } single { ResourceManager(get()) } single { AppCookieManager(get(), get()) } single { ReviewManagerFactory.create(get()) } - single { CalendarManager(get(), get(), get()) } + single { CalendarManager(get(), get()) } + single { DatabaseManager(get(), get(), get(), get()) } + single { get() } single { ImageProcessor(get()) } @@ -98,6 +107,7 @@ val appModule = module { single { DownloadNotifier() } single { VideoNotifier() } single { DiscoveryNotifier() } + single { CalendarNotifier() } single { AppRouter() } single { get() } @@ -109,6 +119,7 @@ val appModule = module { single { get() } single { get() } single { DeepLinkRouter(get(), get(), get(), get(), get(), get()) } + single { get() } single { NetworkConnection(get()) } @@ -150,6 +161,11 @@ val appModule = module { room.downloadDao() } + single { + val room = get() + room.calendarDao() + } + single { FileDownloader() } @@ -184,4 +200,6 @@ val appModule = module { factory { OAuthHelper(get(), get(), get()) } factory { FileUtil(get()) } + + single { CalendarSyncScheduler(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 429d048b9..ae550922c 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -12,8 +12,10 @@ import org.openedx.auth.presentation.restore.RestorePasswordViewModel import org.openedx.auth.presentation.signin.SignInViewModel import org.openedx.auth.presentation.signup.SignUpViewModel import org.openedx.core.Validator +import org.openedx.core.domain.interactor.CalendarInteractor import org.openedx.core.presentation.dialog.selectorbottomsheet.SelectDialogViewModel import org.openedx.core.presentation.settings.video.VideoQualityViewModel +import org.openedx.core.repository.CalendarRepository import org.openedx.core.ui.WindowSize import org.openedx.course.data.repository.CourseRepository import org.openedx.course.domain.interactor.CourseInteractor @@ -58,6 +60,9 @@ import org.openedx.profile.domain.interactor.ProfileInteractor import org.openedx.profile.domain.model.Account import org.openedx.profile.presentation.anothersaccount.AnothersProfileViewModel import org.openedx.profile.presentation.calendar.CalendarViewModel +import org.openedx.profile.presentation.calendar.CoursesToSyncViewModel +import org.openedx.profile.presentation.calendar.DisableCalendarSyncDialogViewModel +import org.openedx.profile.presentation.calendar.NewCalendarDialogViewModel import org.openedx.profile.presentation.delete.DeleteProfileViewModel import org.openedx.profile.presentation.edit.EditProfileViewModel import org.openedx.profile.presentation.manageaccount.ManageAccountViewModel @@ -109,6 +114,8 @@ val screenModule = module { get(), get(), get(), + get(), + get(), courseId, infoType, ) @@ -190,14 +197,21 @@ val screenModule = module { get(), get(), get(), - get() + get(), + get(), ) } viewModel { ManageAccountViewModel(get(), get(), get(), get(), get()) } - viewModel { CalendarViewModel(get()) } + viewModel { CalendarViewModel(get(), get(), get(), get(), get(), get(), get()) } + viewModel { CoursesToSyncViewModel(get(), get(), get(), get()) } + viewModel { NewCalendarDialogViewModel(get(), get(), get(), get(), get(), get()) } + viewModel { DisableCalendarSyncDialogViewModel(get(), get(), get(), get()) } + factory { CalendarRepository(get(), get(), get()) } + factory { CalendarInteractor(get()) } single { CourseRepository(get(), get(), get(), get(), get()) } factory { CourseInteractor(get()) } + viewModel { (pathId: String, infoType: String) -> CourseInfoViewModel( pathId, @@ -221,7 +235,8 @@ val screenModule = module { get(), get(), get(), - get() + get(), + get(), ) } viewModel { (courseId: String, courseTitle: String, enrollmentMode: String, resumeBlockId: String) -> @@ -239,7 +254,6 @@ val screenModule = module { get(), get(), get(), - get(), get() ) } @@ -327,10 +341,9 @@ val screenModule = module { get(), ) } - viewModel { (courseId: String, courseTitle: String, enrollmentMode: String) -> + viewModel { (courseId: String, enrollmentMode: String) -> CourseDatesViewModel( courseId, - courseTitle, enrollmentMode, get(), get(), @@ -339,7 +352,8 @@ val screenModule = module { get(), get(), get(), - get() + get(), + get(), ) } viewModel { (courseId: String, handoutsType: String) -> diff --git a/app/src/main/java/org/openedx/app/room/AppDatabase.kt b/app/src/main/java/org/openedx/app/room/AppDatabase.kt index be320bae7..1728dfe9b 100644 --- a/app/src/main/java/org/openedx/app/room/AppDatabase.kt +++ b/app/src/main/java/org/openedx/app/room/AppDatabase.kt @@ -3,8 +3,11 @@ package org.openedx.app.room import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters +import org.openedx.core.data.model.room.CourseCalendarEventEntity +import org.openedx.core.data.model.room.CourseCalendarStateEntity import org.openedx.core.data.model.room.CourseStructureEntity import org.openedx.core.data.model.room.discovery.EnrolledCourseEntity +import org.openedx.core.module.db.CalendarDao import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.db.DownloadModelEntity import org.openedx.course.data.storage.CourseConverter @@ -22,7 +25,9 @@ const val DATABASE_NAME = "OpenEdX_db" CourseEntity::class, EnrolledCourseEntity::class, CourseStructureEntity::class, - DownloadModelEntity::class + DownloadModelEntity::class, + CourseCalendarEventEntity::class, + CourseCalendarStateEntity::class ], version = DATABASE_VERSION, exportSchema = false @@ -33,4 +38,5 @@ abstract class AppDatabase : RoomDatabase() { abstract fun courseDao(): CourseDao abstract fun dashboardDao(): DashboardDao abstract fun downloadDao(): DownloadDao + abstract fun calendarDao(): CalendarDao } diff --git a/app/src/main/java/org/openedx/app/room/DatabaseManager.kt b/app/src/main/java/org/openedx/app/room/DatabaseManager.kt new file mode 100644 index 000000000..f373e0e42 --- /dev/null +++ b/app/src/main/java/org/openedx/app/room/DatabaseManager.kt @@ -0,0 +1,26 @@ +package org.openedx.app.room + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.openedx.core.DatabaseManager +import org.openedx.core.module.db.DownloadDao +import org.openedx.course.data.storage.CourseDao +import org.openedx.dashboard.data.DashboardDao +import org.openedx.discovery.data.storage.DiscoveryDao + +class DatabaseManager( + private val courseDao: CourseDao, + private val dashboardDao: DashboardDao, + private val downloadDao: DownloadDao, + private val discoveryDao: DiscoveryDao +) : DatabaseManager { + override fun clearTables() { + CoroutineScope(Dispatchers.Main).launch { + courseDao.clearCachedData() + dashboardDao.clearCachedData() + downloadDao.clearCachedData() + discoveryDao.clearCachedData() + } + } +} diff --git a/app/src/test/java/org/openedx/AppViewModelTest.kt b/app/src/test/java/org/openedx/AppViewModelTest.kt index 87a34e790..6da7a144c 100644 --- a/app/src/test/java/org/openedx/AppViewModelTest.kt +++ b/app/src/test/java/org/openedx/AppViewModelTest.kt @@ -96,7 +96,7 @@ class AppViewModelTest { every { notifier.notifier } returns flow { emit(LogoutEvent(true)) } - every { preferencesManager.clear() } returns Unit + every { preferencesManager.clearCorePreferences() } returns Unit every { analytics.setUserIdForSession(any()) } returns Unit every { preferencesManager.user } returns user every { room.clearAllTables() } returns Unit @@ -133,7 +133,7 @@ class AppViewModelTest { emit(LogoutEvent(true)) emit(LogoutEvent(true)) } - every { preferencesManager.clear() } returns Unit + every { preferencesManager.clearCorePreferences() } returns Unit every { analytics.setUserIdForSession(any()) } returns Unit every { preferencesManager.user } returns user every { room.clearAllTables() } returns Unit @@ -161,7 +161,7 @@ class AppViewModelTest { advanceUntilIdle() verify(exactly = 1) { analytics.logoutEvent(true) } - verify(exactly = 1) { preferencesManager.clear() } + verify(exactly = 1) { preferencesManager.clearCorePreferences() } verify(exactly = 1) { analytics.setUserIdForSession(any()) } verify(exactly = 1) { preferencesManager.user } verify(exactly = 1) { room.clearAllTables() } diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt index dd03bdaae..53b42f46d 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt @@ -26,7 +26,9 @@ import org.openedx.core.SingleEventLiveData import org.openedx.core.UIMessage import org.openedx.core.Validator import org.openedx.core.config.Config +import org.openedx.core.data.storage.CalendarPreferences import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.interactor.CalendarInteractor import org.openedx.core.domain.model.createHonorCodeField import org.openedx.core.extension.isInternetError import org.openedx.core.presentation.global.WhatsNewGlobalManager @@ -48,6 +50,8 @@ class SignInViewModel( private val oAuthHelper: OAuthHelper, private val router: AuthRouter, private val whatsNewGlobalManager: WhatsNewGlobalManager, + private val calendarPreferences: CalendarPreferences, + private val calendarInteractor: CalendarInteractor, agreementProvider: AgreementProvider, config: Config, val courseId: String?, @@ -100,6 +104,10 @@ class SignInViewModel( interactor.login(username, password) _uiState.update { it.copy(loginSuccess = true) } setUserId() + if (calendarPreferences.calendarUser != username) { + calendarPreferences.clearCalendarPreferences() + calendarInteractor.clearCalendarCachedData() + } logEvent( AuthAnalyticsEvent.SIGN_IN_SUCCESS, buildMap { diff --git a/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt b/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt index a46b371c8..f991db3ad 100644 --- a/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt +++ b/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt @@ -34,7 +34,9 @@ import org.openedx.core.config.FacebookConfig import org.openedx.core.config.GoogleConfig import org.openedx.core.config.MicrosoftConfig import org.openedx.core.data.model.User +import org.openedx.core.data.storage.CalendarPreferences import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.interactor.CalendarInteractor import org.openedx.core.presentation.global.WhatsNewGlobalManager import org.openedx.core.system.EdxError import org.openedx.core.system.ResourceManager @@ -63,6 +65,8 @@ class SignInViewModelTest { private val oAuthHelper = mockk() private val router = mockk() private val whatsNewGlobalManager = mockk() + private val calendarInteractor = mockk() + private val calendarPreferences = mockk() private val invalidCredential = "Invalid credentials" private val noInternet = "Slow or no internet connection" @@ -87,6 +91,9 @@ class SignInViewModelTest { every { config.getFacebookConfig() } returns FacebookConfig() every { config.getGoogleConfig() } returns GoogleConfig() every { config.getMicrosoftConfig() } returns MicrosoftConfig() + every { calendarPreferences.calendarUser } returns "" + every { calendarPreferences.clearCalendarPreferences() } returns Unit + coEvery { calendarInteractor.clearCalendarCachedData() } returns Unit every { analytics.logScreenEvent(any(), any()) } returns Unit } @@ -115,6 +122,8 @@ class SignInViewModelTest { whatsNewGlobalManager = whatsNewGlobalManager, courseId = "", infoType = "", + calendarInteractor = calendarInteractor, + calendarPreferences = calendarPreferences ) viewModel.login("", "") coVerify(exactly = 0) { interactor.login(any(), any()) } @@ -149,6 +158,8 @@ class SignInViewModelTest { whatsNewGlobalManager = whatsNewGlobalManager, courseId = "", infoType = "", + calendarInteractor = calendarInteractor, + calendarPreferences = calendarPreferences ) viewModel.login("acc@test.o", "") coVerify(exactly = 0) { interactor.login(any(), any()) } @@ -183,6 +194,8 @@ class SignInViewModelTest { whatsNewGlobalManager = whatsNewGlobalManager, courseId = "", infoType = "", + calendarInteractor = calendarInteractor, + calendarPreferences = calendarPreferences ) viewModel.login("acc@test.org", "") @@ -216,6 +229,8 @@ class SignInViewModelTest { whatsNewGlobalManager = whatsNewGlobalManager, courseId = "", infoType = "", + calendarInteractor = calendarInteractor, + calendarPreferences = calendarPreferences ) viewModel.login("acc@test.org", "ed") @@ -253,6 +268,8 @@ class SignInViewModelTest { whatsNewGlobalManager = whatsNewGlobalManager, courseId = "", infoType = "", + calendarInteractor = calendarInteractor, + calendarPreferences = calendarPreferences ) coEvery { interactor.login("acc@test.org", "edx") } returns Unit viewModel.login("acc@test.org", "edx") @@ -290,6 +307,8 @@ class SignInViewModelTest { whatsNewGlobalManager = whatsNewGlobalManager, courseId = "", infoType = "", + calendarInteractor = calendarInteractor, + calendarPreferences = calendarPreferences ) coEvery { interactor.login("acc@test.org", "edx") } throws UnknownHostException() viewModel.login("acc@test.org", "edx") @@ -329,6 +348,8 @@ class SignInViewModelTest { whatsNewGlobalManager = whatsNewGlobalManager, courseId = "", infoType = "", + calendarInteractor = calendarInteractor, + calendarPreferences = calendarPreferences ) coEvery { interactor.login("acc@test.org", "edx") } throws EdxError.InvalidGrantException() viewModel.login("acc@test.org", "edx") @@ -368,6 +389,8 @@ class SignInViewModelTest { whatsNewGlobalManager = whatsNewGlobalManager, courseId = "", infoType = "", + calendarInteractor = calendarInteractor, + calendarPreferences = calendarPreferences ) coEvery { interactor.login("acc@test.org", "edx") } throws IllegalStateException() viewModel.login("acc@test.org", "edx") diff --git a/core/src/main/java/org/openedx/core/CalendarRouter.kt b/core/src/main/java/org/openedx/core/CalendarRouter.kt new file mode 100644 index 000000000..1969ca860 --- /dev/null +++ b/core/src/main/java/org/openedx/core/CalendarRouter.kt @@ -0,0 +1,8 @@ +package org.openedx.core + +import androidx.fragment.app.FragmentManager + +interface CalendarRouter { + + fun navigateToCalendarSettings(fm: FragmentManager) +} diff --git a/core/src/main/java/org/openedx/core/DatabaseManager.kt b/core/src/main/java/org/openedx/core/DatabaseManager.kt new file mode 100644 index 000000000..d7bc7d025 --- /dev/null +++ b/core/src/main/java/org/openedx/core/DatabaseManager.kt @@ -0,0 +1,5 @@ +package org.openedx.core + +interface DatabaseManager { + fun clearTables() +} 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 6d30a9044..fab5d924b 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 @@ -7,6 +7,7 @@ import org.openedx.core.data.model.CourseDates import org.openedx.core.data.model.CourseDatesBannerInfo import org.openedx.core.data.model.CourseEnrollments import org.openedx.core.data.model.CourseStructureModel +import org.openedx.core.data.model.EnrollmentStatus import org.openedx.core.data.model.HandoutsModel import org.openedx.core.data.model.ResetCourseDates import retrofit2.http.Body @@ -76,4 +77,9 @@ interface CourseApi { @Query("status") status: String? = null, @Query("requested_fields") fields: List = emptyList() ): CourseEnrollments + + @GET("/api/mobile/v1/users/{username}/enrollments_status/") + suspend fun getEnrollmentsStatus( + @Path("username") username: String + ): List } diff --git a/core/src/main/java/org/openedx/core/data/model/EnrollmentStatus.kt b/core/src/main/java/org/openedx/core/data/model/EnrollmentStatus.kt new file mode 100644 index 000000000..f5535879e --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/EnrollmentStatus.kt @@ -0,0 +1,19 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.domain.model.EnrollmentStatus + +data class EnrollmentStatus( + @SerializedName("course_id") + val courseId: String?, + @SerializedName("course_name") + val courseName: String?, + @SerializedName("is_active") + val isActive: Boolean? +) { + fun mapToDomain() = EnrollmentStatus( + courseId = courseId ?: "", + courseName = courseName ?: "", + isActive = isActive ?: false + ) +} diff --git a/core/src/main/java/org/openedx/core/data/model/room/CourseCalendarEventEntity.kt b/core/src/main/java/org/openedx/core/data/model/room/CourseCalendarEventEntity.kt new file mode 100644 index 000000000..62f3c30b4 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/room/CourseCalendarEventEntity.kt @@ -0,0 +1,21 @@ +package org.openedx.core.data.model.room + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import org.openedx.core.domain.model.CourseCalendarEvent + +@Entity(tableName = "course_calendar_event_table") +data class CourseCalendarEventEntity( + @PrimaryKey + @ColumnInfo("event_id") + val eventId: Long, + @ColumnInfo("course_id") + val courseId: String +) { + + fun mapToDomain() = CourseCalendarEvent( + courseId = courseId, + eventId = eventId + ) +} diff --git a/core/src/main/java/org/openedx/core/data/model/room/CourseCalendarStateEntity.kt b/core/src/main/java/org/openedx/core/data/model/room/CourseCalendarStateEntity.kt new file mode 100644 index 000000000..e2c39991c --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/room/CourseCalendarStateEntity.kt @@ -0,0 +1,24 @@ +package org.openedx.core.data.model.room + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import org.openedx.core.domain.model.CourseCalendarState + +@Entity(tableName = "course_calendar_state_table") +data class CourseCalendarStateEntity( + @PrimaryKey + @ColumnInfo("course_id") + val courseId: String, + @ColumnInfo("checksum") + val checksum: Int = 0, + @ColumnInfo("is_course_sync_enabled") + val isCourseSyncEnabled: Boolean, +) { + + fun mapToDomain() = CourseCalendarState( + checksum = checksum, + courseId = courseId, + isCourseSyncEnabled = isCourseSyncEnabled + ) +} diff --git a/core/src/main/java/org/openedx/core/data/storage/CalendarPreferences.kt b/core/src/main/java/org/openedx/core/data/storage/CalendarPreferences.kt new file mode 100644 index 000000000..91e38b35c --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/storage/CalendarPreferences.kt @@ -0,0 +1,10 @@ +package org.openedx.core.data.storage + +interface CalendarPreferences { + var calendarId: Long + var calendarUser: String + var isCalendarSyncEnabled: Boolean + var isHideInactiveCourses: Boolean + + fun clearCalendarPreferences() +} diff --git a/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt b/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt index 29495bae8..7792fb4a4 100644 --- a/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt +++ b/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt @@ -14,5 +14,5 @@ interface CorePreferences { var appConfig: AppConfig var canResetAppDirectory: Boolean - fun clear() + fun clearCorePreferences() } diff --git a/core/src/main/java/org/openedx/core/domain/interactor/CalendarInteractor.kt b/core/src/main/java/org/openedx/core/domain/interactor/CalendarInteractor.kt new file mode 100644 index 000000000..da84dba1a --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/interactor/CalendarInteractor.kt @@ -0,0 +1,60 @@ +package org.openedx.core.domain.interactor + +import org.openedx.core.data.model.room.CourseCalendarEventEntity +import org.openedx.core.data.model.room.CourseCalendarStateEntity +import org.openedx.core.domain.model.CourseCalendarEvent +import org.openedx.core.domain.model.CourseCalendarState +import org.openedx.core.repository.CalendarRepository + +class CalendarInteractor( + private val repository: CalendarRepository +) { + + suspend fun getEnrollmentsStatus() = repository.getEnrollmentsStatus() + + suspend fun getCourseDates(courseId: String) = repository.getCourseDates(courseId) + + suspend fun insertCourseCalendarEntityToCache(vararg courseCalendarEntity: CourseCalendarEventEntity) { + repository.insertCourseCalendarEntityToCache(*courseCalendarEntity) + } + + suspend fun getCourseCalendarEventsByIdFromCache(courseId: String): List { + return repository.getCourseCalendarEventsByIdFromCache(courseId) + } + + suspend fun deleteCourseCalendarEntitiesByIdFromCache(courseId: String) { + repository.deleteCourseCalendarEntitiesByIdFromCache(courseId) + } + + suspend fun insertCourseCalendarStateEntityToCache(vararg courseCalendarStateEntity: CourseCalendarStateEntity) { + repository.insertCourseCalendarStateEntityToCache(*courseCalendarStateEntity) + } + + suspend fun getCourseCalendarStateByIdFromCache(courseId: String): CourseCalendarState? { + return repository.getCourseCalendarStateByIdFromCache(courseId) + } + + suspend fun getAllCourseCalendarStateFromCache(): List { + return repository.getAllCourseCalendarStateFromCache() + } + + suspend fun clearCalendarCachedData() { + repository.clearCalendarCachedData() + } + + suspend fun resetChecksums() { + repository.resetChecksums() + } + + suspend fun updateCourseCalendarStateByIdInCache( + courseId: String, + checksum: Int? = null, + isCourseSyncEnabled: Boolean? = null + ) { + repository.updateCourseCalendarStateByIdInCache(courseId, checksum, isCourseSyncEnabled) + } + + suspend fun deleteCourseCalendarStateByIdFromCache(courseId: String) { + repository.deleteCourseCalendarStateByIdFromCache(courseId) + } +} diff --git a/core/src/main/java/org/openedx/core/domain/model/CalendarData.kt b/core/src/main/java/org/openedx/core/domain/model/CalendarData.kt new file mode 100644 index 000000000..849d2f303 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CalendarData.kt @@ -0,0 +1,10 @@ +package org.openedx.core.domain.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class CalendarData( + val title: String, + val color: Int +) : Parcelable diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseCalendarEvent.kt b/core/src/main/java/org/openedx/core/domain/model/CourseCalendarEvent.kt new file mode 100644 index 000000000..bdf676c7f --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseCalendarEvent.kt @@ -0,0 +1,6 @@ +package org.openedx.core.domain.model + +data class CourseCalendarEvent( + val courseId: String, + val eventId: Long, +) diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseCalendarState.kt b/core/src/main/java/org/openedx/core/domain/model/CourseCalendarState.kt new file mode 100644 index 000000000..fefad4d82 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseCalendarState.kt @@ -0,0 +1,7 @@ +package org.openedx.core.domain.model + +data class CourseCalendarState( + val checksum: Int, + val courseId: String, + val isCourseSyncEnabled: Boolean +) 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 394ebdd56..97f8612bf 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 @@ -32,4 +32,24 @@ data class CourseDateBlock( fun isTimeDifferenceLessThan24Hours(): Boolean { return (date.isToday() && date.before(Date())) || date.isTimeLessThan24Hours() } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as CourseDateBlock + + if (blockId != other.blockId) return false + if (date != other.date) return false + if (assignmentType != other.assignmentType) return false + + return true + } + + override fun hashCode(): Int { + var result = blockId.hashCode() + result = 31 * result + date.hashCode() + result = 31 * result + (assignmentType?.hashCode() ?: 0) + return result + } } diff --git a/core/src/main/java/org/openedx/core/domain/model/EnrollmentStatus.kt b/core/src/main/java/org/openedx/core/domain/model/EnrollmentStatus.kt new file mode 100644 index 000000000..8d40ea71d --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/EnrollmentStatus.kt @@ -0,0 +1,7 @@ +package org.openedx.core.domain.model + +data class EnrollmentStatus( + val courseId: String, + val courseName: String, + val isActive: Boolean +) diff --git a/core/src/main/java/org/openedx/core/module/db/CalendarDao.kt b/core/src/main/java/org/openedx/core/module/db/CalendarDao.kt new file mode 100644 index 000000000..0dcef5006 --- /dev/null +++ b/core/src/main/java/org/openedx/core/module/db/CalendarDao.kt @@ -0,0 +1,58 @@ +package org.openedx.core.module.db + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import org.openedx.core.data.model.room.CourseCalendarEventEntity +import org.openedx.core.data.model.room.CourseCalendarStateEntity + +@Dao +interface CalendarDao { + + // region CourseCalendarEventEntity + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertCourseCalendarEntity(vararg courseCalendarEntity: CourseCalendarEventEntity) + + @Query("DELETE FROM course_calendar_event_table WHERE course_id = :courseId") + suspend fun deleteCourseCalendarEntitiesById(courseId: String) + + @Query("SELECT * FROM course_calendar_event_table WHERE course_id=:courseId") + suspend fun readCourseCalendarEventsById(courseId: String): List + + @Query("DELETE FROM course_calendar_event_table") + suspend fun clearCourseCalendarEventsCachedData() + + // region CourseCalendarStateEntity + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertCourseCalendarStateEntity(vararg courseCalendarStateEntity: CourseCalendarStateEntity) + + @Query("SELECT * FROM course_calendar_state_table WHERE course_id=:courseId") + suspend fun readCourseCalendarStateById(courseId: String): CourseCalendarStateEntity? + + @Query("SELECT * FROM course_calendar_state_table") + suspend fun readAllCourseCalendarState(): List + + @Query("DELETE FROM course_calendar_state_table") + suspend fun clearCourseCalendarStateCachedData() + + @Query("DELETE FROM course_calendar_state_table WHERE course_id = :courseId") + suspend fun deleteCourseCalendarStateById(courseId: String) + + @Query("UPDATE course_calendar_state_table SET checksum = 0") + suspend fun resetChecksums() + + @Query( + """ + UPDATE course_calendar_state_table + SET + checksum = COALESCE(:checksum, checksum), + is_course_sync_enabled = COALESCE(:isCourseSyncEnabled, is_course_sync_enabled) + WHERE course_id = :courseId""" + ) + suspend fun updateCourseCalendarStateById( + courseId: String, + checksum: Int? = null, + isCourseSyncEnabled: Boolean? = null + ) +} diff --git a/core/src/main/java/org/openedx/core/module/db/DownloadDao.kt b/core/src/main/java/org/openedx/core/module/db/DownloadDao.kt index 5bdfc637b..8005a4b95 100644 --- a/core/src/main/java/org/openedx/core/module/db/DownloadDao.kt +++ b/core/src/main/java/org/openedx/core/module/db/DownloadDao.kt @@ -1,6 +1,10 @@ package org.openedx.core.module.db -import androidx.room.* +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update import kotlinx.coroutines.flow.Flow @Dao @@ -23,4 +27,7 @@ interface DownloadDao { @Query("DELETE FROM download_model WHERE id in (:ids)") suspend fun removeAllDownloadModels(ids: List) + + @Query("DELETE FROM download_model") + suspend fun clearCachedData() } diff --git a/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncState.kt b/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncState.kt new file mode 100644 index 000000000..95a851442 --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncState.kt @@ -0,0 +1,52 @@ +package org.openedx.core.presentation.settings.calendarsync + +import androidx.annotation.StringRes +import androidx.compose.material.MaterialTheme +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CloudSync +import androidx.compose.material.icons.filled.SyncDisabled +import androidx.compose.material.icons.rounded.EventRepeat +import androidx.compose.material.icons.rounded.FreeCancellation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import org.openedx.core.R +import org.openedx.core.ui.theme.appColors + +enum class CalendarSyncState( + @StringRes val title: Int, + @StringRes val longTitle: Int, + val icon: ImageVector +) { + OFFLINE( + R.string.core_offline, + R.string.core_offline, + Icons.Default.SyncDisabled + ), + SYNC_FAILED( + R.string.core_syncing_failed, + R.string.core_calendar_sync_failed, + Icons.Rounded.FreeCancellation + ), + SYNCED( + R.string.core_to_sync, + R.string.core_synced_to_calendar, + Icons.Rounded.EventRepeat + ), + SYNCHRONIZATION( + R.string.core_syncing_to_calendar, + R.string.core_syncing_to_calendar, + Icons.Default.CloudSync + ); + + val tint: Color + @Composable + @ReadOnlyComposable + get() = when (this) { + OFFLINE -> MaterialTheme.appColors.textFieldHint + SYNC_FAILED -> MaterialTheme.appColors.error + SYNCED -> MaterialTheme.appColors.successGreen + SYNCHRONIZATION -> MaterialTheme.appColors.primary + } +} diff --git a/core/src/main/java/org/openedx/core/repository/CalendarRepository.kt b/core/src/main/java/org/openedx/core/repository/CalendarRepository.kt new file mode 100644 index 000000000..e46922605 --- /dev/null +++ b/core/src/main/java/org/openedx/core/repository/CalendarRepository.kt @@ -0,0 +1,77 @@ +package org.openedx.core.repository + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import org.openedx.core.data.api.CourseApi +import org.openedx.core.data.model.room.CourseCalendarEventEntity +import org.openedx.core.data.model.room.CourseCalendarStateEntity +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.CourseCalendarEvent +import org.openedx.core.domain.model.CourseCalendarState +import org.openedx.core.domain.model.EnrollmentStatus +import org.openedx.core.module.db.CalendarDao + +class CalendarRepository( + private val api: CourseApi, + private val corePreferences: CorePreferences, + private val calendarDao: CalendarDao +) { + + suspend fun getEnrollmentsStatus(): List { + val response = api.getEnrollmentsStatus(corePreferences.user?.username ?: "") + return response.map { it.mapToDomain() } + } + + suspend fun getCourseDates(courseId: String) = api.getCourseDates(courseId) + + suspend fun insertCourseCalendarEntityToCache(vararg courseCalendarEntity: CourseCalendarEventEntity) { + calendarDao.insertCourseCalendarEntity(*courseCalendarEntity) + } + + suspend fun getCourseCalendarEventsByIdFromCache(courseId: String): List { + return calendarDao.readCourseCalendarEventsById(courseId).map { it.mapToDomain() } + } + + suspend fun deleteCourseCalendarEntitiesByIdFromCache(courseId: String) { + calendarDao.deleteCourseCalendarEntitiesById(courseId) + } + + suspend fun insertCourseCalendarStateEntityToCache(vararg courseCalendarStateEntity: CourseCalendarStateEntity) { + calendarDao.insertCourseCalendarStateEntity(*courseCalendarStateEntity) + } + + suspend fun getCourseCalendarStateByIdFromCache(courseId: String): CourseCalendarState? { + return calendarDao.readCourseCalendarStateById(courseId)?.mapToDomain() + } + + suspend fun getAllCourseCalendarStateFromCache(): List { + return calendarDao.readAllCourseCalendarState().map { it.mapToDomain() } + } + + suspend fun resetChecksums() { + calendarDao.resetChecksums() + } + + suspend fun clearCalendarCachedData() { + CoroutineScope(Dispatchers.Main).launch { + val clearCourseCalendarStateDeferred = async { calendarDao.clearCourseCalendarStateCachedData() } + val clearCourseCalendarEventsDeferred = async { calendarDao.clearCourseCalendarEventsCachedData() } + clearCourseCalendarStateDeferred.await() + clearCourseCalendarEventsDeferred.await() + } + } + + suspend fun updateCourseCalendarStateByIdInCache( + courseId: String, + checksum: Int? = null, + isCourseSyncEnabled: Boolean? = null + ) { + calendarDao.updateCourseCalendarStateById(courseId, checksum, isCourseSyncEnabled) + } + + suspend fun deleteCourseCalendarStateByIdFromCache(courseId: String) { + calendarDao.deleteCourseCalendarStateById(courseId) + } +} diff --git a/core/src/main/java/org/openedx/core/system/CalendarManager.kt b/core/src/main/java/org/openedx/core/system/CalendarManager.kt index e1e6f926d..c1a393767 100644 --- a/core/src/main/java/org/openedx/core/system/CalendarManager.kt +++ b/core/src/main/java/org/openedx/core/system/CalendarManager.kt @@ -1,10 +1,8 @@ package org.openedx.core.system -import android.annotation.SuppressLint import android.content.ContentUris import android.content.ContentValues import android.content.Context -import android.content.Intent import android.content.pm.PackageManager import android.database.Cursor import android.net.Uri @@ -13,20 +11,17 @@ import androidx.core.content.ContextCompat import io.branch.indexing.BranchUniversalObject import io.branch.referral.util.ContentMetadata import io.branch.referral.util.LinkProperties -import org.openedx.core.R import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.CalendarData import org.openedx.core.domain.model.CourseDateBlock import org.openedx.core.utils.Logger import org.openedx.core.utils.toCalendar -import java.util.Calendar import java.util.TimeZone import java.util.concurrent.TimeUnit -import org.openedx.core.R as CoreR class CalendarManager( private val context: Context, private val corePreferences: CorePreferences, - private val resourceManager: ResourceManager, ) { private val logger = Logger(TAG) @@ -35,7 +30,7 @@ class CalendarManager( android.Manifest.permission.READ_CALENDAR ) - private val accountName: String + val accountName: String get() = getUserAccountForSync() /** @@ -48,29 +43,40 @@ class CalendarManager( /** * Check if the calendar is already existed in mobile calendar app or not */ - fun isCalendarExists(calendarTitle: String): Boolean { - if (hasPermissions()) { - return getCalendarId(calendarTitle) != CALENDAR_DOES_NOT_EXIST - } - return false + fun isCalendarExist(calendarId: Long): Boolean { + val projection = arrayOf(CalendarContract.Calendars._ID) + val selection = "${CalendarContract.Calendars._ID} = ?" + val selectionArgs = arrayOf(calendarId.toString()) + + val cursor = context.contentResolver.query( + CalendarContract.Calendars.CONTENT_URI, + projection, + selection, + selectionArgs, + null + ) + + val exists = cursor != null && cursor.count > 0 + cursor?.close() + + return exists } /** * Create or update the calendar if it is already existed in mobile calendar app */ fun createOrUpdateCalendar( - calendarTitle: String + calendarId: Long = CALENDAR_DOES_NOT_EXIST, + calendarTitle: String, + calendarColor: Long ): Long { - val calendarId = getCalendarId( - calendarTitle = calendarTitle - ) - if (calendarId != CALENDAR_DOES_NOT_EXIST) { deleteCalendar(calendarId = calendarId) } return createCalendar( - calendarTitle = calendarTitle + calendarTitle = calendarTitle, + calendarColor = calendarColor ) } @@ -78,7 +84,8 @@ class CalendarManager( * Method to create a separate calendar based on course name in mobile calendar app */ private fun createCalendar( - calendarTitle: String + calendarTitle: String, + calendarColor: Long ): Long { val contentValues = ContentValues() contentValues.put(CalendarContract.Calendars.NAME, calendarTitle) @@ -97,7 +104,7 @@ class CalendarManager( contentValues.put(CalendarContract.Calendars.VISIBLE, 1) contentValues.put( CalendarContract.Calendars.CALENDAR_COLOR, - ContextCompat.getColor(context, R.color.primary) + calendarColor.toInt() ) val creationUri: Uri? = asSyncAdapter( Uri.parse(CalendarContract.Calendars.CONTENT_URI.toString()), @@ -114,39 +121,6 @@ class CalendarManager( return CALENDAR_DOES_NOT_EXIST } - /** - * Method to check if the calendar with the course name exist in the mobile calendar app or not - */ - @SuppressLint("Range") - fun getCalendarId(calendarTitle: String): Long { - var calendarId = CALENDAR_DOES_NOT_EXIST - val projection = arrayOf( - CalendarContract.Calendars._ID, - CalendarContract.Calendars.ACCOUNT_NAME, - CalendarContract.Calendars.NAME - ) - val calendarContentResolver = context.contentResolver - val cursor = calendarContentResolver.query( - CalendarContract.Calendars.CONTENT_URI, projection, - CalendarContract.Calendars.ACCOUNT_NAME + "=? and (" + - CalendarContract.Calendars.NAME + "=? or " + - CalendarContract.Calendars.CALENDAR_DISPLAY_NAME + "=?)", arrayOf( - accountName, calendarTitle, - calendarTitle - ), null - ) - if (cursor?.moveToFirst() == true) { - if (cursor.getString(cursor.getColumnIndex(CalendarContract.Calendars.NAME)) - .equals(calendarTitle) - ) { - calendarId = - cursor.getInt(cursor.getColumnIndex(CalendarContract.Calendars._ID)).toLong() - } - } - cursor?.close() - return calendarId - } - /** * Method to add important dates of course as calendar event into calendar of mobile app */ @@ -155,7 +129,7 @@ class CalendarManager( courseId: String, courseName: String, courseDateBlock: CourseDateBlock - ) { + ): Long { val date = courseDateBlock.date.toCalendar() // start time of the event, adjusted 1 hour earlier for a 1-hour duration val startMillis: Long = date.timeInMillis - TimeUnit.HOURS.toMillis(1) @@ -167,7 +141,7 @@ class CalendarManager( put(CalendarContract.Events.DTEND, endMillis) put( CalendarContract.Events.TITLE, - "${resourceManager.getString(R.string.core_assignment_due_tag)} : $courseName" + "${courseDateBlock.title} : $courseName" ) put( CalendarContract.Events.DESCRIPTION, @@ -182,6 +156,8 @@ class CalendarManager( } val uri = context.contentResolver.insert(CalendarContract.Events.CONTENT_URI, values) uri?.let { addReminderToEvent(uri = it) } + val eventId = uri?.lastPathSegment?.toLong() ?: EVENT_DOES_NOT_EXIST + return eventId } /** @@ -194,7 +170,7 @@ class CalendarManager( courseDateBlock: CourseDateBlock, isDeeplinkEnabled: Boolean ): String { - var eventDescription = courseDateBlock.title + var eventDescription = courseDateBlock.description if (isDeeplinkEnabled && courseDateBlock.blockId.isNotEmpty()) { val metaData = ContentMetadata() @@ -246,82 +222,6 @@ class CalendarManager( context.contentResolver.insert(CalendarContract.Reminders.CONTENT_URI, eventValues) } - /** - * Method to query the events for the given calendar id - * - * @param calendarId calendarId to query the events - * - * @return [Cursor] - * - * */ - private fun getCalendarEvents(calendarId: Long): Cursor? { - val calendarContentResolver = context.contentResolver - val projection = arrayOf( - CalendarContract.Events._ID, - CalendarContract.Events.DTEND, - CalendarContract.Events.DESCRIPTION - ) - val selection = CalendarContract.Events.CALENDAR_ID + "=?" - return calendarContentResolver.query( - CalendarContract.Events.CONTENT_URI, - projection, - selection, - arrayOf(calendarId.toString()), - null - ) - } - - /** - * Method to compare the calendar events with course dates - * @return true if the events are the same as calendar dates otherwise false - */ - @SuppressLint("Range") - private fun compareEvents( - calendarId: Long, - courseDateBlocks: List - ): Boolean { - val cursor = getCalendarEvents(calendarId) ?: return false - - val datesList = ArrayList(courseDateBlocks) - val dueDateColumnIndex = cursor.getColumnIndex(CalendarContract.Events.DTEND) - val descriptionColumnIndex = cursor.getColumnIndex(CalendarContract.Events.DESCRIPTION) - - while (cursor.moveToNext()) { - val dueDateInMillis = cursor.getLong(dueDateColumnIndex) - - val description = cursor.getString(descriptionColumnIndex) - if (description != null) { - val matchedDate = datesList.find { unit -> - description.contains(unit.title, ignoreCase = true) - } - - matchedDate?.let { unit -> - val dueDateCalendar = Calendar.getInstance().apply { - timeInMillis = dueDateInMillis - set(Calendar.SECOND, 0) - set(Calendar.MILLISECOND, 0) - } - - val unitDateCalendar = unit.date.toCalendar().apply { - set(Calendar.SECOND, 0) - set(Calendar.MILLISECOND, 0) - } - - if (dueDateCalendar == unitDateCalendar) { - datesList.remove(unit) - } else { - // If any single value isn't matched, return false - cursor.close() - return false - } - } - } - } - - cursor.close() - return datesList.isEmpty() - } - /** * Method to delete the course calendar from the mobile calendar app */ @@ -352,37 +252,6 @@ class CalendarManager( ).build() } - fun openCalendarApp() { - val builder: Uri.Builder = CalendarContract.CONTENT_URI.buildUpon() - .appendPath("time") - ContentUris.appendId(builder, Calendar.getInstance().timeInMillis) - val intent = Intent(Intent.ACTION_VIEW).setData(builder.build()) - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - context.startActivity(intent) - } - - /** - * Helper method used to check that the calendar if outdated for the course or not - * - * @param calendarTitle Title for the course Calendar - * @param courseDateBlocks Course dates events - * - * @return Calendar Id if Calendar is outdated otherwise -1 or CALENDAR_DOES_NOT_EXIST - * - */ - fun isCalendarOutOfDate( - calendarTitle: String, - courseDateBlocks: List - ): Long { - if (isCalendarExists(calendarTitle)) { - val calendarId = getCalendarId(calendarTitle) - if (compareEvents(calendarId, courseDateBlocks).not()) { - return calendarId - } - } - return CALENDAR_DOES_NOT_EXIST - } - /** * Method to get the current user account as the Calendar owner * @@ -392,19 +261,49 @@ class CalendarManager( return corePreferences.user?.email ?: LOCAL_USER } - /** - * Method to create the Calendar title for the platform against the course - * - * @param courseName Name of the course for that creating the Calendar events. - * - * @return title of the Calendar against the course - */ - fun getCourseCalendarTitle(courseName: String): String { - return "${resourceManager.getString(id = CoreR.string.platform_name)} - $courseName" + fun getCalendarData(calendarId: Long): CalendarData? { + val projection = arrayOf( + CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, + CalendarContract.Calendars.CALENDAR_COLOR + ) + val selection = "${CalendarContract.Calendars._ID} = ?" + val selectionArgs = arrayOf(calendarId.toString()) + + val cursor: Cursor? = context.contentResolver.query( + CalendarContract.Calendars.CONTENT_URI, + projection, + selection, + selectionArgs, + null + ) + + return cursor?.use { + if (it.moveToFirst()) { + val title = it.getString(it.getColumnIndexOrThrow(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME)) + val color = it.getInt(it.getColumnIndexOrThrow(CalendarContract.Calendars.CALENDAR_COLOR)) + CalendarData( + title = title, + color = color + ) + } else { + null + } + } + } + + fun deleteEvent(eventId: Long) { + val deleteUri = ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId) + val rows = context.contentResolver.delete(deleteUri, null, null) + if (rows > 0) { + logger.d { "Event deleted successfully" } + } else { + logger.d { "Event deletion failed" } + } } companion object { const val CALENDAR_DOES_NOT_EXIST = -1L + const val EVENT_DOES_NOT_EXIST = -1L private const val TAG = "CalendarManager" private const val LOCAL_USER = "local_user" } diff --git a/core/src/main/java/org/openedx/core/system/notifier/AppUpgradeNotifier.kt b/core/src/main/java/org/openedx/core/system/notifier/AppUpgradeNotifier.kt new file mode 100644 index 000000000..e69de29bb diff --git a/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarCreated.kt b/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarCreated.kt new file mode 100644 index 000000000..028b0d3e3 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarCreated.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier.calendar + +object CalendarCreated : CalendarEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarEvent.kt b/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarEvent.kt new file mode 100644 index 000000000..1bdf92dca --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarEvent.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier.calendar + +interface CalendarEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarNotifier.kt b/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarNotifier.kt new file mode 100644 index 000000000..b0baa674b --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarNotifier.kt @@ -0,0 +1,14 @@ +package org.openedx.core.system.notifier.calendar + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +class CalendarNotifier { + + private val channel = MutableSharedFlow(replay = 0, extraBufferCapacity = 0) + + val notifier: Flow = channel.asSharedFlow() + + suspend fun send(event: CalendarEvent) = channel.emit(event) +} diff --git a/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarSyncDisabled.kt b/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarSyncDisabled.kt new file mode 100644 index 000000000..ec9d61e84 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarSyncDisabled.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier.calendar + +object CalendarSyncDisabled : CalendarEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarSyncFailed.kt b/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarSyncFailed.kt new file mode 100644 index 000000000..af7f507ea --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarSyncFailed.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier.calendar + +object CalendarSyncFailed : CalendarEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarSyncOffline.kt b/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarSyncOffline.kt new file mode 100644 index 000000000..ac78a4a4c --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarSyncOffline.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier.calendar + +object CalendarSyncOffline : CalendarEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarSynced.kt b/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarSynced.kt new file mode 100644 index 000000000..71bfed3ef --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarSynced.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier.calendar + +object CalendarSynced : CalendarEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarSyncing.kt b/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarSyncing.kt new file mode 100644 index 000000000..edfe066a9 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarSyncing.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier.calendar + +object CalendarSyncing : CalendarEvent diff --git a/core/src/main/java/org/openedx/core/worker/CalendarSyncScheduler.kt b/core/src/main/java/org/openedx/core/worker/CalendarSyncScheduler.kt new file mode 100644 index 000000000..b74d7c9da --- /dev/null +++ b/core/src/main/java/org/openedx/core/worker/CalendarSyncScheduler.kt @@ -0,0 +1,39 @@ +package org.openedx.core.worker + +import android.content.Context +import androidx.work.Data +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import java.util.concurrent.TimeUnit + +class CalendarSyncScheduler(private val context: Context) { + + fun scheduleDailySync() { + val periodicWorkRequest = PeriodicWorkRequestBuilder(1, TimeUnit.DAYS) + .addTag(CalendarSyncWorker.WORKER_TAG) + .build() + + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + CalendarSyncWorker.WORKER_TAG, + ExistingPeriodicWorkPolicy.KEEP, + periodicWorkRequest + ) + } + + fun requestImmediateSync() { + val syncWorkRequest = OneTimeWorkRequestBuilder().build() + WorkManager.getInstance(context).enqueue(syncWorkRequest) + } + + fun requestImmediateSync(courseId: String) { + val inputData = Data.Builder() + .putString(CalendarSyncWorker.ARG_COURSE_ID, courseId) + .build() + val syncWorkRequest = OneTimeWorkRequestBuilder() + .setInputData(inputData) + .build() + WorkManager.getInstance(context).enqueue(syncWorkRequest) + } +} diff --git a/core/src/main/java/org/openedx/core/worker/CalendarSyncWorker.kt b/core/src/main/java/org/openedx/core/worker/CalendarSyncWorker.kt new file mode 100644 index 000000000..2c36f075b --- /dev/null +++ b/core/src/main/java/org/openedx/core/worker/CalendarSyncWorker.kt @@ -0,0 +1,224 @@ +package org.openedx.core.worker + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.content.pm.ServiceInfo +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.core.app.NotificationCompat +import androidx.work.CoroutineWorker +import androidx.work.ForegroundInfo +import androidx.work.WorkerParameters +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.openedx.core.R +import org.openedx.core.data.model.CourseDates +import org.openedx.core.data.model.room.CourseCalendarEventEntity +import org.openedx.core.data.model.room.CourseCalendarStateEntity +import org.openedx.core.data.storage.CalendarPreferences +import org.openedx.core.domain.interactor.CalendarInteractor +import org.openedx.core.domain.model.CourseDateBlock +import org.openedx.core.domain.model.EnrollmentStatus +import org.openedx.core.system.CalendarManager +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.system.notifier.calendar.CalendarNotifier +import org.openedx.core.system.notifier.calendar.CalendarSyncFailed +import org.openedx.core.system.notifier.calendar.CalendarSyncOffline +import org.openedx.core.system.notifier.calendar.CalendarSynced +import org.openedx.core.system.notifier.calendar.CalendarSyncing + +class CalendarSyncWorker( + private val context: Context, + workerParams: WorkerParameters +) : CoroutineWorker(context, workerParams), KoinComponent { + + private val calendarManager: CalendarManager by inject() + private val calendarInteractor: CalendarInteractor by inject() + private val calendarNotifier: CalendarNotifier by inject() + private val calendarPreferences: CalendarPreferences by inject() + private val networkConnection: NetworkConnection by inject() + + private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + private val notificationBuilder = NotificationCompat.Builder(context, NOTIFICATION_CHANEL_ID) + + private val failedCoursesSync = mutableSetOf() + + override suspend fun doWork(): Result { + return try { + setForeground(createForegroundInfo()) + val courseId = inputData.getString(ARG_COURSE_ID) + tryToSyncCalendar(courseId) + Result.success() + } catch (e: Exception) { + calendarNotifier.send(CalendarSyncFailed) + Result.failure() + } + } + + private fun createForegroundInfo(): ForegroundInfo { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + createChannel() + } + val serviceType = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC else 0 + + return ForegroundInfo( + NOTIFICATION_ID, + notificationBuilder + .setSmallIcon(R.drawable.core_ic_calendar) + .setContentText(context.getString(R.string.core_title_syncing_calendar)) + .setContentTitle("") + .build(), + serviceType + ) + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun createChannel() { + val notificationChannel = + NotificationChannel( + NOTIFICATION_CHANEL_ID, + context.getString(R.string.core_header_sync_to_calendar), + NotificationManager.IMPORTANCE_LOW + ) + notificationManager.createNotificationChannel(notificationChannel) + } + + private suspend fun tryToSyncCalendar(courseId: String?) { + val isCalendarCreated = calendarPreferences.calendarId != CalendarManager.CALENDAR_DOES_NOT_EXIST + val isCalendarSyncEnabled = calendarPreferences.isCalendarSyncEnabled + if (!networkConnection.isOnline()) { + calendarNotifier.send(CalendarSyncOffline) + } else if (isCalendarCreated && isCalendarSyncEnabled) { + calendarNotifier.send(CalendarSyncing) + val enrollmentsStatus = calendarInteractor.getEnrollmentsStatus() + if (courseId.isNullOrEmpty()) { + syncCalendar(enrollmentsStatus) + } else { + syncCalendar(enrollmentsStatus, courseId) + } + removeUnenrolledCourseEvents(enrollmentsStatus) + if (failedCoursesSync.isEmpty()) { + calendarNotifier.send(CalendarSynced) + } else { + calendarNotifier.send(CalendarSyncFailed) + } + } + } + + private suspend fun removeUnenrolledCourseEvents(enrollmentStatus: List) { + val enrolledCourseIds = enrollmentStatus.map { it.courseId } + val cachedCourseIds = calendarInteractor.getAllCourseCalendarStateFromCache().map { it.courseId } + val unenrolledCourseIds = cachedCourseIds.filter { it !in enrolledCourseIds } + unenrolledCourseIds.forEach { courseId -> + removeCalendarEvents(courseId) + calendarInteractor.deleteCourseCalendarStateByIdFromCache(courseId) + } + } + + private suspend fun syncCalendar(enrollmentsStatus: List, courseId: String) { + enrollmentsStatus + .find { it.courseId == courseId } + ?.let { enrollmentStatus -> + syncCourseEvents(enrollmentStatus) + } + } + + private suspend fun syncCalendar(enrollmentsStatus: List) { + enrollmentsStatus.forEach { enrollmentStatus -> + syncCourseEvents(enrollmentStatus) + } + } + + private suspend fun syncCourseEvents(enrollmentStatus: EnrollmentStatus) { + val courseId = enrollmentStatus.courseId + try { + createCalendarState(enrollmentStatus) + if (enrollmentStatus.isActive && isCourseSyncEnabled(courseId)) { + val courseDates = calendarInteractor.getCourseDates(courseId) + val isCourseCalendarUpToDate = isCourseCalendarUpToDate(courseId, courseDates) + if (!isCourseCalendarUpToDate) { + removeCalendarEvents(courseId) + updateCourseEvents(courseDates, enrollmentStatus) + } + } else { + removeCalendarEvents(courseId) + } + } catch (e: Exception) { + failedCoursesSync.add(courseId) + e.printStackTrace() + } + } + + private suspend fun updateCourseEvents(courseDates: CourseDates, enrollmentStatus: EnrollmentStatus) { + courseDates.courseDateBlocks.forEach { courseDateBlock -> + courseDateBlock.mapToDomain()?.let { domainCourseDateBlock -> + createEvent(domainCourseDateBlock, enrollmentStatus) + } + } + calendarInteractor.updateCourseCalendarStateByIdInCache( + courseId = enrollmentStatus.courseId, + checksum = getCourseChecksum(courseDates) + ) + } + + private suspend fun removeCalendarEvents(courseId: String) { + calendarInteractor.getCourseCalendarEventsByIdFromCache(courseId).forEach { + calendarManager.deleteEvent(it.eventId) + } + calendarInteractor.deleteCourseCalendarEntitiesByIdFromCache(courseId) + calendarInteractor.updateCourseCalendarStateByIdInCache(courseId = courseId, checksum = 0) + } + + private suspend fun createEvent(courseDateBlock: CourseDateBlock, enrollmentStatus: EnrollmentStatus) { + val eventId = calendarManager.addEventsIntoCalendar( + calendarId = calendarPreferences.calendarId, + courseId = enrollmentStatus.courseId, + courseName = enrollmentStatus.courseName, + courseDateBlock = courseDateBlock + ) + val courseCalendarEventEntity = CourseCalendarEventEntity( + courseId = enrollmentStatus.courseId, + eventId = eventId + ) + calendarInteractor.insertCourseCalendarEntityToCache(courseCalendarEventEntity) + } + + private suspend fun createCalendarState(enrollmentStatus: EnrollmentStatus) { + val courseCalendarStateChecksum = getCourseCalendarStateChecksum(enrollmentStatus.courseId) + if (courseCalendarStateChecksum == null) { + val courseCalendarStateEntity = CourseCalendarStateEntity( + courseId = enrollmentStatus.courseId, + isCourseSyncEnabled = enrollmentStatus.isActive + ) + calendarInteractor.insertCourseCalendarStateEntityToCache(courseCalendarStateEntity) + } + } + + private suspend fun isCourseCalendarUpToDate(courseId: String, courseDates: CourseDates): Boolean { + val oldChecksum = getCourseCalendarStateChecksum(courseId) + val newChecksum = getCourseChecksum(courseDates) + return newChecksum == oldChecksum + } + + private suspend fun isCourseSyncEnabled(courseId: String): Boolean { + return calendarInteractor.getCourseCalendarStateByIdFromCache(courseId)?.isCourseSyncEnabled ?: true + } + + private fun getCourseChecksum(courseDates: CourseDates): Int { + return courseDates.courseDateBlocks.sumOf { it.mapToDomain().hashCode() } + } + + private suspend fun getCourseCalendarStateChecksum(courseId: String): Int? { + return calendarInteractor.getCourseCalendarStateByIdFromCache(courseId)?.checksum + } + + companion object { + const val ARG_COURSE_ID = "ARG_COURSE_ID" + const val WORKER_TAG = "calendar_sync_worker_tag" + const val NOTIFICATION_ID = 1234 + const val NOTIFICATION_CHANEL_ID = "calendar_sync_channel" + } +} diff --git a/dashboard/src/main/res/drawable/dashboard_ic_book.xml b/core/src/main/res/drawable/core_ic_book.xml similarity index 100% rename from dashboard/src/main/res/drawable/dashboard_ic_book.xml rename to core/src/main/res/drawable/core_ic_book.xml diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index 931d2c6da..9aded8c31 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -182,4 +182,11 @@ Discussions More Dates + + Calendar Sync Failed + Synced to Calendar + Sync Failed + To Sync + Not Synced + Syncing to calendar… diff --git a/course/src/main/java/org/openedx/course/data/storage/CourseDao.kt b/course/src/main/java/org/openedx/course/data/storage/CourseDao.kt index ca7286e48..63bd1c4d9 100644 --- a/course/src/main/java/org/openedx/course/data/storage/CourseDao.kt +++ b/course/src/main/java/org/openedx/course/data/storage/CourseDao.kt @@ -5,17 +5,16 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import org.openedx.core.data.model.room.CourseStructureEntity -import org.openedx.core.data.model.room.discovery.EnrolledCourseEntity @Dao interface CourseDao { - @Query("SELECT * FROM course_enrolled_table WHERE id=:id") - suspend fun getEnrolledCourseById(id: String): EnrolledCourseEntity? - @Query("SELECT * FROM course_structure_table WHERE id=:id") suspend fun getCourseStructureById(id: String): CourseStructureEntity? @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertCourseStructureEntity(vararg courseStructureEntity: CourseStructureEntity) + + @Query("DELETE FROM course_structure_table") + suspend fun clearCachedData() } 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 d83cd0c18..9168d3148 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 @@ -57,8 +57,6 @@ 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.global.viewBinding -import org.openedx.core.presentation.settings.calendarsync.CalendarSyncDialog -import org.openedx.core.presentation.settings.calendarsync.CalendarSyncDialogType import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OfflineModeDialog import org.openedx.core.ui.RoundTabsBar @@ -90,15 +88,6 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { ) } - private val permissionLauncher = registerForActivityResult( - ActivityResultContracts.RequestMultiplePermissions() - ) { isGranted -> - viewModel.logCalendarPermissionAccess(!isGranted.containsValue(false)) - if (!isGranted.containsValue(false)) { - viewModel.setCalendarSyncDialogType(CalendarSyncDialogType.SYNC_DIALOG) - } - } - private val pushNotificationPermissionLauncher = registerForActivityResult( ActivityResultContracts.RequestPermission() ) { granted -> @@ -188,84 +177,12 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { OpenEdXTheme { val syncState by viewModel.calendarSyncUIState.collectAsState() - LaunchedEffect(key1 = syncState.checkForOutOfSync) { - if (syncState.isCalendarSyncEnabled && syncState.checkForOutOfSync.get()) { - viewModel.checkIfCalendarOutOfDate() - } - } - LaunchedEffect(syncState.uiMessage.get()) { syncState.uiMessage.get().takeIfNotEmpty()?.let { Snackbar.make(binding.root, it, Snackbar.LENGTH_SHORT).show() syncState.uiMessage.set("") } } - - CalendarSyncDialog( - syncDialogType = syncState.dialogType, - calendarTitle = syncState.calendarTitle, - syncDialogPosAction = { dialog -> - when (dialog) { - CalendarSyncDialogType.SYNC_DIALOG -> { - viewModel.logCalendarAddDates(true) - viewModel.addOrUpdateEventsInCalendar( - updatedEvent = false, - ) - } - - CalendarSyncDialogType.UN_SYNC_DIALOG -> { - viewModel.logCalendarRemoveDates(true) - viewModel.deleteCourseCalendar() - } - - CalendarSyncDialogType.PERMISSION_DIALOG -> { - permissionLauncher.launch(viewModel.calendarPermissions) - } - - CalendarSyncDialogType.OUT_OF_SYNC_DIALOG -> { - viewModel.logCalendarSyncUpdate(true) - viewModel.addOrUpdateEventsInCalendar( - updatedEvent = true, - ) - } - - CalendarSyncDialogType.EVENTS_DIALOG -> { - viewModel.logCalendarSyncedConfirmation(true) - viewModel.openCalendarApp() - } - - else -> {} - } - }, - syncDialogNegAction = { dialog -> - when (dialog) { - CalendarSyncDialogType.SYNC_DIALOG -> - viewModel.logCalendarAddDates(false) - - CalendarSyncDialogType.UN_SYNC_DIALOG -> - viewModel.logCalendarRemoveDates(false) - - CalendarSyncDialogType.OUT_OF_SYNC_DIALOG -> { - viewModel.logCalendarSyncUpdate(false) - viewModel.deleteCourseCalendar() - } - - CalendarSyncDialogType.EVENTS_DIALOG -> - viewModel.logCalendarSyncedConfirmation(false) - - CalendarSyncDialogType.LOADING_DIALOG, - CalendarSyncDialogType.PERMISSION_DIALOG, - CalendarSyncDialogType.NONE, - -> { - } - } - - viewModel.setCalendarSyncDialogType(CalendarSyncDialogType.NONE) - }, - dismissSyncDialog = { - viewModel.setCalendarSyncDialogType(CalendarSyncDialogType.NONE) - } - ) } } } 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 60813d29a..8d0f404c3 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt @@ -3,11 +3,9 @@ package org.openedx.course.presentation.container import android.graphics.Bitmap import android.graphics.drawable.BitmapDrawable import android.os.Build -import androidx.annotation.StringRes import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -27,10 +25,8 @@ import org.openedx.core.exception.NoCachedDataException 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.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection -import org.openedx.core.system.notifier.CalendarSyncEvent.CheckCalendarSyncEvent import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEvent import org.openedx.core.system.notifier.CourseCompletionSet import org.openedx.core.system.notifier.CourseDatesShifted @@ -40,12 +36,10 @@ 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.core.worker.CalendarSyncScheduler import org.openedx.course.DatesShiftedSnackBar -import org.openedx.course.data.storage.CoursePreferences import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CalendarSyncDialog -import org.openedx.course.presentation.CalendarSyncSnackbar import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseAnalyticsEvent import org.openedx.course.presentation.CourseAnalyticsKey @@ -61,14 +55,13 @@ class CourseContainerViewModel( private val enrollmentMode: String, private val config: Config, private val interactor: CourseInteractor, - private val calendarManager: CalendarManager, private val resourceManager: ResourceManager, private val courseNotifier: CourseNotifier, private val networkConnection: NetworkConnection, private val corePreferences: CorePreferences, - private val coursePreferences: CoursePreferences, private val courseAnalytics: CourseAnalytics, private val imageProcessor: ImageProcessor, + private val calendarSyncScheduler: CalendarSyncScheduler, val courseRouter: CourseRouter, ) : BaseViewModel() { @@ -104,13 +97,9 @@ class CourseContainerViewModel( val organization: String get() = _organization - val calendarPermissions: Array - get() = calendarManager.permissions - private val _calendarSyncUIState = MutableStateFlow( CalendarSyncUIState( isCalendarSyncEnabled = isCalendarSyncEnabled(), - calendarTitle = calendarManager.getCourseCalendarTitle(courseName), courseDates = emptyList(), dialogType = CalendarSyncDialogType.NONE, checkForOutOfSync = AtomicReference(false), @@ -150,6 +139,7 @@ class CourseContainerViewModel( } is CourseDatesShifted -> { + calendarSyncScheduler.requestImmediateSync(courseId) _uiMessage.emit(DatesShiftedSnackBar()) } @@ -282,113 +272,6 @@ class CourseContainerViewModel( } } - fun addOrUpdateEventsInCalendar( - updatedEvent: Boolean, - ) { - setCalendarSyncDialogType(CalendarSyncDialogType.LOADING_DIALOG) - - val startSyncTime = TimeUtils.getCurrentTime() - val calendarId = getCalendarId() - - if (calendarId == CalendarManager.CALENDAR_DOES_NOT_EXIST) { - setUiMessage(CoreR.string.core_snackbar_course_calendar_error) - setCalendarSyncDialogType(CalendarSyncDialogType.NONE) - - return - } - - viewModelScope.launch(Dispatchers.IO) { - val courseDates = _calendarSyncUIState.value.courseDates - if (courseDates.isNotEmpty()) { - courseDates.forEach { courseDateBlock -> - calendarManager.addEventsIntoCalendar( - calendarId = calendarId, - courseId = courseId, - courseName = courseName, - courseDateBlock = courseDateBlock - ) - } - } - val elapsedSyncTime = TimeUtils.getCurrentTime() - startSyncTime - val delayRemaining = maxOf(0, 1000 - elapsedSyncTime) - - // Ensure minimum 1s delay to prevent flicker for rapid event creation - if (delayRemaining > 0) { - delay(delayRemaining) - } - - setCalendarSyncDialogType(CalendarSyncDialogType.NONE) - updateCalendarSyncState() - - if (updatedEvent) { - logCalendarSyncSnackbar(CalendarSyncSnackbar.UPDATED) - setUiMessage(CoreR.string.core_snackbar_course_calendar_updated) - } else if (coursePreferences.isCalendarSyncEventsDialogShown(courseName)) { - logCalendarSyncSnackbar(CalendarSyncSnackbar.ADDED) - setUiMessage(CoreR.string.core_snackbar_course_calendar_added) - } else { - coursePreferences.setCalendarSyncEventsDialogShown(courseName) - setCalendarSyncDialogType(CalendarSyncDialogType.EVENTS_DIALOG) - } - } - } - - private fun updateCalendarSyncState() { - viewModelScope.launch { - val isCalendarSynced = calendarManager.isCalendarExists( - calendarTitle = _calendarSyncUIState.value.calendarTitle - ) - courseNotifier.send(CheckCalendarSyncEvent(isSynced = isCalendarSynced)) - } - } - - fun checkIfCalendarOutOfDate() { - val courseDates = _calendarSyncUIState.value.courseDates - if (courseDates.isNotEmpty()) { - _calendarSyncUIState.value.checkForOutOfSync.set(false) - val outdatedCalendarId = calendarManager.isCalendarOutOfDate( - calendarTitle = _calendarSyncUIState.value.calendarTitle, - courseDateBlocks = courseDates - ) - if (outdatedCalendarId != CalendarManager.CALENDAR_DOES_NOT_EXIST) { - setCalendarSyncDialogType(CalendarSyncDialogType.OUT_OF_SYNC_DIALOG) - } - } - } - - fun deleteCourseCalendar() { - if (calendarManager.hasPermissions()) { - viewModelScope.launch(Dispatchers.IO) { - val calendarId = getCalendarId() - if (calendarId != CalendarManager.CALENDAR_DOES_NOT_EXIST) { - calendarManager.deleteCalendar( - calendarId = calendarId, - ) - } - updateCalendarSyncState() - - } - logCalendarSyncSnackbar(CalendarSyncSnackbar.REMOVED) - setUiMessage(CoreR.string.core_snackbar_course_calendar_removed) - } - } - - fun openCalendarApp() { - calendarManager.openCalendarApp() - } - - private fun setUiMessage(@StringRes stringResId: Int) { - _calendarSyncUIState.update { - it.copy(uiMessage = AtomicReference(resourceManager.getString(stringResId))) - } - } - - private fun getCalendarId(): Long { - return calendarManager.createOrUpdateCalendar( - calendarTitle = _calendarSyncUIState.value.calendarTitle - ) - } - private fun isCalendarSyncEnabled(): Boolean { val calendarSync = corePreferences.appConfig.courseDatesCalendarSync return calendarSync.isEnabled && ((calendarSync.isSelfPacedEnabled && isSelfPaced) || @@ -437,41 +320,6 @@ class CourseContainerViewModel( ) } - fun logCalendarAddDates(action: Boolean) { - logCalendarSyncEvent( - CourseAnalyticsEvent.DATES_CALENDAR_SYNC_DIALOG_ACTION, - CalendarSyncDialog.ADD.getBuildMap(action) - ) - } - - fun logCalendarRemoveDates(action: Boolean) { - logCalendarSyncEvent( - CourseAnalyticsEvent.DATES_CALENDAR_SYNC_DIALOG_ACTION, - CalendarSyncDialog.REMOVE.getBuildMap(action) - ) - } - - fun logCalendarSyncedConfirmation(action: Boolean) { - logCalendarSyncEvent( - CourseAnalyticsEvent.DATES_CALENDAR_SYNC_DIALOG_ACTION, - CalendarSyncDialog.CONFIRMED.getBuildMap(action) - ) - } - - fun logCalendarSyncUpdate(action: Boolean) { - logCalendarSyncEvent( - CourseAnalyticsEvent.DATES_CALENDAR_SYNC_DIALOG_ACTION, - CalendarSyncDialog.UPDATE.getBuildMap(action) - ) - } - - private fun logCalendarSyncSnackbar(snackbar: CalendarSyncSnackbar) { - logCalendarSyncEvent( - CourseAnalyticsEvent.DATES_CALENDAR_SYNC_SNACKBAR, - snackbar.getBuildMap() - ) - } - private fun logCalendarSyncEvent( event: CourseAnalyticsEvent, param: Map = emptyMap(), 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 76197b93c..69f6e0559 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 @@ -44,7 +44,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -74,6 +73,7 @@ import org.openedx.core.extension.isNotEmptyThenLet import org.openedx.core.presentation.CoreAnalyticsScreen import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.presentation.dialog.alert.ActionDialogFragment +import org.openedx.core.presentation.settings.calendarsync.CalendarSyncState import org.openedx.core.presentation.settings.calendarsync.CalendarSyncUIState import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.WindowSize @@ -100,9 +100,8 @@ fun CourseDatesScreen( isFragmentResumed: Boolean, updateCourseStructure: () -> Unit ) { - val uiState by viewModel.uiState.observeAsState(DatesUIState.Loading) + val uiState by viewModel.uiState.collectAsState(DatesUIState.Loading) val uiMessage by viewModel.uiMessage.collectAsState(null) - val calendarSyncUIState by viewModel.calendarSyncUIState.collectAsState() val context = LocalContext.current CourseDatesUI( @@ -110,7 +109,6 @@ fun CourseDatesScreen( uiState = uiState, uiMessage = uiMessage, isSelfPaced = viewModel.isSelfPaced, - calendarSyncUIState = calendarSyncUIState, onItemClick = { block -> if (block.blockId.isNotEmpty()) { viewModel.getVerticalBlock(block.blockId) @@ -168,9 +166,9 @@ fun CourseDatesScreen( } } }, - onCalendarSyncSwitch = { isChecked -> - viewModel.handleCalendarSyncState(isChecked) - }, + onCalendarSyncStateClick = { + viewModel.calendarRouter.navigateToCalendarSettings(fragmentManager) + } ) } @@ -180,11 +178,10 @@ private fun CourseDatesUI( uiState: DatesUIState, uiMessage: UIMessage?, isSelfPaced: Boolean, - calendarSyncUIState: CalendarSyncUIState, onItemClick: (CourseDateBlock) -> Unit, onPLSBannerViewed: () -> Unit, onSyncDates: () -> Unit, - onCalendarSyncSwitch: (Boolean) -> Unit = {}, + onCalendarSyncStateClick: () -> Unit, ) { val scaffoldState = rememberScaffoldState() @@ -249,16 +246,6 @@ private fun CourseDatesUI( val courseBanner = uiState.courseDatesResult.courseBanner val datesSection = uiState.courseDatesResult.datesSection - if (calendarSyncUIState.isCalendarSyncEnabled) { - item { - CalendarSyncCard( - modifier = Modifier.padding(top = 24.dp), - checked = calendarSyncUIState.isSynced, - onCalendarSync = onCalendarSyncSwitch - ) - } - } - if (courseBanner.isBannerAvailableForUserType(isSelfPaced)) { item { if (windowSize.isTablet) { @@ -277,6 +264,46 @@ private fun CourseDatesUI( } } + // Handle calendar sync state + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp) + .background( + MaterialTheme.appColors.cardViewBackground, + MaterialTheme.shapes.medium + ) + .border( + 0.75.dp, + MaterialTheme.appColors.cardViewBorder, + MaterialTheme.shapes.medium + ) + .clickable { + onCalendarSyncStateClick() + } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp, start = 16.dp, end = 8.dp, bottom = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = uiState.calendarSyncState.icon, + tint = uiState.calendarSyncState.tint, + contentDescription = null + ) + Text( + text = stringResource(uiState.calendarSyncState.longTitle), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textDark + ) + } + } + } + // Handle DatesSection.COMPLETED separately datesSection[DatesSection.COMPLETED]?.isNotEmptyThenLet { section -> item { @@ -650,14 +677,16 @@ private fun CourseDatesScreenPreview() { OpenEdXTheme { CourseDatesUI( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - uiState = DatesUIState.Dates(CourseDatesResult(mockedResponse, mockedCourseBannerInfo)), + uiState = DatesUIState.Dates( + CourseDatesResult(mockedResponse, mockedCourseBannerInfo), + CalendarSyncState.SYNCED + ), uiMessage = null, isSelfPaced = true, - calendarSyncUIState = mockCalendarSyncUIState, onItemClick = {}, onPLSBannerViewed = {}, onSyncDates = {}, - onCalendarSyncSwitch = {}, + onCalendarSyncStateClick = {}, ) } } @@ -669,14 +698,16 @@ private fun CourseDatesScreenTabletPreview() { OpenEdXTheme { CourseDatesUI( windowSize = WindowSize(WindowType.Medium, WindowType.Medium), - uiState = DatesUIState.Dates(CourseDatesResult(mockedResponse, mockedCourseBannerInfo)), + uiState = DatesUIState.Dates( + CourseDatesResult(mockedResponse, mockedCourseBannerInfo), + CalendarSyncState.SYNCED + ), uiMessage = null, isSelfPaced = true, - calendarSyncUIState = mockCalendarSyncUIState, onItemClick = {}, onPLSBannerViewed = {}, onSyncDates = {}, - onCalendarSyncSwitch = {}, + onCalendarSyncStateClick = {}, ) } } 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 4d6236b67..addad3199 100644 --- a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt @@ -1,7 +1,5 @@ package org.openedx.course.presentation.dates -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -12,10 +10,11 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.openedx.core.BaseViewModel +import org.openedx.core.CalendarRouter import org.openedx.core.R import org.openedx.core.UIMessage import org.openedx.core.config.Config -import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.interactor.CalendarInteractor import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.CourseBannerType import org.openedx.core.domain.model.CourseDateBlock @@ -24,15 +23,14 @@ import org.openedx.core.extension.getSequentialBlocks import org.openedx.core.extension.getVerticalBlocks 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.settings.calendarsync.CalendarSyncState 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.RefreshDates +import org.openedx.core.system.notifier.calendar.CalendarNotifier import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseAnalyticsEvent @@ -42,38 +40,28 @@ import org.openedx.core.R as CoreR class CourseDatesViewModel( val courseId: String, - courseTitle: String, private val enrollmentMode: String, private val courseNotifier: CourseNotifier, private val interactor: CourseInteractor, - private val calendarManager: CalendarManager, private val resourceManager: ResourceManager, - private val corePreferences: CorePreferences, private val courseAnalytics: CourseAnalytics, private val config: Config, - val courseRouter: CourseRouter + private val calendarInteractor: CalendarInteractor, + private val calendarNotifier: CalendarNotifier, + val courseRouter: CourseRouter, + val calendarRouter: CalendarRouter ) : BaseViewModel() { var isSelfPaced = true - private val _uiState = MutableLiveData(DatesUIState.Loading) - val uiState: LiveData - get() = _uiState + private val _uiState = MutableStateFlow(DatesUIState.Loading) + val uiState: StateFlow + get() = _uiState.asStateFlow() private val _uiMessage = MutableSharedFlow() val uiMessage: SharedFlow get() = _uiMessage.asSharedFlow() - private val _calendarSyncUIState = MutableStateFlow( - CalendarSyncUIState( - isCalendarSyncEnabled = isCalendarSyncEnabled(), - calendarTitle = calendarManager.getCourseCalendarTitle(courseTitle), - isSynced = false, - ) - ) - val calendarSyncUIState: StateFlow = - _calendarSyncUIState.asStateFlow() - private var courseBannerType: CourseBannerType = CourseBannerType.BLANK private var courseStructure: CourseStructure? = null @@ -83,19 +71,24 @@ class CourseDatesViewModel( viewModelScope.launch { courseNotifier.notifier.collect { event -> when (event) { - is CheckCalendarSyncEvent -> { - _calendarSyncUIState.update { it.copy(isSynced = event.isSynced) } - } - is RefreshDates -> { loadingCourseDatesInternal() } } } } + viewModelScope.launch { + calendarNotifier.notifier.collect { + (_uiState.value as? DatesUIState.Dates)?.let { currentUiState -> + val courseDates = currentUiState.courseDatesResult.datesSection.values.flatten() + _uiState.update { + (it as DatesUIState.Dates).copy(calendarSyncState = getCalendarState(courseDates)) + } + } + } + } loadingCourseDatesInternal() - updateAndFetchCalendarSyncState() } private fun loadingCourseDatesInternal() { @@ -107,7 +100,9 @@ class CourseDatesViewModel( if (datesResponse.datesSection.isEmpty()) { _uiState.value = DatesUIState.Empty } else { - _uiState.value = DatesUIState.Dates(datesResponse) + val courseDates = datesResponse.datesSection.values.flatten() + val calendarState = getCalendarState(courseDates) + _uiState.value = DatesUIState.Dates(datesResponse, calendarState) courseBannerType = datesResponse.courseBanner.bannerType checkIfCalendarOutOfDate() } @@ -159,40 +154,6 @@ class CourseDatesViewModel( } } - fun handleCalendarSyncState(isChecked: Boolean) { - logCalendarSyncToggle(isChecked) - setCalendarSyncDialogType( - when { - isChecked && calendarManager.hasPermissions() -> CalendarSyncDialogType.SYNC_DIALOG - isChecked -> CalendarSyncDialogType.PERMISSION_DIALOG - else -> CalendarSyncDialogType.UN_SYNC_DIALOG - } - ) - } - - private fun updateAndFetchCalendarSyncState(): Boolean { - val isCalendarSynced = calendarManager.isCalendarExists( - calendarTitle = _calendarSyncUIState.value.calendarTitle - ) - _calendarSyncUIState.update { it.copy(isSynced = isCalendarSynced) } - return isCalendarSynced - } - - private fun setCalendarSyncDialogType(dialog: CalendarSyncDialogType) { - val value = _uiState.value - if (value is DatesUIState.Dates) { - viewModelScope.launch { - courseNotifier.send( - CreateCalendarSyncEvent( - courseDates = value.courseDatesResult.datesSection.values.flatten(), - dialogType = dialog.name, - checkOutOfSync = false, - ) - ) - } - } - } - private fun checkIfCalendarOutOfDate() { val value = _uiState.value if (value is DatesUIState.Dates) { @@ -208,10 +169,27 @@ class CourseDatesViewModel( } } - private fun isCalendarSyncEnabled(): Boolean { - val calendarSync = corePreferences.appConfig.courseDatesCalendarSync - return calendarSync.isEnabled && ((calendarSync.isSelfPacedEnabled && isSelfPaced) || - (calendarSync.isInstructorPacedEnabled && !isSelfPaced)) + private suspend fun getCalendarState(courseDates: List): CalendarSyncState { + val courseCalendarState = calendarInteractor.getCourseCalendarStateByIdFromCache(courseId) + return when { + courseCalendarState?.isCourseSyncEnabled != true -> CalendarSyncState.OFFLINE + !isCourseCalendarUpToDate(courseDates) -> CalendarSyncState.SYNC_FAILED + else -> CalendarSyncState.SYNCED + } + } + + private suspend fun isCourseCalendarUpToDate(courseDateBlocks: List): Boolean { + val oldChecksum = getCourseCalendarStateChecksum() + val newChecksum = getCourseChecksum(courseDateBlocks) + return newChecksum == oldChecksum + } + + private fun getCourseChecksum(courseDateBlocks: List): Int { + return courseDateBlocks.sumOf { it.hashCode() } + } + + private suspend fun getCourseCalendarStateChecksum(): Int? { + return calendarInteractor.getCourseCalendarStateByIdFromCache(courseId)?.checksum } fun logPlsBannerViewed() { @@ -237,18 +215,6 @@ class CourseDatesViewModel( logDatesEvent(CourseAnalyticsEvent.DATES_COURSE_COMPONENT_CLICKED, params) } - private fun logCalendarSyncToggle(isChecked: Boolean) { - logDatesEvent( - CourseAnalyticsEvent.DATES_CALENDAR_SYNC_TOGGLE, - buildMap { - put( - CourseAnalyticsKey.ACTION.key, - if (isChecked) CourseAnalyticsKey.ON.key else CourseAnalyticsKey.OFF.key - ) - } - ) - } - private fun logDatesEvent( event: CourseAnalyticsEvent, param: Map = emptyMap(), diff --git a/course/src/main/java/org/openedx/course/presentation/dates/DashboardUIState.kt b/course/src/main/java/org/openedx/course/presentation/dates/DashboardUIState.kt index 8ff75239f..18aebac3f 100644 --- a/course/src/main/java/org/openedx/course/presentation/dates/DashboardUIState.kt +++ b/course/src/main/java/org/openedx/course/presentation/dates/DashboardUIState.kt @@ -1,12 +1,14 @@ package org.openedx.course.presentation.dates import org.openedx.core.domain.model.CourseDatesResult +import org.openedx.core.presentation.settings.calendarsync.CalendarSyncState -sealed class DatesUIState { +sealed interface DatesUIState { data class Dates( val courseDatesResult: CourseDatesResult, - ) : DatesUIState() + val calendarSyncState: CalendarSyncState + ) : DatesUIState - object Empty : DatesUIState() - object Loading : DatesUIState() + data object Empty : DatesUIState + data object Loading : DatesUIState } 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 f049e3751..63ad22b05 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 @@ -33,12 +33,11 @@ import org.openedx.core.domain.model.AppConfig import org.openedx.core.domain.model.CourseDatesCalendarSync import org.openedx.core.domain.model.CourseStructure import org.openedx.core.domain.model.CoursewareAccess -import org.openedx.core.system.CalendarManager import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseStructureUpdated -import org.openedx.course.data.storage.CoursePreferences +import org.openedx.core.worker.CalendarSyncScheduler import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseAnalyticsEvent @@ -57,19 +56,17 @@ class CourseContainerViewModelTest { private val resourceManager = mockk() private val config = mockk() private val interactor = mockk() - private val calendarManager = mockk() private val networkConnection = mockk() private val notifier = spyk() private val analytics = mockk() private val corePreferences = mockk() - private val coursePreferences = mockk() private val mockBitmap = mockk() private val imageProcessor = mockk() private val courseRouter = mockk() private val courseApi = mockk() + private val calendarSyncScheduler = mockk() private val openEdx = "OpenEdx" - private val calendarTitle = "OpenEdx - Abc" private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" @@ -139,7 +136,6 @@ class CourseContainerViewModelTest { every { corePreferences.user } returns user every { corePreferences.appConfig } returns appConfig every { notifier.notifier } returns emptyFlow() - every { calendarManager.getCourseCalendarTitle(any()) } returns calendarTitle every { config.getApiHostURL() } returns "baseUrl" every { imageProcessor.loadImage(any(), any(), any()) } returns Unit every { imageProcessor.applyBlur(any(), any()) } returns mockBitmap @@ -159,15 +155,14 @@ class CourseContainerViewModelTest { "", config, interactor, - calendarManager, resourceManager, notifier, networkConnection, corePreferences, - coursePreferences, analytics, imageProcessor, - courseRouter + calendarSyncScheduler, + courseRouter, ) every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseStructure(any(), any()) } throws UnknownHostException() @@ -193,14 +188,13 @@ class CourseContainerViewModelTest { "", config, interactor, - calendarManager, resourceManager, notifier, networkConnection, corePreferences, - coursePreferences, analytics, imageProcessor, + calendarSyncScheduler, courseRouter ) every { networkConnection.isOnline() } returns true @@ -227,14 +221,13 @@ class CourseContainerViewModelTest { "", config, interactor, - calendarManager, resourceManager, notifier, networkConnection, corePreferences, - coursePreferences, analytics, imageProcessor, + calendarSyncScheduler, courseRouter ) every { networkConnection.isOnline() } returns true @@ -260,14 +253,13 @@ class CourseContainerViewModelTest { "", config, interactor, - calendarManager, resourceManager, notifier, networkConnection, corePreferences, - coursePreferences, analytics, imageProcessor, + calendarSyncScheduler, courseRouter ) every { networkConnection.isOnline() } returns false @@ -296,14 +288,13 @@ class CourseContainerViewModelTest { "", config, interactor, - calendarManager, resourceManager, notifier, networkConnection, corePreferences, - coursePreferences, analytics, imageProcessor, + calendarSyncScheduler, courseRouter ) coEvery { interactor.getCourseStructure(any(), true) } throws UnknownHostException() @@ -327,14 +318,13 @@ class CourseContainerViewModelTest { "", config, interactor, - calendarManager, resourceManager, notifier, networkConnection, corePreferences, - coursePreferences, analytics, imageProcessor, + calendarSyncScheduler, courseRouter ) coEvery { interactor.getCourseStructure(any(), true) } throws Exception() @@ -358,14 +348,13 @@ class CourseContainerViewModelTest { "", config, interactor, - calendarManager, resourceManager, notifier, networkConnection, corePreferences, - coursePreferences, analytics, imageProcessor, + calendarSyncScheduler, courseRouter ) coEvery { interactor.getCourseStructure(any(), true) } returns courseStructure 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 11ffb4932..ca0c18c79 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 @@ -23,25 +23,26 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule +import org.openedx.core.CalendarRouter import org.openedx.core.R import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.data.model.DateType -import org.openedx.core.data.model.User -import org.openedx.core.data.storage.CorePreferences -import org.openedx.core.domain.model.AppConfig +import org.openedx.core.domain.interactor.CalendarInteractor +import org.openedx.core.domain.model.CourseCalendarState import org.openedx.core.domain.model.CourseDateBlock import org.openedx.core.domain.model.CourseDatesBannerInfo -import org.openedx.core.domain.model.CourseDatesCalendarSync import org.openedx.core.domain.model.CourseDatesResult import org.openedx.core.domain.model.CourseStructure import org.openedx.core.domain.model.CoursewareAccess import org.openedx.core.domain.model.DatesSection -import org.openedx.core.system.CalendarManager import org.openedx.core.system.ResourceManager import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEvent import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier +import org.openedx.core.system.notifier.calendar.CalendarEvent +import org.openedx.core.system.notifier.calendar.CalendarNotifier +import org.openedx.core.system.notifier.calendar.CalendarSynced import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseRouter @@ -58,31 +59,17 @@ class CourseDatesViewModelTest { private val resourceManager = mockk() private val notifier = mockk() private val interactor = mockk() - private val calendarManager = mockk() - private val corePreferences = mockk() private val analytics = mockk() private val config = mockk() private val courseRouter = mockk() + private val calendarRouter = mockk() + private val calendarNotifier = mockk() + private val calendarInteractor = mockk() private val openEdx = "OpenEdx" - private val calendarTitle = "OpenEdx - Abc" private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" - private val user = User( - id = 0, - username = "", - email = "", - name = "", - ) - private val appConfig = AppConfig( - CourseDatesCalendarSync( - isEnabled = true, - isSelfPacedEnabled = true, - isInstructorPacedEnabled = true, - isDeepLinkEnabled = false, - ) - ) private val dateBlock = CourseDateBlock( complete = false, date = Date(), @@ -146,14 +133,16 @@ class CourseDatesViewModelTest { every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong coEvery { interactor.getCourseStructure(any()) } returns courseStructure - every { corePreferences.user } returns user - every { corePreferences.appConfig } returns appConfig every { notifier.notifier } returns flowOf(CourseLoading(false)) - every { calendarManager.getCourseCalendarTitle(any()) } returns calendarTitle - every { calendarManager.isCalendarExists(any()) } returns true coEvery { notifier.send(any()) } returns Unit coEvery { notifier.send(any()) } returns Unit - coEvery { notifier.send(any()) } returns Unit + every { calendarNotifier.notifier } returns flowOf(CalendarSynced) + coEvery { calendarNotifier.send(any()) } returns Unit + coEvery { calendarInteractor.getCourseCalendarStateByIdFromCache(any()) } returns CourseCalendarState( + 0, + "", + true + ) } @After @@ -166,15 +155,15 @@ class CourseDatesViewModelTest { val viewModel = CourseDatesViewModel( "id", "", - "", notifier, interactor, - calendarManager, resourceManager, - corePreferences, analytics, config, - courseRouter + calendarInteractor, + calendarNotifier, + courseRouter, + calendarRouter, ) coEvery { interactor.getCourseDates(any()) } throws UnknownHostException() val message = async { @@ -195,15 +184,15 @@ class CourseDatesViewModelTest { val viewModel = CourseDatesViewModel( "id", "", - "", notifier, interactor, - calendarManager, resourceManager, - corePreferences, analytics, config, - courseRouter + calendarInteractor, + calendarNotifier, + courseRouter, + calendarRouter, ) coEvery { interactor.getCourseDates(any()) } throws Exception() val message = async { @@ -224,15 +213,15 @@ class CourseDatesViewModelTest { val viewModel = CourseDatesViewModel( "id", "", - "", notifier, interactor, - calendarManager, resourceManager, - corePreferences, analytics, config, - courseRouter + calendarInteractor, + calendarNotifier, + courseRouter, + calendarRouter, ) coEvery { interactor.getCourseDates(any()) } returns mockedCourseDatesResult val message = async { @@ -253,15 +242,15 @@ class CourseDatesViewModelTest { val viewModel = CourseDatesViewModel( "id", "", - "", notifier, interactor, - calendarManager, resourceManager, - corePreferences, analytics, config, - courseRouter + calendarInteractor, + calendarNotifier, + courseRouter, + calendarRouter, ) coEvery { interactor.getCourseDates(any()) } returns CourseDatesResult( datesSection = linkedMapOf(), diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt index 3392ed7bd..c2668f766 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt @@ -527,7 +527,7 @@ fun EmptyState( horizontalAlignment = Alignment.CenterHorizontally ) { Icon( - painter = painterResource(id = org.openedx.dashboard.R.drawable.dashboard_ic_book), + painter = painterResource(id = R.drawable.core_ic_book), tint = MaterialTheme.appColors.textFieldBorder, contentDescription = null ) diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt index 7401f6304..a6d375569 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt @@ -384,7 +384,7 @@ private fun ViewAllItem( ) { Icon( modifier = Modifier.size(48.dp), - painter = painterResource(id = R.drawable.dashboard_ic_book), + painter = painterResource(id = CoreR.drawable.core_ic_book), tint = MaterialTheme.appColors.textFieldBorder, contentDescription = null ) @@ -749,7 +749,7 @@ private fun NoCoursesInfo( horizontalAlignment = Alignment.CenterHorizontally ) { Icon( - painter = painterResource(id = R.drawable.dashboard_ic_book), + painter = painterResource(id = CoreR.drawable.core_ic_book), tint = MaterialTheme.appColors.textFieldBorder, contentDescription = null ) diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModel.kt b/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModel.kt index c68dd1c47..817363fa3 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModel.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModel.kt @@ -15,6 +15,7 @@ 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.worker.CalendarSyncScheduler import org.openedx.discovery.domain.interactor.DiscoveryInteractor import org.openedx.discovery.domain.model.Course import org.openedx.discovery.presentation.DiscoveryAnalytics @@ -30,6 +31,7 @@ class CourseDetailsViewModel( private val resourceManager: ResourceManager, private val notifier: DiscoveryNotifier, private val analytics: DiscoveryAnalytics, + private val calendarSyncScheduler: CalendarSyncScheduler, ) : BaseViewModel() { val apiHostUrl get() = config.getApiHostURL() val isUserLoggedIn get() = corePreferences.user != null @@ -92,6 +94,7 @@ class CourseDetailsViewModel( if (courseData is CourseDetailsUIState.CourseData) { _uiState.value = courseData.copy(course = course) courseEnrollSuccessEvent(id, title) + calendarSyncScheduler.requestImmediateSync(id) notifier.send(CourseDashboardUpdate()) } } catch (e: Exception) { diff --git a/discovery/src/test/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModelTest.kt b/discovery/src/test/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModelTest.kt index 712a122ab..e62cd0b38 100644 --- a/discovery/src/test/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModelTest.kt +++ b/discovery/src/test/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModelTest.kt @@ -30,6 +30,7 @@ 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.worker.CalendarSyncScheduler import org.openedx.discovery.domain.interactor.DiscoveryInteractor import org.openedx.discovery.domain.model.Course import org.openedx.discovery.presentation.DiscoveryAnalytics @@ -51,6 +52,7 @@ class CourseDetailsViewModelTest { private val networkConnection = mockk() private val notifier = spyk() private val analytics = mockk() + private val calendarSyncScheduler = mockk() private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" @@ -85,6 +87,7 @@ class CourseDetailsViewModelTest { every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong every { config.getApiHostURL() } returns "http://localhost:8000" + every { calendarSyncScheduler.requestImmediateSync(any()) } returns Unit } @After @@ -102,7 +105,8 @@ class CourseDetailsViewModelTest { interactor, resourceManager, notifier, - analytics + analytics, + calendarSyncScheduler, ) every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseDetails(any()) } throws UnknownHostException() @@ -126,7 +130,8 @@ class CourseDetailsViewModelTest { interactor, resourceManager, notifier, - analytics + analytics, + calendarSyncScheduler, ) every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseDetails(any()) } throws Exception() @@ -150,7 +155,8 @@ class CourseDetailsViewModelTest { interactor, resourceManager, notifier, - analytics + analytics, + calendarSyncScheduler, ) every { config.isPreLoginExperienceEnabled() } returns false every { preferencesManager.user } returns null @@ -175,7 +181,8 @@ class CourseDetailsViewModelTest { interactor, resourceManager, notifier, - analytics + analytics, + calendarSyncScheduler, ) every { config.isPreLoginExperienceEnabled() } returns false every { preferencesManager.user } returns null @@ -201,7 +208,8 @@ class CourseDetailsViewModelTest { interactor, resourceManager, notifier, - analytics + analytics, + calendarSyncScheduler, ) every { config.isPreLoginExperienceEnabled() } returns false every { preferencesManager.user } returns null @@ -232,7 +240,8 @@ class CourseDetailsViewModelTest { interactor, resourceManager, notifier, - analytics + analytics, + calendarSyncScheduler, ) every { config.isPreLoginExperienceEnabled() } returns false every { preferencesManager.user } returns null @@ -274,7 +283,8 @@ class CourseDetailsViewModelTest { interactor, resourceManager, notifier, - analytics + analytics, + calendarSyncScheduler, ) every { config.isPreLoginExperienceEnabled() } returns false every { preferencesManager.user } returns null @@ -328,7 +338,8 @@ class CourseDetailsViewModelTest { interactor, resourceManager, notifier, - analytics + analytics, + calendarSyncScheduler, ) val overview = viewModel.getCourseAboutBody(ULong.MAX_VALUE, ULong.MIN_VALUE) val count = overview.contains("black") @@ -345,7 +356,8 @@ class CourseDetailsViewModelTest { interactor, resourceManager, notifier, - analytics + analytics, + calendarSyncScheduler, ) val overview = viewModel.getCourseAboutBody(ULong.MAX_VALUE, ULong.MAX_VALUE) val count = overview.contains("black") diff --git a/profile/src/main/java/org/openedx/profile/data/repository/ProfileRepository.kt b/profile/src/main/java/org/openedx/profile/data/repository/ProfileRepository.kt index ce5580a45..561d73c05 100644 --- a/profile/src/main/java/org/openedx/profile/data/repository/ProfileRepository.kt +++ b/profile/src/main/java/org/openedx/profile/data/repository/ProfileRepository.kt @@ -1,9 +1,9 @@ package org.openedx.profile.data.repository -import androidx.room.RoomDatabase import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody.Companion.asRequestBody import org.openedx.core.ApiConstants +import org.openedx.core.DatabaseManager import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.profile.data.api.ProfileApi @@ -14,9 +14,9 @@ import java.io.File class ProfileRepository( private val config: Config, private val api: ProfileApi, - private val room: RoomDatabase, private val profilePreferences: ProfilePreferences, private val corePreferences: CorePreferences, + private val databaseManager: DatabaseManager ) { suspend fun getAccount(): Account { @@ -61,8 +61,8 @@ class ProfileRepository( ApiConstants.TOKEN_TYPE_REFRESH ) } finally { - corePreferences.clear() - room.clearAllTables() + corePreferences.clearCorePreferences() + databaseManager.clearTables() } } } diff --git a/profile/src/main/java/org/openedx/profile/presentation/ProfileRouter.kt b/profile/src/main/java/org/openedx/profile/presentation/ProfileRouter.kt index fd7514bd5..e9f67ad48 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/ProfileRouter.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/ProfileRouter.kt @@ -22,5 +22,5 @@ interface ProfileRouter { fun navigateToManageAccount(fm: FragmentManager) - fun navigateToCalendarSettings(fm: FragmentManager) + fun navigateToCoursesToSync(fm: FragmentManager) } 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 8d49fb8ec..c1dc22df2 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 @@ -16,6 +16,8 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.material.icons.Icons @@ -86,6 +88,7 @@ private fun CalendarAccessDialog( onCancelClick: () -> Unit, onGrantCalendarAccessClick: () -> Unit ) { + val scrollState = rememberScrollState() DefaultDialogBox( modifier = modifier, onDismissClick = onCancelClick @@ -93,6 +96,7 @@ private fun CalendarAccessDialog( Column( modifier = Modifier .fillMaxWidth() + .verticalScroll(scrollState) .padding(20.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp) diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarFragment.kt index 8a8794c94..112a4e774 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarFragment.kt @@ -1,73 +1,27 @@ package org.openedx.profile.presentation.calendar -import android.content.res.Configuration import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.widthIn -import androidx.compose.material.Card -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Scaffold -import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Autorenew -import androidx.compose.material.icons.rounded.CalendarToday -import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.compose.ui.platform.testTag -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.tooling.preview.Preview -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp import androidx.fragment.app.Fragment -import org.koin.androidx.viewmodel.ext.android.viewModel -import org.openedx.core.ui.OpenEdXButton -import org.openedx.core.ui.Toolbar +import org.koin.androidx.compose.koinViewModel import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType -import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.rememberWindowSize -import org.openedx.core.ui.settingsHeaderBackground -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.profile.R -import org.openedx.core.R as CoreR class CalendarFragment : Fragment() { - private val viewModel by viewModel() - private val permissionLauncher = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() ) { isGranted -> if (!isGranted.containsValue(false)) { - val dialog = NewCalendarDialogFragment.newInstance() + val dialog = NewCalendarDialogFragment.newInstance(NewCalendarDialogType.CREATE_NEW) dialog.show( requireActivity().supportFragmentManager, NewCalendarDialogFragment.DIALOG_TAG @@ -90,14 +44,30 @@ class CalendarFragment : Fragment() { setContent { OpenEdXTheme { val windowSize = rememberWindowSize() + val viewModel: CalendarViewModel = koinViewModel() + val uiState by viewModel.uiState.collectAsState() - CalendarScreen( + CalendarView( windowSize = windowSize, + uiState = uiState, setUpCalendarSync = { viewModel.setUpCalendarSync(permissionLauncher) }, onBackClick = { requireActivity().supportFragmentManager.popBackStack() + }, + onCalendarSyncSwitchClick = { + viewModel.setCalendarSyncEnabled(it, requireActivity().supportFragmentManager) + }, + onChangeSyncOptionClick = { + val dialog = NewCalendarDialogFragment.newInstance(NewCalendarDialogType.UPDATE) + dialog.show( + requireActivity().supportFragmentManager, + NewCalendarDialogFragment.DIALOG_TAG + ) + }, + onCourseToSyncClick = { + viewModel.navigateToCoursesToSync(requireActivity().supportFragmentManager) } ) } @@ -105,160 +75,30 @@ class CalendarFragment : Fragment() { } } -@OptIn(ExperimentalComposeUiApi::class) @Composable -private fun CalendarScreen( +private fun CalendarView( windowSize: WindowSize, + uiState: CalendarUIState, setUpCalendarSync: () -> Unit, - onBackClick: () -> Unit + onBackClick: () -> Unit, + onChangeSyncOptionClick: () -> Unit, + onCourseToSyncClick: () -> Unit, + onCalendarSyncSwitchClick: (Boolean) -> Unit, ) { - val scaffoldState = rememberScaffoldState() - - Scaffold( - modifier = Modifier - .fillMaxSize() - .semantics { - testTagsAsResourceId = true - }, - scaffoldState = scaffoldState - ) { paddingValues -> - - val contentWidth by remember(key1 = windowSize) { - mutableStateOf( - windowSize.windowSizeValue( - expanded = Modifier.widthIn(Dp.Unspecified, 420.dp), - compact = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) - ) - ) - } - - val topBarWidth by remember(key1 = windowSize) { - mutableStateOf( - windowSize.windowSizeValue( - expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), - compact = Modifier - .fillMaxWidth() - ) - ) - } - - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.TopCenter - ) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - .settingsHeaderBackground() - .statusBarsInset(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Toolbar( - modifier = topBarWidth - .displayCutoutForLandscape(), - label = stringResource(id = R.string.profile_dates_and_calendar), - canShowBackBtn = true, - labelTint = MaterialTheme.appColors.settingsTitleContent, - iconTint = MaterialTheme.appColors.settingsTitleContent, - onBackClick = onBackClick - ) - - Box( - modifier = Modifier - .fillMaxSize() - .clip(MaterialTheme.appShapes.screenBackgroundShape) - .background(MaterialTheme.appColors.background) - .displayCutoutForLandscape(), - contentAlignment = Alignment.TopCenter - ) { - Column( - modifier = contentWidth.padding(vertical = 28.dp), - ) { - Text( - modifier = Modifier.testTag("txt_settings"), - text = stringResource(id = CoreR.string.core_settings), - style = MaterialTheme.appTypography.labelLarge, - color = MaterialTheme.appColors.textSecondary - ) - Spacer(modifier = Modifier.height(14.dp)) - Card( - shape = MaterialTheme.appShapes.cardShape, - elevation = 0.dp, - backgroundColor = MaterialTheme.appColors.cardViewBackground - ) { - Column( - modifier = Modifier - .padding(horizontal = 20.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Box( - modifier = Modifier - .padding(vertical = 28.dp), - contentAlignment = Alignment.Center - ) { - Icon( - modifier = Modifier - .fillMaxWidth() - .height(148.dp), - tint = MaterialTheme.appColors.textDark, - imageVector = Icons.Rounded.CalendarToday, - contentDescription = null - ) - Icon( - modifier = Modifier - .fillMaxWidth() - .padding(top = 30.dp) - .height(60.dp), - tint = MaterialTheme.appColors.textDark, - imageVector = Icons.Default.Autorenew, - contentDescription = null - ) - } - Text( - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - text = stringResource(id = R.string.profile_calendar_sync), - style = MaterialTheme.appTypography.titleMedium, - color = MaterialTheme.appColors.textDark - ) - Spacer(modifier = Modifier.height(16.dp)) - Text( - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - text = stringResource(id = R.string.profile_calendar_sync_description), - style = MaterialTheme.appTypography.labelLarge, - color = MaterialTheme.appColors.textDark - ) - Spacer(modifier = Modifier.height(16.dp)) - OpenEdXButton( - modifier = Modifier.fillMaxWidth(0.75f), - text = stringResource(id = R.string.profile_set_up_calendar_sync), - onClick = { - setUpCalendarSync() - } - ) - Spacer(modifier = Modifier.height(24.dp)) - } - } - } - } - } - } - } -} - -@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun CalendarScreenPreview() { - OpenEdXTheme { - CalendarScreen( - windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - setUpCalendarSync = {}, - onBackClick = {} + if (!uiState.isCalendarExist) { + CalendarSetUpView( + windowSize = windowSize, + setUpCalendarSync = setUpCalendarSync, + onBackClick = onBackClick + ) + } else { + CalendarSettingsView( + windowSize = windowSize, + uiState = uiState, + onBackClick = onBackClick, + onCalendarSyncSwitchClick = onCalendarSyncSwitchClick, + onChangeSyncOptionClick = onChangeSyncOptionClick, + onCourseToSyncClick = onCourseToSyncClick ) } } diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarSetUpView.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarSetUpView.kt new file mode 100644 index 000000000..06a842630 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarSetUpView.kt @@ -0,0 +1,213 @@ +package org.openedx.profile.presentation.calendar + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Card +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Autorenew +import androidx.compose.material.icons.rounded.CalendarToday +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +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.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.Toolbar +import org.openedx.core.ui.WindowSize +import org.openedx.core.ui.WindowType +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.settingsHeaderBackground +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.profile.R + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun CalendarSetUpView( + windowSize: WindowSize, + setUpCalendarSync: () -> Unit, + onBackClick: () -> Unit +) { + val scaffoldState = rememberScaffoldState() + val scrollState = rememberScrollState() + + Scaffold( + modifier = Modifier + .fillMaxSize() + .semantics { + testTagsAsResourceId = true + }, + scaffoldState = scaffoldState + ) { paddingValues -> + + val contentWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 420.dp), + compact = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + ) + ) + } + + val topBarWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), + compact = Modifier + .fillMaxWidth() + ) + ) + } + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.TopCenter + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .settingsHeaderBackground() + .statusBarsInset(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Toolbar( + modifier = topBarWidth + .displayCutoutForLandscape(), + label = stringResource(id = R.string.profile_dates_and_calendar), + canShowBackBtn = true, + labelTint = MaterialTheme.appColors.settingsTitleContent, + iconTint = MaterialTheme.appColors.settingsTitleContent, + onBackClick = onBackClick + ) + + Box( + modifier = Modifier + .fillMaxSize() + .clip(MaterialTheme.appShapes.screenBackgroundShape) + .background(MaterialTheme.appColors.background) + .displayCutoutForLandscape(), + contentAlignment = Alignment.TopCenter + ) { + Column( + modifier = contentWidth + .verticalScroll(scrollState) + .padding(vertical = 28.dp), + ) { + Text( + modifier = Modifier.testTag("txt_calendar_sync"), + text = stringResource(id = R.string.profile_calendar_sync), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textSecondary + ) + Spacer(modifier = Modifier.height(14.dp)) + Card( + shape = MaterialTheme.appShapes.cardShape, + elevation = 0.dp, + backgroundColor = MaterialTheme.appColors.cardViewBackground + ) { + Column( + modifier = Modifier + .padding(horizontal = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .padding(vertical = 28.dp), + contentAlignment = Alignment.Center + ) { + Icon( + modifier = Modifier + .fillMaxWidth() + .height(148.dp), + tint = MaterialTheme.appColors.textDark, + imageVector = Icons.Rounded.CalendarToday, + contentDescription = null + ) + Icon( + modifier = Modifier + .fillMaxWidth() + .padding(top = 30.dp) + .height(60.dp), + tint = MaterialTheme.appColors.textDark, + imageVector = Icons.Default.Autorenew, + contentDescription = null + ) + } + Text( + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + text = stringResource(id = R.string.profile_calendar_sync), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textDark + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + text = stringResource(id = R.string.profile_calendar_sync_description), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textDark + ) + Spacer(modifier = Modifier.height(16.dp)) + OpenEdXButton( + modifier = Modifier.fillMaxWidth(0.75f), + text = stringResource(id = R.string.profile_set_up_calendar_sync), + onClick = { + setUpCalendarSync() + } + ) + Spacer(modifier = Modifier.height(24.dp)) + } + } + } + } + } + } + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun CalendarScreenPreview() { + OpenEdXTheme { + CalendarSetUpView( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + setUpCalendarSync = {}, + onBackClick = {} + ) + } +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarSettingsView.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarSettingsView.kt new file mode 100644 index 000000000..bce3ede77 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarSettingsView.kt @@ -0,0 +1,323 @@ +package org.openedx.profile.presentation.calendar + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Card +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.LocalMinimumInteractiveComponentEnforcement +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Switch +import androidx.compose.material.SwitchDefaults +import androidx.compose.material.Text +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.openedx.core.domain.model.CalendarData +import org.openedx.core.presentation.settings.calendarsync.CalendarSyncState +import org.openedx.core.ui.OpenEdXOutlinedButton +import org.openedx.core.ui.Toolbar +import org.openedx.core.ui.WindowSize +import org.openedx.core.ui.WindowType +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.settingsHeaderBackground +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.profile.R +import org.openedx.profile.presentation.ui.SettingsItem + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun CalendarSettingsView( + windowSize: WindowSize, + uiState: CalendarUIState, + onCalendarSyncSwitchClick: (Boolean) -> Unit, + onChangeSyncOptionClick: () -> Unit, + onCourseToSyncClick: () -> Unit, + onBackClick: () -> Unit +) { + val scaffoldState = rememberScaffoldState() + val scrollState = rememberScrollState() + + Scaffold( + modifier = Modifier + .fillMaxSize() + .semantics { + testTagsAsResourceId = true + }, + scaffoldState = scaffoldState + ) { paddingValues -> + + val contentWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 420.dp), + compact = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + ) + ) + } + + val topBarWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), + compact = Modifier + .fillMaxWidth() + ) + ) + } + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.TopCenter + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .settingsHeaderBackground() + .statusBarsInset(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Toolbar( + modifier = topBarWidth + .displayCutoutForLandscape(), + label = stringResource(id = R.string.profile_dates_and_calendar), + canShowBackBtn = true, + labelTint = MaterialTheme.appColors.settingsTitleContent, + iconTint = MaterialTheme.appColors.settingsTitleContent, + onBackClick = onBackClick + ) + + Box( + modifier = Modifier + .fillMaxSize() + .clip(MaterialTheme.appShapes.screenBackgroundShape) + .background(MaterialTheme.appColors.background) + .displayCutoutForLandscape(), + contentAlignment = Alignment.TopCenter + ) { + Column( + modifier = contentWidth + .verticalScroll(scrollState) + .padding(vertical = 28.dp), + ) { + if (uiState.calendarData != null) { + CalendarSyncSection( + isCourseCalendarSyncEnabled = uiState.isCalendarSyncEnabled, + calendarData = uiState.calendarData, + calendarSyncState = uiState.calendarSyncState, + onCalendarSyncSwitchClick = onCalendarSyncSwitchClick, + onChangeSyncOptionClick = onChangeSyncOptionClick + ) + } + Spacer(modifier = Modifier.height(20.dp)) + if (uiState.coursesSynced != null) { + CoursesToSyncSection( + coursesSynced = uiState.coursesSynced, + onCourseToSyncClick = onCourseToSyncClick + ) + } + } + } + } + } + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun CalendarSyncSection( + isCourseCalendarSyncEnabled: Boolean, + calendarData: CalendarData, + calendarSyncState: CalendarSyncState, + onCalendarSyncSwitchClick: (Boolean) -> Unit, + onChangeSyncOptionClick: () -> Unit +) { + Column { + SectionTitle(stringResource(id = R.string.profile_calendar_sync)) + Spacer(modifier = Modifier.height(8.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clip(MaterialTheme.appShapes.cardShape) + .background(MaterialTheme.appColors.cardViewBackground) + .padding(vertical = 8.dp, horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Box( + modifier = Modifier + .size(18.dp) + .clip(CircleShape) + .background(Color(calendarData.color)) + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = calendarData.title, + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textDark + ) + Text( + text = stringResource(id = calendarSyncState.title), + style = MaterialTheme.appTypography.labelSmall, + color = MaterialTheme.appColors.textFieldHint + ) + } + if (calendarSyncState == CalendarSyncState.SYNCHRONIZATION) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = MaterialTheme.appColors.primary + ) + } else { + Icon( + imageVector = calendarSyncState.icon, + tint = calendarSyncState.tint, + contentDescription = null + ) + } + } + Spacer(modifier = Modifier.height(16.dp)) + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier.weight(1f), + text = stringResource(R.string.profile_course_calendar_sync), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textDark + ) + CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) { + Switch( + modifier = Modifier + .padding(0.dp), + checked = isCourseCalendarSyncEnabled, + onCheckedChange = onCalendarSyncSwitchClick, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.appColors.textAccent + ) + ) + } + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.profile_currently_syncing_events), + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textPrimaryVariant + ) + Spacer(modifier = Modifier.height(16.dp)) + SyncOptionsButton( + onChangeSyncOptionClick = onChangeSyncOptionClick + ) + } +} + +@Composable +fun SyncOptionsButton( + onChangeSyncOptionClick: () -> Unit +) { + OpenEdXOutlinedButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.profile_change_sync_options), + backgroundColor = MaterialTheme.appColors.background, + borderColor = MaterialTheme.appColors.primaryButtonBackground, + textColor = MaterialTheme.appColors.primaryButtonBackground, + onClick = { + onChangeSyncOptionClick() + } + ) +} + +@Composable +fun CoursesToSyncSection( + coursesSynced: Int, + onCourseToSyncClick: () -> Unit +) { + Column { + SectionTitle(stringResource(R.string.profile_courses_to_sync)) + Spacer(modifier = Modifier.height(8.dp)) + Card( + modifier = Modifier, + shape = MaterialTheme.appShapes.cardShape, + elevation = 0.dp, + backgroundColor = MaterialTheme.appColors.cardViewBackground + ) { + SettingsItem( + text = stringResource(R.string.profile_syncing_courses, coursesSynced), + onClick = onCourseToSyncClick + ) + } + } +} + +@Composable +fun SectionTitle(title: String) { + Text( + text = title, + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textSecondary + ) +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun CalendarSettingsViewPreview() { + OpenEdXTheme { + CalendarSettingsView( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + uiState = CalendarUIState( + isCalendarExist = true, + calendarData = CalendarData("calendar", Color.Red.toArgb()), + calendarSyncState = CalendarSyncState.SYNCED, + isCalendarSyncEnabled = false, + coursesSynced = 5 + ), + onBackClick = {}, + onCalendarSyncSwitchClick = {}, + onChangeSyncOptionClick = {}, + onCourseToSyncClick = {} + ) + } +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarUIState.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarUIState.kt new file mode 100644 index 000000000..cf99e0fa2 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarUIState.kt @@ -0,0 +1,12 @@ +package org.openedx.profile.presentation.calendar + +import org.openedx.core.domain.model.CalendarData +import org.openedx.core.presentation.settings.calendarsync.CalendarSyncState + +data class CalendarUIState( + val isCalendarExist: Boolean, + val calendarData: CalendarData? = null, + val calendarSyncState: CalendarSyncState, + val isCalendarSyncEnabled: Boolean, + val coursesSynced: Int? +) diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarViewModel.kt index 316b689b4..658d7ca8e 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarViewModel.kt @@ -1,14 +1,139 @@ package org.openedx.profile.presentation.calendar import androidx.activity.result.ActivityResultLauncher +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import org.openedx.core.BaseViewModel +import org.openedx.core.data.storage.CalendarPreferences +import org.openedx.core.domain.interactor.CalendarInteractor +import org.openedx.core.presentation.settings.calendarsync.CalendarSyncState import org.openedx.core.system.CalendarManager +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.system.notifier.calendar.CalendarCreated +import org.openedx.core.system.notifier.calendar.CalendarNotifier +import org.openedx.core.system.notifier.calendar.CalendarSyncDisabled +import org.openedx.core.system.notifier.calendar.CalendarSyncFailed +import org.openedx.core.system.notifier.calendar.CalendarSyncOffline +import org.openedx.core.system.notifier.calendar.CalendarSynced +import org.openedx.core.system.notifier.calendar.CalendarSyncing +import org.openedx.core.worker.CalendarSyncScheduler +import org.openedx.profile.presentation.ProfileRouter class CalendarViewModel( - private val calendarManager: CalendarManager + private val calendarSyncScheduler: CalendarSyncScheduler, + private val calendarManager: CalendarManager, + private val calendarPreferences: CalendarPreferences, + private val calendarNotifier: CalendarNotifier, + private val calendarInteractor: CalendarInteractor, + private val profileRouter: ProfileRouter, + private val networkConnection: NetworkConnection, ) : BaseViewModel() { + private val calendarInitState: CalendarUIState + get() = CalendarUIState( + isCalendarExist = isCalendarExist(), + calendarData = null, + calendarSyncState = if (networkConnection.isOnline()) CalendarSyncState.SYNCED else CalendarSyncState.OFFLINE, + isCalendarSyncEnabled = calendarPreferences.isCalendarSyncEnabled, + coursesSynced = null + ) + + private val _uiState = MutableStateFlow(calendarInitState) + val uiState: StateFlow + get() = _uiState.asStateFlow() + + init { + calendarSyncScheduler.requestImmediateSync() + viewModelScope.launch { + calendarNotifier.notifier.collect { calendarEvent -> + when (calendarEvent) { + CalendarCreated -> { + calendarSyncScheduler.requestImmediateSync() + _uiState.update { it.copy(isCalendarExist = true) } + getCalendarData() + } + + CalendarSyncing -> { + _uiState.update { it.copy(calendarSyncState = CalendarSyncState.SYNCHRONIZATION) } + } + + CalendarSynced -> { + _uiState.update { it.copy(calendarSyncState = CalendarSyncState.SYNCED) } + updateSyncedCoursesCount() + } + + CalendarSyncFailed -> { + _uiState.update { it.copy(calendarSyncState = CalendarSyncState.SYNC_FAILED) } + updateSyncedCoursesCount() + } + + CalendarSyncOffline -> { + _uiState.update { it.copy(calendarSyncState = CalendarSyncState.OFFLINE) } + } + + CalendarSyncDisabled -> { + _uiState.update { calendarInitState } + } + } + } + } + + getCalendarData() + updateSyncedCoursesCount() + } + fun setUpCalendarSync(permissionLauncher: ActivityResultLauncher>) { permissionLauncher.launch(calendarManager.permissions) } + + fun setCalendarSyncEnabled(isEnabled: Boolean, fragmentManager: FragmentManager) { + if (!isEnabled) { + _uiState.value.calendarData?.let { + val dialog = DisableCalendarSyncDialogFragment.newInstance(it) + dialog.show( + fragmentManager, + DisableCalendarSyncDialogFragment.DIALOG_TAG + ) + } + } else { + calendarPreferences.isCalendarSyncEnabled = true + _uiState.update { it.copy(isCalendarSyncEnabled = true) } + calendarSyncScheduler.requestImmediateSync() + } + } + + fun navigateToCoursesToSync(fragmentManager: FragmentManager) { + profileRouter.navigateToCoursesToSync(fragmentManager) + } + + private fun getCalendarData() { + if (calendarManager.hasPermissions()) { + val calendarData = calendarManager.getCalendarData(calendarId = calendarPreferences.calendarId) + _uiState.update { it.copy(calendarData = calendarData) } + } + } + + private fun updateSyncedCoursesCount() { + viewModelScope.launch { + val courseStates = calendarInteractor.getAllCourseCalendarStateFromCache() + if (courseStates.isNotEmpty()) { + val syncedCoursesCount = courseStates.count { it.isCourseSyncEnabled } + _uiState.update { it.copy(coursesSynced = syncedCoursesCount) } + } + } + } + + private fun isCalendarExist(): Boolean { + return try { + calendarPreferences.calendarId != CalendarManager.CALENDAR_DOES_NOT_EXIST && + calendarManager.isCalendarExist(calendarPreferences.calendarId) + } catch (e: SecurityException) { + false + } + } } diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CoursesToSyncFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CoursesToSyncFragment.kt new file mode 100644 index 000000000..7b4d1d9d0 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CoursesToSyncFragment.kt @@ -0,0 +1,443 @@ +package org.openedx.profile.presentation.calendar + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.Checkbox +import androidx.compose.material.CheckboxDefaults +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.LocalMinimumInteractiveComponentEnforcement +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Switch +import androidx.compose.material.SwitchDefaults +import androidx.compose.material.Tab +import androidx.compose.material.TabRow +import androidx.compose.material.Text +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.collectAsState +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.clip +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.fragment.app.Fragment +import org.koin.androidx.compose.koinViewModel +import org.openedx.core.UIMessage +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.Toolbar +import org.openedx.core.ui.WindowSize +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.rememberWindowSize +import org.openedx.core.ui.settingsHeaderBackground +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.theme.fontFamily +import org.openedx.core.ui.windowSizeValue +import org.openedx.profile.R +import org.openedx.core.R as coreR + +class CoursesToSyncFragment : Fragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val windowSize = rememberWindowSize() + val viewModel: CoursesToSyncViewModel = koinViewModel() + val uiState by viewModel.uiState.collectAsState() + val uiMessage by viewModel.uiMessage.collectAsState(initial = null) + + CoursesToSyncView( + windowSize = windowSize, + uiState = uiState, + uiMessage = uiMessage, + onHideInactiveCoursesSwitchClick = { + viewModel.setHideInactiveCoursesEnabled(it) + }, + onCourseSyncCheckChange = { isEnabled, courseId -> + viewModel.setCourseSyncEnabled(isEnabled, courseId) + }, + onBackClick = { + requireActivity().supportFragmentManager.popBackStack() + } + ) + } + } + } +} + +@Composable +private fun CoursesToSyncView( + windowSize: WindowSize, + onBackClick: () -> Unit, + uiState: CoursesToSyncUIState, + uiMessage: UIMessage?, + onHideInactiveCoursesSwitchClick: (Boolean) -> Unit, + onCourseSyncCheckChange: (Boolean, String) -> Unit +) { + val scaffoldState = rememberScaffoldState() + + Scaffold( + modifier = Modifier + .fillMaxSize(), + scaffoldState = scaffoldState + ) { paddingValues -> + + val contentWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 420.dp), + compact = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + ) + ) + } + + val topBarWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), + compact = Modifier + .fillMaxWidth() + ) + ) + } + + HandleUIMessage( + uiMessage = uiMessage, + scaffoldState = scaffoldState + ) + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.TopCenter + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .settingsHeaderBackground() + .statusBarsInset(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Toolbar( + modifier = topBarWidth + .displayCutoutForLandscape(), + label = stringResource(id = R.string.profile_courses_to_sync), + canShowBackBtn = true, + labelTint = MaterialTheme.appColors.settingsTitleContent, + iconTint = MaterialTheme.appColors.settingsTitleContent, + onBackClick = onBackClick + ) + + Box( + modifier = Modifier + .fillMaxSize() + .clip(MaterialTheme.appShapes.screenBackgroundShape) + .background(MaterialTheme.appColors.background) + .displayCutoutForLandscape(), + contentAlignment = Alignment.TopCenter + ) { + Column( + modifier = contentWidth + .padding(vertical = 28.dp), + ) { + Text( + text = stringResource(R.string.profile_courses_to_sync_title), + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textPrimaryVariant + ) + Spacer(modifier = Modifier.height(20.dp)) + HideInactiveCoursesView( + isHideInactiveCourses = uiState.isHideInactiveCourses, + onHideInactiveCoursesSwitchClick = onHideInactiveCoursesSwitchClick + ) + Spacer(modifier = Modifier.height(20.dp)) + SyncCourseTabRow( + uiState = uiState, + onCourseSyncCheckChange = onCourseSyncCheckChange + ) + } + } + } + } + } +} + +@Composable +private fun SyncCourseTabRow( + uiState: CoursesToSyncUIState, + onCourseSyncCheckChange: (Boolean, String) -> Unit +) { + var selectedTab by remember { mutableStateOf(SyncCourseTab.SYNCED) } + val selectedTabIndex = SyncCourseTab.entries.indexOf(selectedTab) + + Column { + TabRow( + modifier = Modifier + .clip(MaterialTheme.appShapes.buttonShape) + .border( + 1.dp, + MaterialTheme.appColors.textAccent, + MaterialTheme.appShapes.buttonShape + ), + selectedTabIndex = selectedTabIndex, + backgroundColor = MaterialTheme.appColors.background, + indicator = {} + ) { + SyncCourseTab.entries.forEachIndexed { index, tab -> + val backgroundColor = if (selectedTabIndex == index) { + MaterialTheme.appColors.textAccent + } else { + MaterialTheme.appColors.background + } + Tab( + modifier = Modifier + .background(backgroundColor), + text = { Text(stringResource(id = tab.title)) }, + selected = selectedTabIndex == index, + onClick = { selectedTab = SyncCourseTab.entries[index] }, + unselectedContentColor = MaterialTheme.appColors.textAccent, + selectedContentColor = MaterialTheme.appColors.background + ) + } + } + + CourseCheckboxList( + selectedTab = selectedTab, + uiState = uiState, + onCourseSyncCheckChange = onCourseSyncCheckChange + ) + } +} + + +@Composable +private fun CourseCheckboxList( + selectedTab: SyncCourseTab, + uiState: CoursesToSyncUIState, + onCourseSyncCheckChange: (Boolean, String) -> Unit +) { + if (uiState.isLoading) { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } + } else { + LazyColumn( + modifier = Modifier.padding(8.dp), + ) { + val courseIds = uiState.coursesCalendarState + .filter { it.isCourseSyncEnabled == (selectedTab == SyncCourseTab.SYNCED) } + .map { it.courseId } + val filteredEnrollments = uiState.enrollmentsStatus + .filter { it.courseId in courseIds } + .let { enrollments -> + if (uiState.isHideInactiveCourses) { + enrollments.filter { it.isActive } + } else { + enrollments + } + } + if (filteredEnrollments.isEmpty()) { + item { + EmptyListState( + selectedTab = selectedTab + ) + } + } else { + items(filteredEnrollments) { course -> + val isCourseSyncEnabled = + uiState.coursesCalendarState.find { it.courseId == course.courseId }?.isCourseSyncEnabled + ?: false + val annotatedString = buildAnnotatedString { + append(course.courseName) + if (!course.isActive) { + append(" ") + withStyle( + style = SpanStyle( + fontSize = 10.sp, + fontWeight = FontWeight.Normal, + letterSpacing = 0.sp, + fontFamily = fontFamily, + color = MaterialTheme.appColors.textFieldHint, + ) + ) { + append(stringResource(R.string.profile_inactive)) + } + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + modifier = Modifier.size(24.dp), + colors = CheckboxDefaults.colors( + checkedColor = MaterialTheme.appColors.primary, + uncheckedColor = MaterialTheme.appColors.textFieldText + ), + checked = isCourseSyncEnabled, + enabled = course.isActive, + onCheckedChange = { isEnabled -> + onCourseSyncCheckChange(isEnabled, course.courseId) + } + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = annotatedString, + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textDark + ) + } + } + } + } + } +} + +@Composable +private fun EmptyListState( + modifier: Modifier = Modifier, + selectedTab: SyncCourseTab, +) { + val description = if (selectedTab == SyncCourseTab.SYNCED) { + stringResource(id = R.string.profile_no_sync_courses) + } else { + stringResource(id = R.string.profile_no_courses_with_current_filter) + } + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 40.dp, vertical = 60.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + modifier = Modifier.size(96.dp), + painter = painterResource(id = coreR.drawable.core_ic_book), + tint = MaterialTheme.appColors.divider, + contentDescription = null + ) + Text( + text = stringResource( + id = R.string.profile_no_courses, + stringResource(id = selectedTab.title) + ), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textDark + ) + Text( + text = description, + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textDark, + textAlign = TextAlign.Center + ) + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +private fun HideInactiveCoursesView( + isHideInactiveCourses: Boolean, + onHideInactiveCoursesSwitchClick: (Boolean) -> Unit +) { + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.weight(1f), + text = stringResource(R.string.profile_hide_inactive_courses), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textDark + ) + CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) { + Switch( + modifier = Modifier + .padding(0.dp), + checked = isHideInactiveCourses, + onCheckedChange = onHideInactiveCoursesSwitchClick, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.appColors.textAccent + ) + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.profile_automatically_remove_events), + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textPrimaryVariant + ) + } +} + +@Preview +@Composable +private fun CoursesToSyncViewPreview() { + OpenEdXTheme { + CoursesToSyncView( + windowSize = rememberWindowSize(), + uiState = CoursesToSyncUIState( + enrollmentsStatus = emptyList(), + coursesCalendarState = emptyList(), + isHideInactiveCourses = true, + isLoading = false + ), + uiMessage = null, + onHideInactiveCoursesSwitchClick = {}, + onCourseSyncCheckChange = { _, _ -> }, + onBackClick = {} + ) + } +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CoursesToSyncUIState.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CoursesToSyncUIState.kt new file mode 100644 index 000000000..e43988d2f --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CoursesToSyncUIState.kt @@ -0,0 +1,11 @@ +package org.openedx.profile.presentation.calendar + +import org.openedx.core.domain.model.CourseCalendarState +import org.openedx.core.domain.model.EnrollmentStatus + +data class CoursesToSyncUIState( + val enrollmentsStatus: List, + val coursesCalendarState: List, + val isHideInactiveCourses: Boolean, + val isLoading: Boolean +) diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CoursesToSyncViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CoursesToSyncViewModel.kt new file mode 100644 index 000000000..5e54363e6 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CoursesToSyncViewModel.kt @@ -0,0 +1,92 @@ +package org.openedx.profile.presentation.calendar + +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.openedx.core.BaseViewModel +import org.openedx.core.R +import org.openedx.core.UIMessage +import org.openedx.core.data.storage.CalendarPreferences +import org.openedx.core.domain.interactor.CalendarInteractor +import org.openedx.core.extension.isInternetError +import org.openedx.core.system.ResourceManager +import org.openedx.core.worker.CalendarSyncScheduler + +class CoursesToSyncViewModel( + private val calendarInteractor: CalendarInteractor, + private val calendarPreferences: CalendarPreferences, + private val calendarSyncScheduler: CalendarSyncScheduler, + private val resourceManager: ResourceManager, +) : BaseViewModel() { + + private val _uiState = MutableStateFlow( + CoursesToSyncUIState( + enrollmentsStatus = emptyList(), + coursesCalendarState = emptyList(), + isHideInactiveCourses = calendarPreferences.isHideInactiveCourses, + isLoading = true + ) + ) + + private val _uiMessage = MutableSharedFlow() + val uiMessage: SharedFlow + get() = _uiMessage.asSharedFlow() + + val uiState: StateFlow + get() = _uiState.asStateFlow() + + init { + getEnrollmentsStatus() + getCourseCalendarState() + } + + fun setHideInactiveCoursesEnabled(isEnabled: Boolean) { + calendarPreferences.isHideInactiveCourses = isEnabled + _uiState.update { it.copy(isHideInactiveCourses = isEnabled) } + } + + fun setCourseSyncEnabled(isEnabled: Boolean, courseId: String) { + viewModelScope.launch { + calendarInteractor.updateCourseCalendarStateByIdInCache( + courseId = courseId, + isCourseSyncEnabled = isEnabled + ) + getCourseCalendarState() + calendarSyncScheduler.requestImmediateSync(courseId) + } + } + + private fun getCourseCalendarState() { + viewModelScope.launch { + try { + val coursesCalendarState = calendarInteractor.getAllCourseCalendarStateFromCache() + _uiState.update { it.copy(coursesCalendarState = coursesCalendarState) } + } catch (e: Exception) { + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error))) + } + } + } + + private fun getEnrollmentsStatus() { + viewModelScope.launch { + try { + val enrollmentsStatus = calendarInteractor.getEnrollmentsStatus() + _uiState.update { it.copy(enrollmentsStatus = enrollmentsStatus) } + } catch (e: Exception) { + if (e.isInternetError()) { + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection))) + } else { + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error))) + } + } finally { + _uiState.update { it.copy(isLoading = false) } + } + } + } +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/DisableCalendarSyncDialogFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/DisableCalendarSyncDialogFragment.kt new file mode 100644 index 000000000..e6a196a8c --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/DisableCalendarSyncDialogFragment.kt @@ -0,0 +1,194 @@ +package org.openedx.profile.presentation.calendar + +import android.content.res.Configuration +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.os.bundleOf +import androidx.fragment.app.DialogFragment +import org.koin.androidx.compose.koinViewModel +import org.openedx.core.domain.model.CalendarData +import org.openedx.core.extension.parcelable +import org.openedx.core.presentation.dialog.DefaultDialogBox +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.OpenEdXOutlinedButton +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes +import org.openedx.core.ui.theme.appTypography +import org.openedx.profile.R +import androidx.compose.ui.graphics.Color as ComposeColor +import org.openedx.core.R as coreR + +class DisableCalendarSyncDialogFragment : DialogFragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val viewModel: DisableCalendarSyncDialogViewModel = koinViewModel() + DisableCalendarSyncDialogView( + calendarData = requireArguments().parcelable(ARG_CALENDAR_DATA), + onCancelClick = { + dismiss() + }, + onDisableSyncingClick = { + viewModel.disableSyncingClick() + dismiss() + } + ) + } + } + } + + companion object { + const val DIALOG_TAG = "DisableCalendarSyncDialogFragment" + const val ARG_CALENDAR_DATA = "ARG_CALENDAR_DATA" + + fun newInstance( + calendarData: CalendarData + ): DisableCalendarSyncDialogFragment { + val fragment = DisableCalendarSyncDialogFragment() + fragment.arguments = bundleOf( + ARG_CALENDAR_DATA to calendarData + ) + return fragment + } + } +} + +@Composable +private fun DisableCalendarSyncDialogView( + modifier: Modifier = Modifier, + calendarData: CalendarData?, + onCancelClick: () -> Unit, + onDisableSyncingClick: () -> Unit +) { + val scrollState = rememberScrollState() + DefaultDialogBox( + modifier = modifier, + onDismissClick = onCancelClick + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(scrollState) + .padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Image( + modifier = Modifier.size(24.dp), + painter = painterResource(id = coreR.drawable.core_ic_warning), + contentDescription = null + ) + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.profile_disable_calendar_dialog_title), + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textDark + ) + } + calendarData?.let { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clip(MaterialTheme.appShapes.cardShape) + .background(MaterialTheme.appColors.cardViewBackground) + .padding(vertical = 16.dp, horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Box( + modifier = Modifier + .size(18.dp) + .clip(CircleShape) + .background(ComposeColor(calendarData.color)) + ) + Text( + text = calendarData.title, + style = MaterialTheme.appTypography.bodyMedium.copy( + textDecoration = TextDecoration.LineThrough + ), + color = MaterialTheme.appColors.textDark + ) + } + } + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource( + id = R.string.profile_disable_calendar_dialog_description, + calendarData?.title ?: "" + ), + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.textDark + ) + OpenEdXOutlinedButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.profile_disable_syncing), + backgroundColor = MaterialTheme.appColors.background, + borderColor = MaterialTheme.appColors.primaryButtonBackground, + textColor = MaterialTheme.appColors.primaryButtonBackground, + onClick = { + onDisableSyncingClick() + } + ) + OpenEdXButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = coreR.string.core_cancel), + onClick = { + onCancelClick() + } + ) + } + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun DisableCalendarSyncDialogPreview() { + OpenEdXTheme { + DisableCalendarSyncDialogView( + calendarData = CalendarData("calendar", Color.GREEN), + onCancelClick = { }, + onDisableSyncingClick = { } + ) + } +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/DisableCalendarSyncDialogViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/DisableCalendarSyncDialogViewModel.kt new file mode 100644 index 000000000..303dd2a40 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/DisableCalendarSyncDialogViewModel.kt @@ -0,0 +1,27 @@ +package org.openedx.profile.presentation.calendar + +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch +import org.openedx.core.BaseViewModel +import org.openedx.core.data.storage.CalendarPreferences +import org.openedx.core.domain.interactor.CalendarInteractor +import org.openedx.core.system.CalendarManager +import org.openedx.core.system.notifier.calendar.CalendarNotifier +import org.openedx.core.system.notifier.calendar.CalendarSyncDisabled + +class DisableCalendarSyncDialogViewModel( + private val calendarNotifier: CalendarNotifier, + private val calendarManager: CalendarManager, + private val calendarPreferences: CalendarPreferences, + private val calendarInteractor: CalendarInteractor, +) : BaseViewModel() { + + fun disableSyncingClick() { + viewModelScope.launch { + calendarInteractor.clearCalendarCachedData() + calendarManager.deleteCalendar(calendarPreferences.calendarId) + calendarPreferences.clearCalendarPreferences() + calendarNotifier.send(CalendarSyncDisabled) + } + } +} 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 8e55b885b..af09b3ea3 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 @@ -21,9 +21,11 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll import androidx.compose.material.Divider import androidx.compose.material.DropdownMenu import androidx.compose.material.DropdownMenuItem @@ -36,6 +38,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.ExpandMore 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 @@ -58,7 +61,11 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.core.os.bundleOf import androidx.fragment.app.DialogFragment +import org.koin.androidx.compose.koinViewModel +import org.openedx.core.extension.parcelable +import org.openedx.core.extension.toastMessage import org.openedx.core.presentation.dialog.DefaultDialogBox import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.OpenEdXOutlinedButton @@ -82,12 +89,32 @@ class NewCalendarDialogFragment : DialogFragment() { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { OpenEdXTheme { + val viewModel: NewCalendarDialogViewModel = koinViewModel() + + LaunchedEffect(Unit) { + viewModel.uiMessage.collect { message -> + if (message.isNotEmpty()) { + context.toastMessage(message) + } + } + } + + LaunchedEffect(Unit) { + viewModel.isSuccess.collect { isSuccess -> + if (isSuccess) { + dismiss() + } + } + } + NewCalendarDialog( + newCalendarDialogType = requireArguments().parcelable(ARG_DIALOG_TYPE) + ?: NewCalendarDialogType.CREATE_NEW, onCancelClick = { dismiss() }, - onBeginSyncingClick = { calendarName, calendarColor -> - //TODO Create calendar and sync events + onBeginSyncingClick = { calendarTitle, calendarColor -> + viewModel.createCalendar(calendarTitle, calendarColor) } ) } @@ -96,12 +123,19 @@ class NewCalendarDialogFragment : DialogFragment() { companion object { const val DIALOG_TAG = "NewCalendarDialogFragment" + const val ARG_DIALOG_TYPE = "ARG_DIALOG_TYPE" - fun newInstance(): NewCalendarDialogFragment { - return NewCalendarDialogFragment() + fun newInstance( + newCalendarDialogType: NewCalendarDialogType + ): NewCalendarDialogFragment { + val fragment = NewCalendarDialogFragment() + fragment.arguments = bundleOf( + ARG_DIALOG_TYPE to newCalendarDialogType + ) + return fragment } - fun getDefaultCalendarName(context: Context): String { + fun getDefaultCalendarTitle(context: Context): String { return "${context.getString(CoreR.string.app_name)} ${context.getString(R.string.profile_course_dates)}" } } @@ -110,11 +144,17 @@ class NewCalendarDialogFragment : DialogFragment() { @Composable private fun NewCalendarDialog( modifier: Modifier = Modifier, + newCalendarDialogType: NewCalendarDialogType, onCancelClick: () -> Unit, - onBeginSyncingClick: (calendarName: String, calendarColor: CalendarColor) -> Unit + onBeginSyncingClick: (calendarTitle: String, calendarColor: CalendarColor) -> Unit ) { val context = LocalContext.current - var calendarName by rememberSaveable { + val scrollState = rememberScrollState() + val title = when (newCalendarDialogType) { + NewCalendarDialogType.CREATE_NEW -> stringResource(id = R.string.profile_new_calendar) + NewCalendarDialogType.UPDATE -> stringResource(id = R.string.profile_change_sync_options) + } + var calendarTitle by rememberSaveable { mutableStateOf("") } var calendarColor by rememberSaveable { @@ -127,6 +167,7 @@ private fun NewCalendarDialog( Column( modifier = Modifier .fillMaxWidth() + .verticalScroll(scrollState) .padding(20.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp) @@ -136,7 +177,7 @@ private fun NewCalendarDialog( ) { Text( modifier = Modifier.weight(1f), - text = stringResource(id = R.string.profile_new_calendar), + text = title, color = MaterialTheme.appColors.textDark, style = MaterialTheme.appTypography.titleLarge ) @@ -151,9 +192,9 @@ private fun NewCalendarDialog( tint = MaterialTheme.appColors.primary ) } - CalendarNameTextField( + CalendarTitleTextField( onValueChanged = { - calendarName = it + calendarTitle = it } ) ColorDropdown( @@ -183,7 +224,7 @@ private fun NewCalendarDialog( text = stringResource(id = R.string.profile_begin_syncing), onClick = { onBeginSyncingClick( - calendarName.ifEmpty { NewCalendarDialogFragment.getDefaultCalendarName(context) }, + calendarTitle.ifEmpty { NewCalendarDialogFragment.getDefaultCalendarTitle(context) }, calendarColor ) } @@ -193,11 +234,12 @@ private fun NewCalendarDialog( } @Composable -private fun CalendarNameTextField( +private fun CalendarTitleTextField( modifier: Modifier = Modifier, onValueChanged: (String) -> Unit ) { val focusManager = LocalFocusManager.current + val maxChar = 40 var textFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf( TextFieldValue("") @@ -218,7 +260,7 @@ private fun CalendarNameTextField( .height(48.dp), value = textFieldValue, onValueChange = { - textFieldValue = it + if (it.text.length <= maxChar) textFieldValue = it onValueChanged(it.text.trim()) }, colors = TextFieldDefaults.outlinedTextFieldColors( @@ -227,7 +269,7 @@ private fun CalendarNameTextField( shape = MaterialTheme.appShapes.textFieldShape, placeholder = { Text( - text = NewCalendarDialogFragment.getDefaultCalendarName(LocalContext.current), + text = NewCalendarDialogFragment.getDefaultCalendarTitle(LocalContext.current), color = MaterialTheme.appColors.textFieldHint, style = MaterialTheme.appTypography.bodyMedium ) @@ -383,6 +425,7 @@ private fun ColorCircle( private fun NewCalendarDialogPreview() { OpenEdXTheme { NewCalendarDialog( + newCalendarDialogType = NewCalendarDialogType.CREATE_NEW, onCancelClick = { }, onBeginSyncingClick = { _, _ -> } ) diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogType.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogType.kt new file mode 100644 index 000000000..1905b8faa --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogType.kt @@ -0,0 +1,9 @@ +package org.openedx.profile.presentation.calendar + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +enum class NewCalendarDialogType : Parcelable { + CREATE_NEW, UPDATE +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogViewModel.kt new file mode 100644 index 000000000..e43f1b989 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogViewModel.kt @@ -0,0 +1,62 @@ +package org.openedx.profile.presentation.calendar + +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import org.openedx.core.BaseViewModel +import org.openedx.core.R +import org.openedx.core.data.storage.CalendarPreferences +import org.openedx.core.domain.interactor.CalendarInteractor +import org.openedx.core.system.CalendarManager +import org.openedx.core.system.ResourceManager +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.system.notifier.calendar.CalendarCreated +import org.openedx.core.system.notifier.calendar.CalendarNotifier + +class NewCalendarDialogViewModel( + private val calendarManager: CalendarManager, + private val calendarPreferences: CalendarPreferences, + private val calendarNotifier: CalendarNotifier, + private val calendarInteractor: CalendarInteractor, + private val networkConnection: NetworkConnection, + private val resourceManager: ResourceManager, +) : BaseViewModel() { + + private val _uiMessage = MutableSharedFlow() + val uiMessage: SharedFlow + get() = _uiMessage.asSharedFlow() + + private val _isSuccess = MutableSharedFlow() + val isSuccess: SharedFlow + get() = _isSuccess.asSharedFlow() + + fun createCalendar( + calendarTitle: String, + calendarColor: CalendarColor, + ) { + viewModelScope.launch { + if (networkConnection.isOnline()) { + calendarInteractor.resetChecksums() + val calendarId = calendarManager.createOrUpdateCalendar( + calendarId = calendarPreferences.calendarId, + calendarTitle = calendarTitle, + calendarColor = calendarColor.color + ) + if (calendarId != CalendarManager.CALENDAR_DOES_NOT_EXIST) { + calendarPreferences.calendarId = calendarId + calendarPreferences.calendarUser = calendarManager.accountName + viewModelScope.launch { + calendarNotifier.send(CalendarCreated) + } + _isSuccess.emit(true) + } else { + _uiMessage.emit(resourceManager.getString(R.string.core_error_unknown_error)) + } + } else { + _uiMessage.emit(resourceManager.getString(R.string.core_error_no_connection)) + } + } + } +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/SyncCourseTab.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/SyncCourseTab.kt new file mode 100644 index 000000000..ef65db249 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/SyncCourseTab.kt @@ -0,0 +1,12 @@ +package org.openedx.profile.presentation.calendar + +import androidx.annotation.StringRes +import org.openedx.core.R + +enum class SyncCourseTab( + @StringRes + val title: Int +) { + SYNCED(R.string.core_to_sync), + NOT_SYNCED(R.string.core_not_synced) +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileViewModel.kt index c4477ef28..79fff00d1 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileViewModel.kt @@ -15,8 +15,8 @@ import org.openedx.profile.domain.interactor.ProfileInteractor import org.openedx.profile.presentation.ProfileAnalytics import org.openedx.profile.presentation.ProfileAnalyticsEvent import org.openedx.profile.presentation.ProfileAnalyticsKey -import org.openedx.profile.system.notifier.AccountDeactivated -import org.openedx.profile.system.notifier.ProfileNotifier +import org.openedx.profile.system.notifier.account.AccountDeactivated +import org.openedx.profile.system.notifier.profile.ProfileNotifier class DeleteProfileViewModel( private val resourceManager: ResourceManager, diff --git a/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileViewModel.kt index 211ce2794..bc4c77dd1 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileViewModel.kt @@ -15,8 +15,8 @@ import org.openedx.profile.domain.model.Account import org.openedx.profile.presentation.ProfileAnalytics import org.openedx.profile.presentation.ProfileAnalyticsEvent import org.openedx.profile.presentation.ProfileAnalyticsKey -import org.openedx.profile.system.notifier.AccountUpdated -import org.openedx.profile.system.notifier.ProfileNotifier +import org.openedx.profile.system.notifier.account.AccountUpdated +import org.openedx.profile.system.notifier.profile.ProfileNotifier import java.io.File class EditProfileViewModel( diff --git a/profile/src/main/java/org/openedx/profile/presentation/manageaccount/ManageAccountViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/manageaccount/ManageAccountViewModel.kt index 2370e0508..972426d2e 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/manageaccount/ManageAccountViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/manageaccount/ManageAccountViewModel.kt @@ -20,8 +20,8 @@ import org.openedx.profile.presentation.ProfileAnalytics import org.openedx.profile.presentation.ProfileAnalyticsEvent import org.openedx.profile.presentation.ProfileAnalyticsKey import org.openedx.profile.presentation.ProfileRouter -import org.openedx.profile.system.notifier.AccountUpdated -import org.openedx.profile.system.notifier.ProfileNotifier +import org.openedx.profile.system.notifier.account.AccountUpdated +import org.openedx.profile.system.notifier.profile.ProfileNotifier class ManageAccountViewModel( private val interactor: ProfileInteractor, diff --git a/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileViewModel.kt index d8fc19715..f02e09c22 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileViewModel.kt @@ -19,8 +19,8 @@ import org.openedx.profile.presentation.ProfileAnalytics import org.openedx.profile.presentation.ProfileAnalyticsEvent import org.openedx.profile.presentation.ProfileAnalyticsKey import org.openedx.profile.presentation.ProfileRouter -import org.openedx.profile.system.notifier.AccountUpdated -import org.openedx.profile.system.notifier.ProfileNotifier +import org.openedx.profile.system.notifier.account.AccountUpdated +import org.openedx.profile.system.notifier.profile.ProfileNotifier class ProfileViewModel( private val interactor: ProfileInteractor, diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt index 6e622e2cc..64145b063 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.openedx.core.AppUpdateState import org.openedx.core.BaseViewModel +import org.openedx.core.CalendarRouter import org.openedx.core.R import org.openedx.core.UIMessage import org.openedx.core.config.Config @@ -33,8 +34,8 @@ import org.openedx.profile.presentation.ProfileAnalytics import org.openedx.profile.presentation.ProfileAnalyticsEvent import org.openedx.profile.presentation.ProfileAnalyticsKey import org.openedx.profile.presentation.ProfileRouter -import org.openedx.profile.system.notifier.AccountDeactivated -import org.openedx.profile.system.notifier.ProfileNotifier +import org.openedx.profile.system.notifier.account.AccountDeactivated +import org.openedx.profile.system.notifier.profile.ProfileNotifier class SettingsViewModel( private val appData: AppData, @@ -44,7 +45,8 @@ class SettingsViewModel( private val cookieManager: AppCookieManager, private val workerController: DownloadWorkerController, private val analytics: ProfileAnalytics, - private val router: ProfileRouter, + private val profileRouter: ProfileRouter, + private val calendarRouter: CalendarRouter, private val appNotifier: AppNotifier, private val profileNotifier: ProfileNotifier, ) : BaseViewModel() { @@ -128,12 +130,12 @@ class SettingsViewModel( } fun videoSettingsClicked(fragmentManager: FragmentManager) { - router.navigateToVideoSettings(fragmentManager) + profileRouter.navigateToVideoSettings(fragmentManager) logProfileEvent(ProfileAnalyticsEvent.VIDEO_SETTING_CLICKED) } fun privacyPolicyClicked(fragmentManager: FragmentManager) { - router.navigateToWebContent( + profileRouter.navigateToWebContent( fm = fragmentManager, title = resourceManager.getString(R.string.core_privacy_policy), url = configuration.agreementUrls.privacyPolicyUrl, @@ -142,7 +144,7 @@ class SettingsViewModel( } fun cookiePolicyClicked(fragmentManager: FragmentManager) { - router.navigateToWebContent( + profileRouter.navigateToWebContent( fm = fragmentManager, title = resourceManager.getString(R.string.core_cookie_policy), url = configuration.agreementUrls.cookiePolicyUrl, @@ -151,7 +153,7 @@ class SettingsViewModel( } fun dataSellClicked(fragmentManager: FragmentManager) { - router.navigateToWebContent( + profileRouter.navigateToWebContent( fm = fragmentManager, title = resourceManager.getString(R.string.core_data_sell), url = configuration.agreementUrls.dataSellConsentUrl, @@ -164,7 +166,7 @@ class SettingsViewModel( } fun termsOfUseClicked(fragmentManager: FragmentManager) { - router.navigateToWebContent( + profileRouter.navigateToWebContent( fm = fragmentManager, title = resourceManager.getString(R.string.core_terms_of_use), url = configuration.agreementUrls.tosUrl, @@ -186,15 +188,15 @@ class SettingsViewModel( } fun manageAccountClicked(fragmentManager: FragmentManager) { - router.navigateToManageAccount(fragmentManager) + profileRouter.navigateToManageAccount(fragmentManager) } fun calendarSettingsClicked(fragmentManager: FragmentManager) { - router.navigateToCalendarSettings(fragmentManager) + calendarRouter.navigateToCalendarSettings(fragmentManager) } fun restartApp(fragmentManager: FragmentManager) { - router.restartApp( + profileRouter.restartApp( fragmentManager, isLogistrationEnabled ) diff --git a/profile/src/main/java/org/openedx/profile/system/notifier/AccountDeactivated.kt b/profile/src/main/java/org/openedx/profile/system/notifier/AccountDeactivated.kt deleted file mode 100644 index ff09cbf72..000000000 --- a/profile/src/main/java/org/openedx/profile/system/notifier/AccountDeactivated.kt +++ /dev/null @@ -1,3 +0,0 @@ -package org.openedx.profile.system.notifier - -class AccountDeactivated : ProfileEvent diff --git a/profile/src/main/java/org/openedx/profile/system/notifier/AccountUpdated.kt b/profile/src/main/java/org/openedx/profile/system/notifier/AccountUpdated.kt deleted file mode 100644 index 2870235f2..000000000 --- a/profile/src/main/java/org/openedx/profile/system/notifier/AccountUpdated.kt +++ /dev/null @@ -1,3 +0,0 @@ -package org.openedx.profile.system.notifier - -class AccountUpdated : ProfileEvent diff --git a/profile/src/main/java/org/openedx/profile/system/notifier/ProfileEvent.kt b/profile/src/main/java/org/openedx/profile/system/notifier/ProfileEvent.kt deleted file mode 100644 index dbe877081..000000000 --- a/profile/src/main/java/org/openedx/profile/system/notifier/ProfileEvent.kt +++ /dev/null @@ -1,3 +0,0 @@ -package org.openedx.profile.system.notifier - -interface ProfileEvent diff --git a/profile/src/main/java/org/openedx/profile/system/notifier/account/AccountDeactivated.kt b/profile/src/main/java/org/openedx/profile/system/notifier/account/AccountDeactivated.kt new file mode 100644 index 000000000..68f68e58f --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/system/notifier/account/AccountDeactivated.kt @@ -0,0 +1,5 @@ +package org.openedx.profile.system.notifier.account + +import org.openedx.profile.system.notifier.profile.ProfileEvent + +class AccountDeactivated : ProfileEvent diff --git a/profile/src/main/java/org/openedx/profile/system/notifier/account/AccountUpdated.kt b/profile/src/main/java/org/openedx/profile/system/notifier/account/AccountUpdated.kt new file mode 100644 index 000000000..f43d6c329 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/system/notifier/account/AccountUpdated.kt @@ -0,0 +1,5 @@ +package org.openedx.profile.system.notifier.account + +import org.openedx.profile.system.notifier.profile.ProfileEvent + +class AccountUpdated : ProfileEvent diff --git a/profile/src/main/java/org/openedx/profile/system/notifier/profile/ProfileEvent.kt b/profile/src/main/java/org/openedx/profile/system/notifier/profile/ProfileEvent.kt new file mode 100644 index 000000000..c978a78d3 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/system/notifier/profile/ProfileEvent.kt @@ -0,0 +1,3 @@ +package org.openedx.profile.system.notifier.profile + +interface ProfileEvent diff --git a/profile/src/main/java/org/openedx/profile/system/notifier/ProfileNotifier.kt b/profile/src/main/java/org/openedx/profile/system/notifier/profile/ProfileNotifier.kt similarity index 70% rename from profile/src/main/java/org/openedx/profile/system/notifier/ProfileNotifier.kt rename to profile/src/main/java/org/openedx/profile/system/notifier/profile/ProfileNotifier.kt index c51d82340..71e2dbf1d 100644 --- a/profile/src/main/java/org/openedx/profile/system/notifier/ProfileNotifier.kt +++ b/profile/src/main/java/org/openedx/profile/system/notifier/profile/ProfileNotifier.kt @@ -1,9 +1,10 @@ -package org.openedx.profile.system.notifier +package org.openedx.profile.system.notifier.profile import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow -import org.openedx.core.system.notifier.VideoQualityChanged +import org.openedx.profile.system.notifier.account.AccountDeactivated +import org.openedx.profile.system.notifier.account.AccountUpdated class ProfileNotifier { diff --git a/profile/src/main/res/values/strings.xml b/profile/src/main/res/values/strings.xml index 60f0e4060..41535240c 100644 --- a/profile/src/main/res/values/strings.xml +++ b/profile/src/main/res/values/strings.xml @@ -60,5 +60,23 @@ Accent Course Dates Color + Course Calendar Sync + Currently syncing events to your calendar + Change Sync Options + Courses to Sync + Syncing %1$s Courses + Options + Use relative dates + Show relative dates like “Tomorrow” and “Yesterday” + Disabling sync for a course will remove all events connected to the course from your synced calendar. + Automatically remove events from courses you haven’t viewed in the last month + Inactive + Hide Inactive Courses + Disable Calendar Sync + Disabling calendar sync will delete the calendar “%1$s.” You can turn calendar sync back on at any time. + Disable Syncing + No %1$s Courses + No courses are currently being synced to your calendar. + No courses match the current filter. diff --git a/profile/src/test/java/org/openedx/profile/presentation/edit/EditProfileViewModelTest.kt b/profile/src/test/java/org/openedx/profile/presentation/edit/EditProfileViewModelTest.kt index a9b5b0c31..e8f1c13ef 100644 --- a/profile/src/test/java/org/openedx/profile/presentation/edit/EditProfileViewModelTest.kt +++ b/profile/src/test/java/org/openedx/profile/presentation/edit/EditProfileViewModelTest.kt @@ -1,25 +1,33 @@ package org.openedx.profile.presentation.edit import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import org.openedx.core.R -import org.openedx.core.UIMessage -import org.openedx.profile.domain.model.Account -import org.openedx.core.domain.model.ProfileImage -import org.openedx.core.system.ResourceManager -import org.openedx.profile.domain.interactor.ProfileInteractor -import org.openedx.profile.presentation.ProfileAnalytics -import org.openedx.profile.system.notifier.AccountUpdated -import org.openedx.profile.system.notifier.ProfileNotifier -import io.mockk.* +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.* +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule +import org.openedx.core.R +import org.openedx.core.UIMessage +import org.openedx.core.domain.model.ProfileImage +import org.openedx.core.system.ResourceManager +import org.openedx.profile.domain.interactor.ProfileInteractor +import org.openedx.profile.domain.model.Account +import org.openedx.profile.presentation.ProfileAnalytics +import org.openedx.profile.system.notifier.account.AccountUpdated +import org.openedx.profile.system.notifier.profile.ProfileNotifier import java.io.File import java.net.UnknownHostException diff --git a/profile/src/test/java/org/openedx/profile/presentation/profile/ProfileViewModelTest.kt b/profile/src/test/java/org/openedx/profile/presentation/profile/ProfileViewModelTest.kt index ca2ffd9bb..d33f24fb9 100644 --- a/profile/src/test/java/org/openedx/profile/presentation/profile/ProfileViewModelTest.kt +++ b/profile/src/test/java/org/openedx/profile/presentation/profile/ProfileViewModelTest.kt @@ -32,8 +32,8 @@ import org.openedx.core.system.ResourceManager import org.openedx.profile.domain.interactor.ProfileInteractor import org.openedx.profile.presentation.ProfileAnalytics import org.openedx.profile.presentation.ProfileRouter -import org.openedx.profile.system.notifier.AccountUpdated -import org.openedx.profile.system.notifier.ProfileNotifier +import org.openedx.profile.system.notifier.account.AccountUpdated +import org.openedx.profile.system.notifier.profile.ProfileNotifier import java.net.UnknownHostException @OptIn(ExperimentalCoroutinesApi::class) From b5256cefcb6699886657040f813b847af3e0fc97 Mon Sep 17 00:00:00 2001 From: Omar Al-Ithawi Date: Mon, 19 Aug 2024 10:28:27 +0300 Subject: [PATCH 30/56] chore: Remove in-code ukranian translations (#342) `make pull_translations` should be used before release --- README.md | 3 +- app/src/main/res/values-uk/strings.xml | 11 --- auth/src/main/res/values-uk/strings.xml | 18 ----- core/src/main/res/values-uk/strings.xml | 75 ----------------- course/src/main/res/values-uk/strings.xml | 49 ----------- dashboard/src/main/res/values-uk/strings.xml | 6 -- discovery/src/main/res/values-uk/strings.xml | 22 ----- discussion/src/main/res/values-uk/strings.xml | 81 ------------------- profile/src/main/res/values-uk/strings.xml | 36 --------- whatsnew/src/main/res/values-uk/strings.xml | 7 -- 10 files changed, 2 insertions(+), 306 deletions(-) delete mode 100644 app/src/main/res/values-uk/strings.xml delete mode 100644 auth/src/main/res/values-uk/strings.xml delete mode 100644 core/src/main/res/values-uk/strings.xml delete mode 100644 course/src/main/res/values-uk/strings.xml delete mode 100644 dashboard/src/main/res/values-uk/strings.xml delete mode 100644 discovery/src/main/res/values-uk/strings.xml delete mode 100644 discussion/src/main/res/values-uk/strings.xml delete mode 100644 profile/src/main/res/values-uk/strings.xml delete mode 100644 whatsnew/src/main/res/values-uk/strings.xml diff --git a/README.md b/README.md index 65a19cce7..a3ecff99f 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,8 @@ Additional arguments can be passed to `atlas pull`. Refer to the [atlas document Translations are managed in the [open-edx/openedx-translations](https://app.transifex.com/open-edx/openedx-translations/dashboard/) Transifex project. -To translate the app join the [Transifex project](https://app.transifex.com/open-edx/openedx-translations/dashboard/) and add your translations `openedx-app-android` resource: https://app.transifex.com/open-edx/openedx-translations/openedx-app-android/ (the link will start working after the [pull request #317](https://github.com/openedx/openedx-app-android/pull/317) is merged) +To translate the app join the [Transifex project](https://app.transifex.com/open-edx/openedx-translations/dashboard/) and add your translations to the +[`openedx-app-android`](https://app.transifex.com/open-edx/openedx-translations/openedx-app-android/) resource. Once the resource is both 100% translated and reviewed the [Transifex integration](https://github.com/apps/transifex-integration) will automatically push it to the [openedx-translations](https://github.com/openedx/openedx-translations) repository and developers can use the translations in their app. diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml deleted file mode 100644 index 17d58ded3..000000000 --- a/app/src/main/res/values-uk/strings.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - Налаштування - Далі - Назад - - Всі курси - Мої курси - Програми - Профіль - diff --git a/auth/src/main/res/values-uk/strings.xml b/auth/src/main/res/values-uk/strings.xml deleted file mode 100644 index 9c1a1aa69..000000000 --- a/auth/src/main/res/values-uk/strings.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - Зареєструватися - Забули пароль? - Електронна пошта - Неправильна E-mail адреса - Пароль занадто короткий - Показати додаткові поля - Приховати додаткові поля - Створити акаунт - Відновити пароль - Забули пароль - Будь ласка, введіть свій логін або адресу електронної пошти для відновлення нижче, і ми надішлемо вам електронний лист з інструкціями. - Перевірте свою електронну пошту - Ми надіслали інструкції щодо відновлення пароля на вашу електронну пошту %s - Введіть пароль - - diff --git a/core/src/main/res/values-uk/strings.xml b/core/src/main/res/values-uk/strings.xml deleted file mode 100644 index 2aab8871c..000000000 --- a/core/src/main/res/values-uk/strings.xml +++ /dev/null @@ -1,75 +0,0 @@ - - - Результати - Неправильні облікові дані - Повільне або відсутнє з\'єднання з Інтернетом - Щось пішло не так - Спробуйте ще раз - Політика конфіденційності - Умови використання - Профіль - Скасувати - Пошук - Виберіть значення - Починається %1$s - Закінчився %1$s - Закінчується %1$s - Термін дії курсу закінчується %1$s - Термін дії курсу закінчується %1$s - Термін дії курсу минув %1$s - Термін дії курсу минув %1$s - Пароль - незабаром - Авто - Рекомендовано - Менше використання трафіку - Найкраща якість - Офлайн - Закрити - Перезавантажити - Завантаження у процесі. - Обліковий запис користувача не активовано. Будь ласка, спочатку активуйте свій обліковий запис. - Надіслати електронний лист за допомогою ... - Не встановлено жодного поштового клієнта - dd MMMM, yyyy - dd MMM yyyy HH:mm - Оновлення додатку - Ми рекомендуємо вам оновитись до останньої версії. Оновіться зараз, щоб отримати останні функції та виправлення. - Доступне нове оновлення! Оновіть зараз, щоб отримати останні можливості та виправлення - Не зараз - Оновити - Застаріла версія додатку - Налаштування аккаунту - Необхідне оновлення додатку - Ця версія додатка %1$s застаріла. Щоб продовжити навчання та отримати останні можливості та виправлення, будь ласка, оновіть до останньої версії. - Чому мені потрібно оновити? - Версія: %1$s - Оновлено - Натисніть, щоб оновити до версії %1$s - Натисніть, щоб встановити обов\'язкове оновлення додатку - Підтвердити - Вам подобається %1$s? - Ваш відгук має значення для нас. Будь ласка, оцініть додаток, натиснувши на зірочку нижче. Дякуємо за вашу підтримку! - Залиште відгук - Нам шкода, що ваш досвід навчання був з деякими проблемами. Ми цінуємо всі відгуки. - Що могло б бути краще? - Поділитися відгуком - Дякуємо - Оцінити нас - Дякуємо за надання відгуку. Чи бажаєте ви поділитися своєю оцінкою цього додатка з іншими користувачами в магазині додатків? - Ми отримали ваш відгук і використовуватимемо його, щоб покращити ваш досвід навчання в майбутньому. Дякуємо, що поділилися! - - Зареєструватися - Увійти - - - %1$s зображення профілю - Заглавне зображення для курсу %1$s - - Якість транслювання відео - - Курс - Відео - Обговорення - Матеріали - diff --git a/course/src/main/res/values-uk/strings.xml b/course/src/main/res/values-uk/strings.xml deleted file mode 100644 index 14f3487c4..000000000 --- a/course/src/main/res/values-uk/strings.xml +++ /dev/null @@ -1,49 +0,0 @@ - - - Огляд курсу - Зміст курсу - Одиниці курсу - Підрозділи курсу - Відео - Вітаємо, ви отримали сертифікат про проходження курсу \"%s\". - Переглянути сертифікат - Назад - Попередня одиниця - Далі - Наступна одиниця - Завершити - Цей курс не містить відео. - Остання одиниця: - Продовжити - Обговорення - Роздаткові матеріали - Оголошення - Знайдіть важливу інформацію про курс - Будьте в курсі останніх новин - Гарна робота! - Секція \"%s\" завершена. - Наступний розділ - Повернутись до модуля - Цей курс ще не розпочався. - Ви не підключені до Інтернету. Будь ласка, перевірте ваше підключення до Інтернету. - Курс - Відео - Обговорення - Матеріали - Ви можете завантажувати контент тільки через Wi-Fi - Ця інтерактивна компонента ще не доступна - Досліджуйте інші частини цього курсу або перегляньте це на веб-сайті. - Відкрити в браузері - Субтитри - Остання активність: - Продовжити - Щоб перейти до \"%s\", натисніть \"Наступний розділ\". - - - Відеоплеєр - Видалити секцію курсу - Завантажити секцію курсу - Зупинити завантаження секції курсу - Секція завершена - Секція не завершена - diff --git a/dashboard/src/main/res/values-uk/strings.xml b/dashboard/src/main/res/values-uk/strings.xml deleted file mode 100644 index bf1c7da16..000000000 --- a/dashboard/src/main/res/values-uk/strings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - Курси - You are not enrolled in any courses yet. - - \ No newline at end of file diff --git a/discovery/src/main/res/values-uk/strings.xml b/discovery/src/main/res/values-uk/strings.xml deleted file mode 100644 index f25c4ef5c..000000000 --- a/discovery/src/main/res/values-uk/strings.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - Нові курси - Знайти нові курси - Давайте знайдемо щось нове для вас - Результати пошуку - Почніть вводити, щоб знайти курс - Деталі курсу - Записатися зараз - Переглянути курс - Ви не можете записатися на цей курс, оскільки термін запису вже минув. - - - Знайдено %s курс за вашим запитом - Знайдено %s курси за вашим запитом - Знайдено %s курсів за вашим запитом - Знайдено %s курсів за вашим запитом - - - - Відтворити відео - diff --git a/discussion/src/main/res/values-uk/strings.xml b/discussion/src/main/res/values-uk/strings.xml deleted file mode 100644 index 212932f49..000000000 --- a/discussion/src/main/res/values-uk/strings.xml +++ /dev/null @@ -1,81 +0,0 @@ - - - Обговорення - Всі публікації - Непрочитані - Без відповіді - Публікації, за якими стежу - Уточнити: - Остання активність - Найбільша активність - Найбільше голосів - Створити дискусію - Додати відповідь - Останнє повідомлення: %1$s - Стежити - Поскаржитися - Відмінити скаргу - Додати коментар - Коментар - Коментар успішно додано - Обговорення - Питання - Заголовок - Слідкувати за цією дискусією - Слідкувати за цим питанням - Опублікувати обговорення - Опублікувати питання - Загальні - Шукати в усіх повідомленнях - Головні категорії - Виберіть тип публікації - Тема - Результати пошуку - Почніть вводити, щоб знайти тему - anonymous - Ще немає обговорень - Натисніть кнопку нижче, щоб створити перше обговорення. - - - %1$d голос - %1$d голоси - %1$d голосів - %1$d голосів - - - - %1$d коментар - %1$d коментарі - %1$d коментарів - %1$d коментарів - - - - %1$d пропущений допис - %1$d пропущені дописи - %1$d пропущених дописів - %1$d пропущених дописів - - - - %1$d відповідь - %1$d відповіді - %1$d відповідей - %1$d відповідей - - - - %1$d Відповідь - %1$d Відповіді - %1$d Відповідей - %1$d Відповідей - - - - Знайдено %s запис - Знайдено %s записи - Знайдено %s записів - Знайдено %s записів - - - \ No newline at end of file diff --git a/profile/src/main/res/values-uk/strings.xml b/profile/src/main/res/values-uk/strings.xml deleted file mode 100644 index fe5eb14b8..000000000 --- a/profile/src/main/res/values-uk/strings.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - Інформація про профіль - Біо: %1$s - Рік народження: %1$s - Повний профіль - Обмежений профіль - Редагувати профіль - Редагувати - Зберегти - Видалити профіль - Вам повинно бути не менше 13 років, щоб мати повний доступ до інформації в профілі - Рік народження - Місцезнаходження - Про мене - Мова - Перейти до повного профілю - Перейти до обмеженого профілю - Готово - Змінити зображення профілю - Вибрати з галереї - Видалити фото - Налаштування - Видалити акаунт - Ви впевнені, що бажаєте - видалити свій акаунт? - Для підтвердження цієї дії потрібно ввести пароль вашого акаунту - Так, видалити акаунт - Пароль невірний. Будь ласка, спробуйте знову. - Пароль занадто короткий - Покинути профіль? - Покинути - Продовжити редагування - Зміни, які ви внесли, можуть не бути збереженими. - - diff --git a/whatsnew/src/main/res/values-uk/strings.xml b/whatsnew/src/main/res/values-uk/strings.xml deleted file mode 100644 index d1ad95a41..000000000 --- a/whatsnew/src/main/res/values-uk/strings.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - Що нового - Попередній - Наступний - Закрити - \ No newline at end of file From ad5c6464d085570b34bf68ce3fc929b4e1951842 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk <141041606+PavloNetrebchuk@users.noreply.github.com> Date: Mon, 9 Sep 2024 09:53:19 +0300 Subject: [PATCH 31/56] feat: [FC-0047] xBlock offline mode (#346) * feat: Confirm and Error Dialogs UI * feat: Confirm and Error Dialogs UI * feat: DownloadStorageErrorDialogFragment * feat: StorageBar * feat: Download HTML block * feat: DownloadErrorDialog logic * feat: updateOutdatedOfflineXBlocks * feat: Progress of downloaded blocks * feat: Download all button * feat: List of the largest downloads * feat: Remove all downloads * fix: Fixes according to demo feedback * feat: Cancel Course Download button * feat: Sync offline progress to the LMS * fix: Fixes according to QA feedback * fix: Fixes according to PR feedback * feat: NoAvailableUnitFragment * fix: Fixes according to QA feedback * fix: Fixes according to QA feedback * feat: clean offline progress when logging out * fix: Release R8 build * refactor: clearTables Dispatchers.IO * fix: Fixes according to designer feedback --- app/build.gradle | 3 +- app/proguard-rules.pro | 100 ++-- .../main/java/org/openedx/app/AppActivity.kt | 13 + .../main/java/org/openedx/app/AppViewModel.kt | 22 +- .../main/java/org/openedx/app/di/AppModule.kt | 7 + .../java/org/openedx/app/di/ScreenModule.kt | 48 +- .../java/org/openedx/app/room/AppDatabase.kt | 2 + .../org/openedx/app/room/DatabaseManager.kt | 4 +- .../test/java/org/openedx/AppViewModelTest.kt | 14 +- auth/build.gradle | 1 + auth/proguard-rules.pro | 30 +- build.gradle | 5 +- core/build.gradle | 4 + core/consumer-rules.pro | 2 - core/proguard-rules.pro | 30 +- .../java/org/openedx/core/config/UIConfig.kt | 2 + .../org/openedx/core/data/api/CourseApi.kt | 11 + .../java/org/openedx/core/data/model/Block.kt | 9 +- .../core/data/model/OfflineDownload.kt | 26 + .../core/data/model/XBlockProgressBody.kt | 8 + .../openedx/core/data/model/room/BlockDb.kt | 27 +- .../data/model/room/OfflineXBlockProgress.kt | 49 ++ .../core/domain/model/AssignmentProgress.kt | 6 +- .../org/openedx/core/domain/model/Block.kt | 52 +- .../org/openedx/core/extension/LongExt.kt | 10 +- .../org/openedx/core/extension/StringExt.kt | 7 + .../org/openedx/core/module/DownloadWorker.kt | 49 +- .../core/module/DownloadWorkerController.kt | 5 +- .../openedx/core/module/TranscriptManager.kt | 2 +- .../org/openedx/core/module/db/CalendarDao.kt | 7 + .../org/openedx/core/module/db/DownloadDao.kt | 24 +- .../openedx/core/module/db/DownloadModel.kt | 15 +- .../core/module/db/DownloadModelEntity.kt | 14 +- .../module/download/AbstractDownloader.kt | 18 +- .../module/download/BaseDownloadViewModel.kt | 55 +- .../core/module/download/DownloadHelper.kt | 113 ++++ .../core/repository/CalendarRepository.kt | 11 +- .../core/system/PreviewFragmentManager.kt | 5 + .../org/openedx/core/system/StorageManager.kt | 21 + .../core/system/notifier/DownloadFailed.kt | 7 + .../core/system/notifier/DownloadNotifier.kt | 1 + .../java/org/openedx/core/ui/ComposeCommon.kt | 6 +- .../java/org/openedx/core/utils/FileUtil.kt | 45 ++ core/src/main/res/values/strings.xml | 4 + .../org/openedx/core/ui/theme/Colors.kt | 4 +- course/build.gradle | 1 + course/proguard-rules.pro | 28 +- .../data/repository/CourseRepository.kt | 57 +- .../domain/interactor/CourseInteractor.kt | 16 + .../domain/model/DownloadDialogResource.kt | 9 + .../container/CourseContainerFragment.kt | 16 + .../container/CourseContainerTab.kt | 2 + .../container/CourseContainerViewModel.kt | 8 +- .../presentation/dates/CourseDatesScreen.kt | 16 +- .../presentation/dates/CourseDatesUIState.kt | 14 + .../dates/CourseDatesViewModel.kt | 12 +- .../download/DownloadConfirmDialogFragment.kt | 264 ++++++++++ .../download/DownloadConfirmDialogType.kt | 9 + .../download/DownloadDialogItem.kt | 13 + .../download/DownloadDialogManager.kt | 263 ++++++++++ .../download/DownloadDialogUIState.kt | 17 + .../download/DownloadErrorDialogFragment.kt | 222 ++++++++ .../download/DownloadErrorDialogType.kt | 9 + .../DownloadStorageErrorDialogFragment.kt | 283 ++++++++++ .../presentation/download/DownloadView.kt | 59 +++ .../offline/CourseOfflineScreen.kt | 489 ++++++++++++++++++ .../offline/CourseOfflineUIState.kt | 12 + .../offline/CourseOfflineViewModel.kt | 216 ++++++++ .../outline/CourseOutlineScreen.kt | 12 +- .../outline/CourseOutlineViewModel.kt | 122 +++-- .../section/CourseSectionFragment.kt | 73 +-- .../section/CourseSectionUIState.kt | 2 - .../section/CourseSectionViewModel.kt | 55 +- .../course/presentation/ui/CourseUI.kt | 26 +- .../course/presentation/ui/CourseVideosUI.kt | 26 +- ...ragment.kt => NotAvailableUnitFragment.kt} | 113 ++-- .../presentation/unit/NotAvailableUnitType.kt | 9 + .../container/CourseUnitContainerAdapter.kt | 133 +++-- .../container/CourseUnitContainerViewModel.kt | 5 + .../unit/html/HtmlUnitFragment.kt | 149 ++++-- .../presentation/unit/html/HtmlUnitUIState.kt | 6 + .../unit/html/HtmlUnitViewModel.kt | 50 +- .../videos/CourseVideoViewModel.kt | 75 ++- .../download/DownloadQueueFragment.kt | 4 +- .../download/DownloadQueueViewModel.kt | 11 +- .../worker/OfflineProgressSyncScheduler.kt | 35 ++ .../worker/OfflineProgressSyncWorker.kt | 82 +++ .../src/main/res/drawable/course_ic_error.xml | 9 + .../drawable/course_ic_remove_download.xml | 37 -- .../res/drawable/course_ic_start_download.xml | 9 - course/src/main/res/values/strings.xml | 35 +- .../dates/CourseDatesViewModelTest.kt | 8 +- .../outline/CourseOutlineViewModelTest.kt | 109 ++-- .../section/CourseSectionViewModelTest.kt | 87 +--- .../CourseUnitContainerViewModelTest.kt | 34 +- .../videos/CourseVideoViewModelTest.kt | 63 ++- dashboard/build.gradle | 1 + dashboard/proguard-rules.pro | 28 +- .../presentation/AllEnrolledCoursesView.kt | 3 +- .../openedx/courses/presentation/CourseTab.kt | 2 +- .../presentation/DashboardGalleryView.kt | 3 +- .../presentation/DashboardListFragment.kt | 4 +- default_config/dev/config.yaml | 1 + default_config/prod/config.yaml | 1 + default_config/stage/config.yaml | 1 + discovery/build.gradle | 1 + discovery/proguard-rules.pro | 28 +- .../discovery/presentation/ui/DiscoveryUI.kt | 4 +- discussion/build.gradle | 1 + discussion/proguard-rules.pro | 28 +- .../topics/DiscussionTopicsViewModelTest.kt | 35 +- gradle.properties | 1 + profile/build.gradle | 1 + profile/proguard-rules.pro | 28 +- settings.gradle | 2 +- whatsnew/build.gradle | 1 + whatsnew/proguard-rules.pro | 28 +- 117 files changed, 3530 insertions(+), 984 deletions(-) create mode 100644 core/src/main/java/org/openedx/core/data/model/OfflineDownload.kt create mode 100644 core/src/main/java/org/openedx/core/data/model/XBlockProgressBody.kt create mode 100644 core/src/main/java/org/openedx/core/data/model/room/OfflineXBlockProgress.kt create mode 100644 core/src/main/java/org/openedx/core/module/download/DownloadHelper.kt create mode 100644 core/src/main/java/org/openedx/core/system/PreviewFragmentManager.kt create mode 100644 core/src/main/java/org/openedx/core/system/StorageManager.kt create mode 100644 core/src/main/java/org/openedx/core/system/notifier/DownloadFailed.kt create mode 100644 course/src/main/java/org/openedx/course/domain/model/DownloadDialogResource.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/dates/CourseDatesUIState.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogType.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/download/DownloadDialogItem.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/download/DownloadDialogUIState.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogFragment.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogType.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/download/DownloadStorageErrorDialogFragment.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/download/DownloadView.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineUIState.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt rename course/src/main/java/org/openedx/course/presentation/unit/{NotSupportedUnitFragment.kt => NotAvailableUnitFragment.kt} (52%) create mode 100644 course/src/main/java/org/openedx/course/presentation/unit/NotAvailableUnitType.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitUIState.kt create mode 100644 course/src/main/java/org/openedx/course/worker/OfflineProgressSyncScheduler.kt create mode 100644 course/src/main/java/org/openedx/course/worker/OfflineProgressSyncWorker.kt create mode 100644 course/src/main/res/drawable/course_ic_error.xml delete mode 100644 course/src/main/res/drawable/course_ic_remove_download.xml delete mode 100644 course/src/main/res/drawable/course_ic_start_download.xml diff --git a/app/build.gradle b/app/build.gradle index 659730ff0..cc09177bc 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -10,7 +10,6 @@ apply plugin: 'com.android.application' apply plugin: 'org.jetbrains.kotlin.android' apply plugin: 'kotlin-parcelize' apply plugin: 'kotlin-kapt' -apply plugin: 'fullstory' if (firebaseEnabled) { apply plugin: 'com.google.gms.google-services' @@ -30,6 +29,7 @@ if (firebaseEnabled) { } if (fullstoryEnabled) { + apply plugin: 'fullstory' def fullstoryOrgId = fullstoryConfig?.get("ORG_ID") fullstory { @@ -107,6 +107,7 @@ android { } kotlinOptions { jvmTarget = JavaVersion.VERSION_17 + freeCompilerArgs = List.of("-Xstring-concat=inline") } buildFeatures { viewBinding true diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index dc403e8f7..373a73186 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,66 +1,3 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile - -#====================/////Retrofit Rules\\\\\=============== -# Retrofit does reflection on generic parameters. InnerClasses is required to use Signature and -# EnclosingMethod is required to use InnerClasses. --keepattributes Signature, InnerClasses, EnclosingMethod - -# Retrofit does reflection on method and parameter annotations. --keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations - -# Keep annotation default values (e.g., retrofit2.http.Field.encoded). --keepattributes AnnotationDefault - -# Retain service method parameters when optimizing. --keepclassmembers,allowshrinking,allowobfuscation interface * { - @retrofit2.http.* ; -} - -# Ignore JSR 305 annotations for embedding nullability information. --dontwarn javax.annotation.** - -# Guarded by a NoClassDefFoundError try/catch and only used when on the classpath. --dontwarn kotlin.Unit - -# Top-level functions that can only be used by Kotlin. --dontwarn retrofit2.KotlinExtensions --dontwarn retrofit2.KotlinExtensions$* - -# With R8 full mode, it sees no subtypes of Retrofit interfaces since they are created with a Proxy -# and replaces all potential values with null. Explicitly keeping the interfaces prevents this. --if interface * { @retrofit2.http.* ; } --keep,allowobfuscation interface <1> - -# Keep generic signature of Call, Response (R8 full mode strips signatures from non-kept items). --keep,allowobfuscation,allowshrinking interface retrofit2.Call --keep,allowobfuscation,allowshrinking class retrofit2.Response - -# With R8 full mode generic signatures are stripped for classes that are not -# kept. Suspend functions are wrapped in continuations where the type argument -# is used. --keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation - -#===============/////GSON RULES \\\\\\\============ ##---------------Begin: proguard configuration for Gson ---------- # Gson uses generic type information stored in a class file when working with fields. Proguard # removes such information by default, so configure it to keep all of it. @@ -69,12 +6,8 @@ # For using GSON @Expose annotation -keepattributes *Annotation* -# Gson specific classes --dontwarn sun.misc.** -#-keep class com.google.gson.stream.** { *; } - # Application classes that will be serialized/deserialized over Gson --keep class org.openedx.*.data.model.** { ; } +-keepclassmembers class org.openedx.**.data.model.** { *; } # Prevent proguard from stripping interface information from TypeAdapter, TypeAdapterFactory, # JsonSerializer, JsonDeserializer instances (so they can be used in @JsonAdapter) @@ -85,13 +18,13 @@ # Prevent R8 from leaving Data object members always null -keepclassmembers,allowobfuscation class * { + (); @com.google.gson.annotations.SerializedName ; } # Retain generic signatures of TypeToken and its subclasses with R8 version 3.0 and higher. -keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken -keep,allowobfuscation,allowshrinking class * extends com.google.gson.reflect.TypeToken - ##---------------End: proguard configuration for Gson ---------- -keepclassmembers class * extends java.lang.Enum { @@ -108,4 +41,31 @@ -dontwarn org.conscrypt.ConscryptHostnameVerifier -dontwarn org.openjsse.javax.net.ssl.SSLParameters -dontwarn org.openjsse.javax.net.ssl.SSLSocket --dontwarn org.openjsse.net.ssl.OpenJSSE \ No newline at end of file +-dontwarn org.openjsse.net.ssl.OpenJSSE +-dontwarn com.google.crypto.tink.subtle.Ed25519Sign$KeyPair +-dontwarn com.google.crypto.tink.subtle.Ed25519Sign +-dontwarn com.google.crypto.tink.subtle.Ed25519Verify +-dontwarn com.google.crypto.tink.subtle.X25519 +-dontwarn com.segment.analytics.kotlin.core.platform.plugins.logger.LogFilterKind +-dontwarn com.segment.analytics.kotlin.core.platform.plugins.logger.LogTargetKt +-dontwarn edu.umd.cs.findbugs.annotations.NonNull +-dontwarn edu.umd.cs.findbugs.annotations.Nullable +-dontwarn edu.umd.cs.findbugs.annotations.SuppressFBWarnings +-dontwarn org.bouncycastle.asn1.ASN1Encodable +-dontwarn org.bouncycastle.asn1.pkcs.PrivateKeyInfo +-dontwarn org.bouncycastle.asn1.x509.AlgorithmIdentifier +-dontwarn org.bouncycastle.asn1.x509.SubjectPublicKeyInfo +-dontwarn org.bouncycastle.cert.X509CertificateHolder +-dontwarn org.bouncycastle.cert.jcajce.JcaX509CertificateHolder +-dontwarn org.bouncycastle.crypto.BlockCipher +-dontwarn org.bouncycastle.crypto.CipherParameters +-dontwarn org.bouncycastle.crypto.InvalidCipherTextException +-dontwarn org.bouncycastle.crypto.engines.AESEngine +-dontwarn org.bouncycastle.crypto.modes.GCMBlockCipher +-dontwarn org.bouncycastle.crypto.params.AEADParameters +-dontwarn org.bouncycastle.crypto.params.KeyParameter +-dontwarn org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider +-dontwarn org.bouncycastle.jce.provider.BouncyCastleProvider +-dontwarn org.bouncycastle.openssl.PEMKeyPair +-dontwarn org.bouncycastle.openssl.PEMParser +-dontwarn org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter diff --git a/app/src/main/java/org/openedx/app/AppActivity.kt b/app/src/main/java/org/openedx/app/AppActivity.kt index c12e23bf8..b75825048 100644 --- a/app/src/main/java/org/openedx/app/AppActivity.kt +++ b/app/src/main/java/org/openedx/app/AppActivity.kt @@ -12,10 +12,12 @@ import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope import androidx.window.layout.WindowMetricsCalculator import com.braze.support.toStringMap import io.branch.referral.Branch import io.branch.referral.Branch.BranchUniversalReferralInitListener +import kotlinx.coroutines.launch import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.openedx.app.databinding.ActivityAppBinding @@ -30,6 +32,7 @@ import org.openedx.core.ui.WindowSize import org.openedx.core.ui.WindowType import org.openedx.core.utils.Logger import org.openedx.core.worker.CalendarSyncScheduler +import org.openedx.course.presentation.download.DownloadDialogManager import org.openedx.profile.presentation.ProfileRouter import org.openedx.whatsnew.WhatsNewManager import org.openedx.whatsnew.presentation.whatsnew.WhatsNewFragment @@ -51,6 +54,7 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { private val whatsNewManager by inject() private val corePreferencesManager by inject() private val profileRouter by inject() + private val downloadDialogManager by inject() private val calendarSyncScheduler by inject() private val branchLogger = Logger(BRANCH_TAG) @@ -163,6 +167,15 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { profileRouter.restartApp(supportFragmentManager, viewModel.isLogistrationEnabled) } + lifecycleScope.launch { + viewModel.downloadFailedDialog.collect { + downloadDialogManager.showDownloadFailedPopup( + downloadModel = it.downloadModel, + fragmentManager = supportFragmentManager, + ) + } + } + calendarSyncScheduler.scheduleDailySync() } diff --git a/app/src/main/java/org/openedx/app/AppViewModel.kt b/app/src/main/java/org/openedx/app/AppViewModel.kt index 43faf506f..69fc3a9d9 100644 --- a/app/src/main/java/org/openedx/app/AppViewModel.kt +++ b/app/src/main/java/org/openedx/app/AppViewModel.kt @@ -9,6 +9,9 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.viewModelScope import androidx.room.RoomDatabase import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.openedx.app.deeplink.DeepLink @@ -20,6 +23,8 @@ import org.openedx.core.SingleEventLiveData import org.openedx.core.config.Config import org.openedx.core.data.model.User import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.system.notifier.DownloadFailed +import org.openedx.core.system.notifier.DownloadNotifier import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.core.system.notifier.app.LogoutEvent import org.openedx.core.system.notifier.app.SignInEvent @@ -29,13 +34,14 @@ import org.openedx.core.utils.FileUtil @SuppressLint("StaticFieldLeak") class AppViewModel( private val config: Config, - private val notifier: AppNotifier, + private val appNotifier: AppNotifier, private val room: RoomDatabase, private val preferencesManager: CorePreferences, private val dispatcher: CoroutineDispatcher, private val analytics: AppAnalytics, private val deepLinkRouter: DeepLinkRouter, private val fileUtil: FileUtil, + private val downloadNotifier: DownloadNotifier, private val context: Context ) : BaseViewModel() { @@ -43,6 +49,11 @@ class AppViewModel( val logoutUser: LiveData get() = _logoutUser + private val _downloadFailedDialog = MutableSharedFlow() + val downloadFailedDialog: SharedFlow + get() = _downloadFailedDialog.asSharedFlow() + + val isLogistrationEnabled get() = config.isPreLoginExperienceEnabled() private var logoutHandledAt: Long = 0 @@ -66,7 +77,7 @@ class AppViewModel( } viewModelScope.launch { - notifier.notifier.collect { event -> + appNotifier.notifier.collect { event -> if (event is SignInEvent && config.getFirebaseConfig().isCloudMessagingEnabled) { SyncFirebaseTokenWorker.schedule(context) } else if (event is LogoutEvent) { @@ -74,6 +85,13 @@ class AppViewModel( } } } + viewModelScope.launch { + downloadNotifier.notifier.collect { event -> + if (event is DownloadFailed) { + _downloadFailedDialog.emit(event) + } + } + } } fun logAppLaunchEvent() { 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 795049d31..7cd9d7093 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -35,6 +35,7 @@ import org.openedx.core.data.storage.CorePreferences import org.openedx.core.data.storage.InAppReviewPreferences import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.TranscriptManager +import org.openedx.core.module.download.DownloadHelper import org.openedx.core.module.download.FileDownloader import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.presentation.dialog.appreview.AppReviewAnalytics @@ -57,6 +58,8 @@ import org.openedx.core.worker.CalendarSyncScheduler import org.openedx.course.data.storage.CoursePreferences import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseRouter +import org.openedx.course.presentation.download.DownloadDialogManager +import org.openedx.course.worker.OfflineProgressSyncScheduler import org.openedx.dashboard.presentation.DashboardAnalytics import org.openedx.dashboard.presentation.DashboardRouter import org.openedx.discovery.presentation.DiscoveryAnalytics @@ -89,6 +92,7 @@ val appModule = module { single { AppCookieManager(get(), get()) } single { ReviewManagerFactory.create(get()) } single { CalendarManager(get(), get()) } + single { DownloadDialogManager(get(), get(), get(), get()) } single { DatabaseManager(get(), get(), get(), get()) } single { get() } @@ -200,6 +204,9 @@ val appModule = module { factory { OAuthHelper(get(), get(), get()) } factory { FileUtil(get()) } + single { DownloadHelper(get(), get()) } + + factory { OfflineProgressSyncScheduler(get()) } single { CalendarSyncScheduler(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 ae550922c..541782caf 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -22,6 +22,7 @@ import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.container.CourseContainerViewModel import org.openedx.course.presentation.dates.CourseDatesViewModel import org.openedx.course.presentation.handouts.HandoutsViewModel +import org.openedx.course.presentation.offline.CourseOfflineViewModel import org.openedx.course.presentation.outline.CourseOutlineViewModel import org.openedx.course.presentation.section.CourseSectionViewModel import org.openedx.course.presentation.unit.container.CourseUnitContainerViewModel @@ -83,7 +84,8 @@ val screenModule = module { get(), get(), get(), - get() + get(), + get(), ) } viewModel { MainViewModel(get(), get(), get()) } @@ -271,6 +273,9 @@ val screenModule = module { get(), get(), get(), + get(), + get(), + get(), get() ) } @@ -281,11 +286,6 @@ val screenModule = module { get(), get(), get(), - get(), - get(), - get(), - get(), - get(), ) } viewModel { (courseId: String, unitId: String) -> @@ -296,6 +296,7 @@ val screenModule = module { get(), get(), get(), + get(), ) } viewModel { (courseId: String, courseTitle: String) -> @@ -313,7 +314,10 @@ val screenModule = module { get(), get(), get(), - get() + get(), + get(), + get(), + get(), ) } viewModel { (courseId: String) -> BaseVideoViewModel(courseId, get()) } @@ -437,10 +441,38 @@ val screenModule = module { get(), get(), get(), + get(), + ) + } + viewModel { (blockId: String, courseId: String) -> + HtmlUnitViewModel( + blockId, + courseId, + get(), + get(), + get(), + get(), + get(), + get(), ) } - viewModel { HtmlUnitViewModel(get(), get(), get(), get()) } viewModel { ProgramViewModel(get(), get(), get(), get(), get(), get(), get()) } + viewModel { (courseId: String, courseTitle: String) -> + CourseOfflineViewModel( + courseId, + courseTitle, + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + ) + } + } diff --git a/app/src/main/java/org/openedx/app/room/AppDatabase.kt b/app/src/main/java/org/openedx/app/room/AppDatabase.kt index 1728dfe9b..6aa46ed1f 100644 --- a/app/src/main/java/org/openedx/app/room/AppDatabase.kt +++ b/app/src/main/java/org/openedx/app/room/AppDatabase.kt @@ -6,6 +6,7 @@ import androidx.room.TypeConverters import org.openedx.core.data.model.room.CourseCalendarEventEntity import org.openedx.core.data.model.room.CourseCalendarStateEntity import org.openedx.core.data.model.room.CourseStructureEntity +import org.openedx.core.data.model.room.OfflineXBlockProgress import org.openedx.core.data.model.room.discovery.EnrolledCourseEntity import org.openedx.core.module.db.CalendarDao import org.openedx.core.module.db.DownloadDao @@ -26,6 +27,7 @@ const val DATABASE_NAME = "OpenEdX_db" EnrolledCourseEntity::class, CourseStructureEntity::class, DownloadModelEntity::class, + OfflineXBlockProgress::class, CourseCalendarEventEntity::class, CourseCalendarStateEntity::class ], diff --git a/app/src/main/java/org/openedx/app/room/DatabaseManager.kt b/app/src/main/java/org/openedx/app/room/DatabaseManager.kt index f373e0e42..5d5415854 100644 --- a/app/src/main/java/org/openedx/app/room/DatabaseManager.kt +++ b/app/src/main/java/org/openedx/app/room/DatabaseManager.kt @@ -16,10 +16,10 @@ class DatabaseManager( private val discoveryDao: DiscoveryDao ) : DatabaseManager { override fun clearTables() { - CoroutineScope(Dispatchers.Main).launch { + CoroutineScope(Dispatchers.IO).launch { courseDao.clearCachedData() dashboardDao.clearCachedData() - downloadDao.clearCachedData() + downloadDao.clearOfflineProgress() discoveryDao.clearCachedData() } } diff --git a/app/src/test/java/org/openedx/AppViewModelTest.kt b/app/src/test/java/org/openedx/AppViewModelTest.kt index 6da7a144c..d2fb4897b 100644 --- a/app/src/test/java/org/openedx/AppViewModelTest.kt +++ b/app/src/test/java/org/openedx/AppViewModelTest.kt @@ -22,14 +22,15 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule import org.openedx.app.AppAnalytics -import org.openedx.app.deeplink.DeepLinkRouter import org.openedx.app.AppViewModel import org.openedx.app.data.storage.PreferencesManager +import org.openedx.app.deeplink.DeepLinkRouter import org.openedx.app.room.AppDatabase import org.openedx.core.system.notifier.app.LogoutEvent import org.openedx.core.config.Config import org.openedx.core.config.FirebaseConfig import org.openedx.core.data.model.User +import org.openedx.core.system.notifier.DownloadNotifier import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.core.utils.FileUtil @@ -49,12 +50,14 @@ class AppViewModelTest { private val fileUtil = mockk() private val deepLinkRouter = mockk() private val context = mockk() + private val downloadNotifier = mockk() private val user = User(0, "", "", "") @Before fun before() { Dispatchers.setMain(dispatcher) + every { downloadNotifier.notifier } returns flow { } } @After @@ -79,7 +82,8 @@ class AppViewModelTest { analytics, deepLinkRouter, fileUtil, - context + downloadNotifier, + context, ) val mockLifeCycleOwner: LifecycleOwner = mockk() @@ -114,7 +118,8 @@ class AppViewModelTest { analytics, deepLinkRouter, fileUtil, - context + downloadNotifier, + context, ) val mockLifeCycleOwner: LifecycleOwner = mockk() @@ -151,7 +156,8 @@ class AppViewModelTest { analytics, deepLinkRouter, fileUtil, - context + downloadNotifier, + context, ) val mockLifeCycleOwner: LifecycleOwner = mockk() diff --git a/auth/build.gradle b/auth/build.gradle index 7cf4d0a86..cd6f00621 100644 --- a/auth/build.gradle +++ b/auth/build.gradle @@ -42,6 +42,7 @@ android { } kotlinOptions { jvmTarget = JavaVersion.VERSION_17 + freeCompilerArgs = List.of("-Xstring-concat=inline") } buildFeatures { viewBinding true diff --git a/auth/proguard-rules.pro b/auth/proguard-rules.pro index 82ef50a20..a054eb116 100644 --- a/auth/proguard-rules.pro +++ b/auth/proguard-rules.pro @@ -1,26 +1,12 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile - -if class androidx.credentials.CredentialManager -keep class androidx.credentials.playservices.** { *; } + +# Prevent shrinking, optimization, and obfuscation of the library when consumed by other modules. +# This ensures that all classes and methods remain available for use by the consumer of the library. +# Disabling these steps at the library level is important because the main app module will handle +# shrinking, optimization, and obfuscation for the entire application, including this library. +-dontshrink +-dontoptimize +-dontobfuscate diff --git a/build.gradle b/build.gradle index c163d3982..1dab497e9 100644 --- a/build.gradle +++ b/build.gradle @@ -37,10 +37,10 @@ ext { firebase_version = "33.0.0" - retrofit_version = '2.9.0' + retrofit_version = '2.11.0' logginginterceptor_version = '4.9.1' - koin_version = '3.2.0' + koin_version = '3.5.6' coil_version = '2.3.0' @@ -60,6 +60,7 @@ ext { configHelper = new ConfigHelper(projectDir, getCurrentFlavor()) + zip_version = '2.6.3' //testing mockk_version = '1.13.3' android_arch_version = '2.2.0' diff --git a/core/build.gradle b/core/build.gradle index c18b5ad0c..dce265ef3 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -80,6 +80,7 @@ android { } kotlinOptions { jvmTarget = JavaVersion.VERSION_17 + freeCompilerArgs = List.of("-Xstring-concat=inline") } buildFeatures { @@ -165,6 +166,9 @@ dependencies { api "com.google.android.gms:play-services-ads-identifier:18.0.1" api "com.android.installreferrer:installreferrer:2.2" + // Zip + api "net.lingala.zip4j:zip4j:$zip_version" + testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' diff --git a/core/consumer-rules.pro b/core/consumer-rules.pro index 894a21021..e69de29bb 100644 --- a/core/consumer-rules.pro +++ b/core/consumer-rules.pro @@ -1,2 +0,0 @@ --dontwarn java.lang.invoke.StringConcatFactory --dontwarn org.openedx.core.R$string \ No newline at end of file diff --git a/core/proguard-rules.pro b/core/proguard-rules.pro index a6be9313d..cdb308aa0 100644 --- a/core/proguard-rules.pro +++ b/core/proguard-rules.pro @@ -1,23 +1,7 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile - --dontwarn java.lang.invoke.StringConcatFactory \ No newline at end of file +# Prevent shrinking, optimization, and obfuscation of the library when consumed by other modules. +# This ensures that all classes and methods remain available for use by the consumer of the library. +# Disabling these steps at the library level is important because the main app module will handle +# shrinking, optimization, and obfuscation for the entire application, including this library. +-dontshrink +-dontoptimize +-dontobfuscate diff --git a/core/src/main/java/org/openedx/core/config/UIConfig.kt b/core/src/main/java/org/openedx/core/config/UIConfig.kt index 86c5d6b2b..0da0388bd 100644 --- a/core/src/main/java/org/openedx/core/config/UIConfig.kt +++ b/core/src/main/java/org/openedx/core/config/UIConfig.kt @@ -7,4 +7,6 @@ data class UIConfig( val isCourseDropdownNavigationEnabled: Boolean = false, @SerializedName("COURSE_UNIT_PROGRESS_ENABLED") val isCourseUnitProgressEnabled: Boolean = false, + @SerializedName("COURSE_DOWNLOAD_QUEUE_SCREEN") + val isCourseDownloadQueueEnabled: Boolean = false, ) 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 fab5d924b..4822a3762 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 @@ -1,5 +1,6 @@ package org.openedx.core.data.api +import okhttp3.MultipartBody import org.openedx.core.data.model.AnnouncementModel import org.openedx.core.data.model.BlocksCompletionBody import org.openedx.core.data.model.CourseComponentStatus @@ -13,7 +14,9 @@ import org.openedx.core.data.model.ResetCourseDates import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.Header +import retrofit2.http.Multipart import retrofit2.http.POST +import retrofit2.http.Part import retrofit2.http.Path import retrofit2.http.Query @@ -78,6 +81,14 @@ interface CourseApi { @Query("requested_fields") fields: List = emptyList() ): CourseEnrollments + @Multipart + @POST("/courses/{course_id}/xblock/{block_id}/handler/xmodule_handler/problem_check") + suspend fun submitOfflineXBlockProgress( + @Path("course_id") courseId: String, + @Path("block_id") blockId: String, + @Part progress: List + ) + @GET("/api/mobile/v1/users/{username}/enrollments_status/") suspend fun getEnrollmentsStatus( @Path("username") username: String diff --git a/core/src/main/java/org/openedx/core/data/model/Block.kt b/core/src/main/java/org/openedx/core/data/model/Block.kt index b5581209f..c4b50df63 100644 --- a/core/src/main/java/org/openedx/core/data/model/Block.kt +++ b/core/src/main/java/org/openedx/core/data/model/Block.kt @@ -41,7 +41,9 @@ data class Block( @SerializedName("assignment_progress") val assignmentProgress: AssignmentProgress?, @SerializedName("due") - val due: String? + val due: String?, + @SerializedName("offline_download") + val offlineDownload: OfflineDownload?, ) { fun mapToDomain(blockData: Map): DomainBlock { val blockType = BlockType.getBlockType(type ?: "") @@ -73,6 +75,7 @@ data class Block( containsGatedContent = containsGatedContent ?: false, assignmentProgress = assignmentProgress?.mapToDomain(), due = TimeUtils.iso8601ToDate(due ?: ""), + offlineDownload = offlineDownload?.mapToDomain() ) } } @@ -133,7 +136,7 @@ data class VideoInfo( @SerializedName("url") var url: String?, @SerializedName("file_size") - var fileSize: Int? + var fileSize: Long? ) { fun mapToDomain(): DomainVideoInfo { return DomainVideoInfo( @@ -152,4 +155,4 @@ data class BlockCounts( video = video ?: 0 ) } -} \ No newline at end of file +} diff --git a/core/src/main/java/org/openedx/core/data/model/OfflineDownload.kt b/core/src/main/java/org/openedx/core/data/model/OfflineDownload.kt new file mode 100644 index 000000000..40868fc7a --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/OfflineDownload.kt @@ -0,0 +1,26 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.data.model.room.OfflineDownloadDb +import org.openedx.core.domain.model.OfflineDownload + +data class OfflineDownload( + @SerializedName("file_url") + var fileUrl: String?, + @SerializedName("last_modified") + var lastModified: String?, + @SerializedName("file_size") + var fileSize: Long?, +) { + fun mapToDomain() = OfflineDownload( + fileUrl = fileUrl ?: "", + lastModified = lastModified, + fileSize = fileSize ?: 0 + ) + + fun mapToRoomEntity() = OfflineDownloadDb( + fileUrl = fileUrl ?: "", + lastModified = lastModified, + fileSize = fileSize ?: 0 + ) +} diff --git a/core/src/main/java/org/openedx/core/data/model/XBlockProgressBody.kt b/core/src/main/java/org/openedx/core/data/model/XBlockProgressBody.kt new file mode 100644 index 000000000..25251abfc --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/XBlockProgressBody.kt @@ -0,0 +1,8 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName + +data class XBlockProgressBody( + @SerializedName("body") + val body: String +) diff --git a/core/src/main/java/org/openedx/core/data/model/room/BlockDb.kt b/core/src/main/java/org/openedx/core/data/model/room/BlockDb.kt index 737437dd0..70ddfdf79 100644 --- a/core/src/main/java/org/openedx/core/data/model/room/BlockDb.kt +++ b/core/src/main/java/org/openedx/core/data/model/room/BlockDb.kt @@ -48,7 +48,9 @@ data class BlockDb( @Embedded val assignmentProgress: AssignmentProgressDb?, @ColumnInfo("due") - val due: String? + val due: String?, + @Embedded + val offlineDownload: OfflineDownloadDb?, ) { fun mapToDomain(blocks: List): DomainBlock { val blockType = BlockType.getBlockType(type) @@ -80,6 +82,7 @@ data class BlockDb( containsGatedContent = containsGatedContent, assignmentProgress = assignmentProgress?.mapToDomain(), due = TimeUtils.iso8601ToDate(due ?: ""), + offlineDownload = offlineDownload?.mapToDomain() ) } @@ -105,7 +108,8 @@ data class BlockDb( completion = completion ?: 0.0, containsGatedContent = containsGatedContent ?: false, assignmentProgress = assignmentProgress?.mapToRoomEntity(), - due = due + due = due, + offlineDownload = offlineDownload?.mapToRoomEntity() ) } } @@ -193,7 +197,7 @@ data class VideoInfoDb( @ColumnInfo("url") val url: String, @ColumnInfo("fileSize") - val fileSize: Int + val fileSize: Long ) { fun mapToDomain() = DomainVideoInfo(url, fileSize) @@ -235,3 +239,20 @@ data class AssignmentProgressDb( numPointsPossible = numPointsPossible ?: 0f ) } + +data class OfflineDownloadDb( + @ColumnInfo("file_url") + var fileUrl: String?, + @ColumnInfo("last_modified") + var lastModified: String?, + @ColumnInfo("file_size") + var fileSize: Long?, +) { + fun mapToDomain(): org.openedx.core.domain.model.OfflineDownload { + return org.openedx.core.domain.model.OfflineDownload( + fileUrl = fileUrl ?: "", + lastModified = lastModified, + fileSize = fileSize ?: 0 + ) + } +} diff --git a/core/src/main/java/org/openedx/core/data/model/room/OfflineXBlockProgress.kt b/core/src/main/java/org/openedx/core/data/model/room/OfflineXBlockProgress.kt new file mode 100644 index 000000000..f78ef6524 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/room/OfflineXBlockProgress.kt @@ -0,0 +1,49 @@ +package org.openedx.core.data.model.room + +import androidx.room.ColumnInfo +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.PrimaryKey +import org.json.JSONObject + +@Entity(tableName = "offline_x_block_progress_table") +data class OfflineXBlockProgress( + @PrimaryKey + @ColumnInfo("id") + val blockId: String, + @ColumnInfo("courseId") + val courseId: String, + @Embedded + val jsonProgress: XBlockProgressData, +) + +data class XBlockProgressData( + @PrimaryKey + @ColumnInfo("url") + val url: String, + @ColumnInfo("type") + val type: String, + @ColumnInfo("data") + val data: String +) { + + fun toJson(): String { + val jsonObject = JSONObject() + jsonObject.put("url", url) + jsonObject.put("type", type) + jsonObject.put("data", data) + + return jsonObject.toString() + } + + companion object { + fun parseJson(jsonString: String): XBlockProgressData { + val jsonObject = JSONObject(jsonString) + val url = jsonObject.getString("url") + val type = jsonObject.getString("type") + val data = jsonObject.getString("data") + + return XBlockProgressData(url, type, data) + } + } +} diff --git a/core/src/main/java/org/openedx/core/domain/model/AssignmentProgress.kt b/core/src/main/java/org/openedx/core/domain/model/AssignmentProgress.kt index 659665bfe..730bfbfba 100644 --- a/core/src/main/java/org/openedx/core/domain/model/AssignmentProgress.kt +++ b/core/src/main/java/org/openedx/core/domain/model/AssignmentProgress.kt @@ -1,7 +1,11 @@ package org.openedx.core.domain.model +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize data class AssignmentProgress( val assignmentType: String, val numPointsEarned: Float, val numPointsPossible: Float -) +) : Parcelable diff --git a/core/src/main/java/org/openedx/core/domain/model/Block.kt b/core/src/main/java/org/openedx/core/domain/model/Block.kt index 460f283ba..3ebf8c8b6 100644 --- a/core/src/main/java/org/openedx/core/domain/model/Block.kt +++ b/core/src/main/java/org/openedx/core/domain/model/Block.kt @@ -1,6 +1,9 @@ package org.openedx.core.domain.model +import android.os.Parcelable import android.webkit.URLUtil +import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.RawValue import org.openedx.core.AppDataConstants import org.openedx.core.BlockType import org.openedx.core.module.db.DownloadModel @@ -9,7 +12,7 @@ import org.openedx.core.module.db.FileType import org.openedx.core.utils.VideoUtil import java.util.Date - +@Parcelize data class Block( val id: String, val blockId: String, @@ -28,22 +31,24 @@ data class Block( val containsGatedContent: Boolean = false, val downloadModel: DownloadModel? = null, val assignmentProgress: AssignmentProgress?, - val due: Date? -) { + val due: Date?, + val offlineDownload: OfflineDownload? +) : Parcelable { val isDownloadable: Boolean get() { - return studentViewData != null && studentViewData.encodedVideos?.hasDownloadableVideo == true + return (studentViewData != null && studentViewData.encodedVideos?.hasDownloadableVideo == true) || isxBlock } - val downloadableType: FileType - get() = when (type) { - BlockType.VIDEO -> { - FileType.VIDEO - } + val isxBlock: Boolean + get() = !offlineDownload?.fileUrl.isNullOrEmpty() - else -> { - FileType.UNKNOWN - } + val downloadableType: FileType? + get() = if (type == BlockType.VIDEO) { + FileType.VIDEO + } else if (isxBlock) { + FileType.X_BLOCK + } else { + null } fun isDownloading(): Boolean { @@ -89,14 +94,16 @@ data class Block( val isSurveyBlock get() = type == BlockType.SURVEY } +@Parcelize data class StudentViewData( val onlyOnWeb: Boolean, - val duration: Any, + val duration: @RawValue Any, val transcripts: HashMap?, val encodedVideos: EncodedVideos?, val topicId: String, -) +) : Parcelable +@Parcelize data class EncodedVideos( val youtube: VideoInfo?, var hls: VideoInfo?, @@ -104,7 +111,7 @@ data class EncodedVideos( var desktopMp4: VideoInfo?, var mobileHigh: VideoInfo?, var mobileLow: VideoInfo?, -) { +) : Parcelable { val hasDownloadableVideo: Boolean get() = isPreferredVideoInfo(hls) || isPreferredVideoInfo(fallback) || @@ -184,11 +191,20 @@ data class EncodedVideos( } +@Parcelize data class VideoInfo( val url: String, - val fileSize: Int, -) + val fileSize: Long, +) : Parcelable +@Parcelize data class BlockCounts( val video: Int, -) +) : Parcelable + +@Parcelize +data class OfflineDownload( + var fileUrl: String, + var lastModified: String?, + var fileSize: Long, +) : Parcelable diff --git a/core/src/main/java/org/openedx/core/extension/LongExt.kt b/core/src/main/java/org/openedx/core/extension/LongExt.kt index 06f052616..2071b6946 100644 --- a/core/src/main/java/org/openedx/core/extension/LongExt.kt +++ b/core/src/main/java/org/openedx/core/extension/LongExt.kt @@ -3,14 +3,14 @@ package org.openedx.core.extension import kotlin.math.log10 import kotlin.math.pow -fun Long.toFileSize(round: Int = 2): String { +fun Long.toFileSize(round: Int = 2, space: Boolean = true): String { try { - if (this <= 0) return "0" + if (this <= 0) return "0MB" val units = arrayOf("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB") val digitGroups = (log10(this.toDouble()) / log10(1024.0)).toInt() - return String.format( - "%." + round + "f", this / 1024.0.pow(digitGroups.toDouble()) - ) + " " + units[digitGroups] + val size = this / 1024.0.pow(digitGroups.toDouble()) + val formatString = if (size % 1 < 0.05 || size % 1 >= 0.95) "%.0f" else "%.${round}f" + return String.format(formatString, size) + if (space) " " else "" + units[digitGroups] } catch (e: Exception) { println(e.toString()) } diff --git a/core/src/main/java/org/openedx/core/extension/StringExt.kt b/core/src/main/java/org/openedx/core/extension/StringExt.kt index 343398782..6d8457fed 100644 --- a/core/src/main/java/org/openedx/core/extension/StringExt.kt +++ b/core/src/main/java/org/openedx/core/extension/StringExt.kt @@ -37,3 +37,10 @@ fun String.tagId(): String = this.replaceSpace("_").lowercase(Locale.getDefault( fun String.takeIfNotEmpty(): String? { return if (this.isEmpty().not()) this else null } + +fun String.toImageLink(apiHostURL: String): String = + if (this.isLinkValid()) { + this + } else { + apiHostURL + this.removePrefix("/") + } 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 736a1b1ce..2186dbfc6 100644 --- a/core/src/main/java/org/openedx/core/module/DownloadWorker.kt +++ b/core/src/main/java/org/openedx/core/module/DownloadWorker.kt @@ -19,28 +19,29 @@ import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadModelEntity import org.openedx.core.module.db.DownloadedState +import org.openedx.core.module.download.AbstractDownloader.DownloadResult import org.openedx.core.module.download.CurrentProgress +import org.openedx.core.module.download.DownloadHelper import org.openedx.core.module.download.FileDownloader +import org.openedx.core.system.notifier.DownloadFailed 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, ) : CoroutineWorker(context, parameters), CoroutineScope { - private val notificationManager = - context.getSystemService(Context.NOTIFICATION_SERVICE) as - NotificationManager - + private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager private val notificationBuilder = NotificationCompat.Builder(context, CHANNEL_ID) private val notifier by inject(DownloadNotifier::class.java) private val downloadDao: DownloadDao by inject(DownloadDao::class.java) + private val downloadHelper: DownloadHelper by inject(DownloadHelper::class.java) private var downloadEnqueue = listOf() + private var downloadError = mutableListOf() private val folder = FileUtil(context).getExternalAppDir() @@ -58,7 +59,6 @@ class DownloadWorker( return Result.success() } - private fun createForegroundInfo(): ForegroundInfo { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { createChannel() @@ -116,7 +116,7 @@ class DownloadWorker( folder.mkdir() } - downloadEnqueue = downloadDao.readAllData().first() + downloadEnqueue = downloadDao.getAllDataFlow().first() .map { it.mapToDomain() } .filter { it.downloadedState == DownloadedState.WAITING } @@ -131,21 +131,34 @@ class DownloadWorker( ) ) ) - val isSuccess = fileDownloader.download(downloadTask.url, downloadTask.path) - if (isSuccess) { - downloadDao.updateDownloadModel( - DownloadModelEntity.createFrom( - downloadTask.copy( - downloadedState = DownloadedState.DOWNLOADED, - size = File(downloadTask.path).length().toInt() + val downloadResult = fileDownloader.download(downloadTask.url, downloadTask.path) + when (downloadResult) { + DownloadResult.SUCCESS -> { + val updatedModel = downloadHelper.updateDownloadStatus(downloadTask) + if (updatedModel == null) { + downloadDao.removeDownloadModel(downloadTask.id) + downloadError.add(downloadTask) + } else { + downloadDao.updateDownloadModel( + DownloadModelEntity.createFrom(updatedModel) ) - ) - ) - } else { - downloadDao.removeDownloadModel(downloadTask.id) + } + } + + DownloadResult.CANCELED -> { + downloadDao.removeDownloadModel(downloadTask.id) + } + + DownloadResult.ERROR -> { + downloadDao.removeDownloadModel(downloadTask.id) + downloadError.add(downloadTask) + } } newDownload() } else { + if (downloadError.isNotEmpty()) { + notifier.send(DownloadFailed(downloadError)) + } return } } diff --git a/core/src/main/java/org/openedx/core/module/DownloadWorkerController.kt b/core/src/main/java/org/openedx/core/module/DownloadWorkerController.kt index a4e83c07e..e440cfcc5 100644 --- a/core/src/main/java/org/openedx/core/module/DownloadWorkerController.kt +++ b/core/src/main/java/org/openedx/core/module/DownloadWorkerController.kt @@ -28,7 +28,7 @@ class DownloadWorkerController( init { GlobalScope.launch { - downloadDao.readAllData().collect { list -> + downloadDao.getAllDataFlow().collect { list -> val domainList = list.map { it.mapToDomain() } downloadTaskList = domainList.filter { it.downloadedState == DownloadedState.WAITING || it.downloadedState == DownloadedState.DOWNLOADING @@ -47,7 +47,7 @@ class DownloadWorkerController( private suspend fun updateList() { downloadTaskList = - downloadDao.readAllData().first().map { it.mapToDomain() }.filter { + downloadDao.getAllDataFlow().first().map { it.mapToDomain() }.filter { it.downloadedState == DownloadedState.WAITING || it.downloadedState == DownloadedState.DOWNLOADING } } @@ -83,6 +83,7 @@ class DownloadWorkerController( if (hasDownloading) fileDownloader.cancelDownloading() downloadDao.removeAllDownloadModels(removeIds) + downloadDao.removeOfflineXBlockProgress(removeIds) updateList() 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 c08870a33..114fc3147 100644 --- a/core/src/main/java/org/openedx/core/module/TranscriptManager.kt +++ b/core/src/main/java/org/openedx/core/module/TranscriptManager.kt @@ -65,7 +65,7 @@ class TranscriptManager( downloadLink, file.path ) - if (result) { + if (result == AbstractDownloader.DownloadResult.SUCCESS) { getInputStream(downloadLink)?.let { val transcriptTimedTextObject = convertIntoTimedTextObject(it) diff --git a/core/src/main/java/org/openedx/core/module/db/CalendarDao.kt b/core/src/main/java/org/openedx/core/module/db/CalendarDao.kt index 0dcef5006..686009b92 100644 --- a/core/src/main/java/org/openedx/core/module/db/CalendarDao.kt +++ b/core/src/main/java/org/openedx/core/module/db/CalendarDao.kt @@ -4,6 +4,7 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import androidx.room.Transaction import org.openedx.core.data.model.room.CourseCalendarEventEntity import org.openedx.core.data.model.room.CourseCalendarStateEntity @@ -55,4 +56,10 @@ interface CalendarDao { checksum: Int? = null, isCourseSyncEnabled: Boolean? = null ) + + @Transaction + suspend fun clearCachedData() { + clearCourseCalendarStateCachedData() + clearCourseCalendarEventsCachedData() + } } diff --git a/core/src/main/java/org/openedx/core/module/db/DownloadDao.kt b/core/src/main/java/org/openedx/core/module/db/DownloadDao.kt index 8005a4b95..a07329e4d 100644 --- a/core/src/main/java/org/openedx/core/module/db/DownloadDao.kt +++ b/core/src/main/java/org/openedx/core/module/db/DownloadDao.kt @@ -6,6 +6,7 @@ import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Update import kotlinx.coroutines.flow.Flow +import org.openedx.core.data.model.room.OfflineXBlockProgress @Dao interface DownloadDao { @@ -20,14 +21,29 @@ interface DownloadDao { suspend fun updateDownloadModel(downloadModelEntity: DownloadModelEntity) @Query("SELECT * FROM download_model") - fun readAllData() : Flow> + fun getAllDataFlow(): Flow> + + @Query("SELECT * FROM download_model") + suspend fun readAllData(): List @Query("SELECT * FROM download_model WHERE id in (:ids)") - fun readAllDataByIds(ids: List) : Flow> + fun readAllDataByIds(ids: List): Flow> @Query("DELETE FROM download_model WHERE id in (:ids)") suspend fun removeAllDownloadModels(ids: List) - @Query("DELETE FROM download_model") - suspend fun clearCachedData() + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertOfflineXBlockProgress(offlineXBlockProgress: OfflineXBlockProgress) + + @Query("SELECT * FROM offline_x_block_progress_table WHERE id=:id") + suspend fun getOfflineXBlockProgress(id: String): OfflineXBlockProgress? + + @Query("SELECT * FROM offline_x_block_progress_table") + suspend fun getAllOfflineXBlockProgress(): List + + @Query("DELETE FROM offline_x_block_progress_table WHERE id in (:ids)") + suspend fun removeOfflineXBlockProgress(ids: List) + + @Query("DELETE FROM offline_x_block_progress_table") + suspend fun clearOfflineProgress() } diff --git a/core/src/main/java/org/openedx/core/module/db/DownloadModel.kt b/core/src/main/java/org/openedx/core/module/db/DownloadModel.kt index 86bc31540..da736ba28 100644 --- a/core/src/main/java/org/openedx/core/module/db/DownloadModel.kt +++ b/core/src/main/java/org/openedx/core/module/db/DownloadModel.kt @@ -1,15 +1,20 @@ package org.openedx.core.module.db +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize data class DownloadModel( val id: String, val title: String, - val size: Int, + val courseId: String, + val size: Long, val path: String, val url: String, val type: FileType, val downloadedState: DownloadedState, - val progress: Float? -) + val lastModified: String? = null, +) : Parcelable enum class DownloadedState { WAITING, DOWNLOADING, DOWNLOADED, NOT_DOWNLOADED; @@ -26,5 +31,5 @@ enum class DownloadedState { } enum class FileType { - VIDEO, UNKNOWN -} \ No newline at end of file + VIDEO, X_BLOCK +} diff --git a/core/src/main/java/org/openedx/core/module/db/DownloadModelEntity.kt b/core/src/main/java/org/openedx/core/module/db/DownloadModelEntity.kt index cd12a4eea..4e1a2f2cf 100644 --- a/core/src/main/java/org/openedx/core/module/db/DownloadModelEntity.kt +++ b/core/src/main/java/org/openedx/core/module/db/DownloadModelEntity.kt @@ -11,8 +11,10 @@ data class DownloadModelEntity( val id: String, @ColumnInfo("title") val title: String, + @ColumnInfo("courseId") + val courseId: String, @ColumnInfo("size") - val size: Int, + val size: Long, @ColumnInfo("path") val path: String, @ColumnInfo("url") @@ -21,19 +23,20 @@ data class DownloadModelEntity( val type: String, @ColumnInfo("downloadedState") val downloadedState: String, - @ColumnInfo("progress") - val progress: Float? + @ColumnInfo("lastModified") + val lastModified: String? ) { fun mapToDomain() = DownloadModel( id, title, + courseId, size, path, url, FileType.valueOf(type), DownloadedState.valueOf(downloadedState), - progress + lastModified ) companion object { @@ -43,12 +46,13 @@ data class DownloadModelEntity( return DownloadModelEntity( id, title, + courseId, size, path, url, type.name, downloadedState.name, - progress + lastModified ) } } diff --git a/core/src/main/java/org/openedx/core/module/download/AbstractDownloader.kt b/core/src/main/java/org/openedx/core/module/download/AbstractDownloader.kt index 40144325e..146cc1fc3 100644 --- a/core/src/main/java/org/openedx/core/module/download/AbstractDownloader.kt +++ b/core/src/main/java/org/openedx/core/module/download/AbstractDownloader.kt @@ -35,7 +35,7 @@ abstract class AbstractDownloader : KoinComponent { open suspend fun download( url: String, path: String - ): Boolean { + ): DownloadResult { isCanceled = false return try { val response = downloadApi.downloadFile(url).body() @@ -56,20 +56,23 @@ abstract class AbstractDownloader : KoinComponent { } output?.flush() } - true + DownloadResult.SUCCESS } else { - false + DownloadResult.ERROR } } catch (e: Exception) { e.printStackTrace() - false + if (isCanceled) { + DownloadResult.CANCELED + } else { + DownloadResult.ERROR + } } finally { fos?.close() input?.close() } } - suspend fun cancelDownloading() { isCanceled = true withContext(Dispatchers.IO) { @@ -88,4 +91,7 @@ abstract class AbstractDownloader : KoinComponent { } } -} \ No newline at end of file + enum class DownloadResult { + SUCCESS, CANCELED, ERROR + } +} diff --git a/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt b/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt index 40cc94e4d..40d3f1f41 100644 --- a/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt +++ b/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt @@ -17,8 +17,6 @@ import org.openedx.core.module.db.DownloadedState import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.presentation.CoreAnalyticsEvent import org.openedx.core.presentation.CoreAnalyticsKey -import org.openedx.core.utils.Sha1Util -import java.io.File abstract class BaseDownloadViewModel( private val courseId: String, @@ -26,9 +24,10 @@ abstract class BaseDownloadViewModel( private val preferencesManager: CorePreferences, private val workerController: DownloadWorkerController, private val analytics: CoreAnalytics, + private val downloadHelper: DownloadHelper, ) : BaseViewModel() { - private val allBlocks = hashMapOf() + val allBlocks = hashMapOf() private val downloadableChildrenMap = hashMapOf>() private val downloadModelsStatus = hashMapOf() @@ -42,7 +41,7 @@ abstract class BaseDownloadViewModel( init { viewModelScope.launch { - downloadDao.readAllData().map { list -> list.map { it.mapToDomain() } } + downloadDao.getAllDataFlow().map { list -> list.map { it.mapToDomain() } } .collect { downloadModels -> updateDownloadModelsStatus(downloadModels) _downloadModelsStatusFlow.emit(downloadModelsStatus) @@ -56,7 +55,7 @@ abstract class BaseDownloadViewModel( } private suspend fun getDownloadModelList(): List { - return downloadDao.readAllData().first().map { it.mapToDomain() } + return downloadDao.getAllDataFlow().first().map { it.mapToDomain() } } private suspend fun updateDownloadModelsStatus(models: List) { @@ -121,33 +120,16 @@ abstract class BaseDownloadViewModel( } } - private suspend fun saveDownloadModels(folder: String, saveBlocksIds: List) { + suspend fun saveDownloadModels(folder: String, saveBlocksIds: List) { val downloadModels = mutableListOf() val downloadModelList = getDownloadModelList() for (blockId in saveBlocksIds) { allBlocks[blockId]?.let { block -> - val videoInfo = - block.studentViewData?.encodedVideos?.getPreferredVideoInfoForDownloading( - preferencesManager.videoSettings.videoDownloadQuality - ) - val size = videoInfo?.fileSize ?: 0 - val url = videoInfo?.url ?: "" - val extension = url.split('.').lastOrNull() ?: "mp4" - val path = - folder + File.separator + "${Sha1Util.SHA1(block.displayName)}.$extension" - if (downloadModelList.find { it.id == blockId && it.downloadedState.isDownloaded } == null) { - downloadModels.add( - DownloadModel( - block.id, - block.displayName, - size, - path, - url, - block.downloadableType, - DownloadedState.WAITING, - null - ) - ) + val downloadModel = downloadHelper.generateDownloadModelFromBlock(folder, block, courseId) + val isNotDownloaded = + downloadModelList.find { it.id == blockId && it.downloadedState.isDownloaded } == null + if (isNotDownloaded && downloadModel != null) { + downloadModels.add(downloadModel) } } } @@ -212,6 +194,12 @@ abstract class BaseDownloadViewModel( } } + fun removeBlockDownloadModel(blockId: String) { + viewModelScope.launch { + workerController.removeModel(blockId) + } + } + protected fun addDownloadableChildrenForSequentialBlock(sequentialBlock: Block) { for (item in sequentialBlock.descendants) { allBlocks[item]?.let { blockDescendant -> @@ -229,17 +217,6 @@ abstract class BaseDownloadViewModel( } } - protected fun addDownloadableChildrenForVerticalBlock(verticalBlock: Block) { - for (unitBlockId in verticalBlock.descendants) { - val block = allBlocks[unitBlockId] - if (block?.isDownloadable == true) { - val id = verticalBlock.id - val children = downloadableChildrenMap[id] ?: listOf() - downloadableChildrenMap[id] = children + block.id - } - } - } - fun logBulkDownloadToggleEvent(toggle: Boolean) { logEvent( CoreAnalyticsEvent.VIDEO_BULK_DOWNLOAD_TOGGLE, diff --git a/core/src/main/java/org/openedx/core/module/download/DownloadHelper.kt b/core/src/main/java/org/openedx/core/module/download/DownloadHelper.kt new file mode 100644 index 000000000..7c687f58e --- /dev/null +++ b/core/src/main/java/org/openedx/core/module/download/DownloadHelper.kt @@ -0,0 +1,113 @@ +package org.openedx.core.module.download + +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.Block +import org.openedx.core.module.db.DownloadModel +import org.openedx.core.module.db.DownloadedState +import org.openedx.core.module.db.FileType +import org.openedx.core.utils.FileUtil +import org.openedx.core.utils.Sha1Util +import java.io.File + +class DownloadHelper( + private val preferencesManager: CorePreferences, + private val fileUtil: FileUtil, +) { + + fun generateDownloadModelFromBlock( + folder: String, + block: Block, + courseId: String + ): DownloadModel? { + return when (val downloadableType = block.downloadableType) { + FileType.VIDEO -> { + val videoInfo = + block.studentViewData?.encodedVideos?.getPreferredVideoInfoForDownloading( + preferencesManager.videoSettings.videoDownloadQuality + ) + val size = videoInfo?.fileSize ?: 0 + val url = videoInfo?.url ?: "" + val extension = url.split('.').lastOrNull() ?: "mp4" + val path = + folder + File.separator + "${Sha1Util.SHA1(url)}.$extension" + DownloadModel( + block.id, + block.displayName, + courseId, + size, + path, + url, + downloadableType, + DownloadedState.WAITING, + null + ) + } + + FileType.X_BLOCK -> { + val url = if (block.downloadableType == FileType.X_BLOCK) { + block.offlineDownload?.fileUrl ?: "" + } else { + "" + } + val size = block.offlineDownload?.fileSize ?: 0 + val extension = "zip" + val path = + folder + File.separator + "${Sha1Util.SHA1(url)}.$extension" + val lastModified = block.offlineDownload?.lastModified + DownloadModel( + block.id, + block.displayName, + courseId, + size, + path, + url, + downloadableType, + DownloadedState.WAITING, + lastModified + ) + } + + null -> null + } + } + + suspend fun updateDownloadStatus(downloadModel: DownloadModel): DownloadModel? { + return when (downloadModel.type) { + FileType.VIDEO -> { + downloadModel.copy( + downloadedState = DownloadedState.DOWNLOADED, + size = File(downloadModel.path).length() + ) + } + + FileType.X_BLOCK -> { + val unzippedFolderPath = fileUtil.unzipFile(downloadModel.path) ?: return null + downloadModel.copy( + downloadedState = DownloadedState.DOWNLOADED, + size = calculateDirectorySize(File(unzippedFolderPath)), + path = unzippedFolderPath + ) + } + } + } + + private fun calculateDirectorySize(directory: File): Long { + var size: Long = 0 + + if (directory.exists()) { + val files = directory.listFiles() + + if (files != null) { + for (file in files) { + size += if (file.isDirectory) { + calculateDirectorySize(file) + } else { + file.length() + } + } + } + } + + return size + } +} diff --git a/core/src/main/java/org/openedx/core/repository/CalendarRepository.kt b/core/src/main/java/org/openedx/core/repository/CalendarRepository.kt index e46922605..726709d8a 100644 --- a/core/src/main/java/org/openedx/core/repository/CalendarRepository.kt +++ b/core/src/main/java/org/openedx/core/repository/CalendarRepository.kt @@ -1,9 +1,5 @@ package org.openedx.core.repository -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.launch import org.openedx.core.data.api.CourseApi import org.openedx.core.data.model.room.CourseCalendarEventEntity import org.openedx.core.data.model.room.CourseCalendarStateEntity @@ -55,12 +51,7 @@ class CalendarRepository( } suspend fun clearCalendarCachedData() { - CoroutineScope(Dispatchers.Main).launch { - val clearCourseCalendarStateDeferred = async { calendarDao.clearCourseCalendarStateCachedData() } - val clearCourseCalendarEventsDeferred = async { calendarDao.clearCourseCalendarEventsCachedData() } - clearCourseCalendarStateDeferred.await() - clearCourseCalendarEventsDeferred.await() - } + calendarDao.clearCachedData() } suspend fun updateCourseCalendarStateByIdInCache( diff --git a/core/src/main/java/org/openedx/core/system/PreviewFragmentManager.kt b/core/src/main/java/org/openedx/core/system/PreviewFragmentManager.kt new file mode 100644 index 000000000..36d4b39eb --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/PreviewFragmentManager.kt @@ -0,0 +1,5 @@ +package org.openedx.core.system + +import androidx.fragment.app.FragmentManager + +object PreviewFragmentManager : FragmentManager() diff --git a/core/src/main/java/org/openedx/core/system/StorageManager.kt b/core/src/main/java/org/openedx/core/system/StorageManager.kt new file mode 100644 index 000000000..895072fb1 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/StorageManager.kt @@ -0,0 +1,21 @@ +package org.openedx.core.system + +import android.os.Environment +import android.os.StatFs + +object StorageManager { + + fun getTotalStorage(): Long { + val stat = StatFs(Environment.getDataDirectory().path) + val blockSize = stat.blockSizeLong + val totalBlocks = stat.blockCountLong + return totalBlocks * blockSize + } + + fun getFreeStorage(): Long { + val stat = StatFs(Environment.getDataDirectory().path) + val blockSize = stat.blockSizeLong + val availableBlocks = stat.availableBlocksLong + return availableBlocks * blockSize + } +} diff --git a/core/src/main/java/org/openedx/core/system/notifier/DownloadFailed.kt b/core/src/main/java/org/openedx/core/system/notifier/DownloadFailed.kt new file mode 100644 index 000000000..c5812f57f --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/DownloadFailed.kt @@ -0,0 +1,7 @@ +package org.openedx.core.system.notifier + +import org.openedx.core.module.db.DownloadModel + +data class DownloadFailed( + val downloadModel: List +) : DownloadEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/DownloadNotifier.kt b/core/src/main/java/org/openedx/core/system/notifier/DownloadNotifier.kt index eb16cf99f..9c0c698cf 100644 --- a/core/src/main/java/org/openedx/core/system/notifier/DownloadNotifier.kt +++ b/core/src/main/java/org/openedx/core/system/notifier/DownloadNotifier.kt @@ -11,5 +11,6 @@ class DownloadNotifier { val notifier: Flow = channel.asSharedFlow() suspend fun send(event: DownloadProgressChanged) = channel.emit(event) + suspend fun send(event: DownloadFailed) = channel.emit(event) } 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 26806897f..eb9f92800 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt @@ -833,6 +833,7 @@ fun AutoSizeText( style: TextStyle, color: Color = Color.Unspecified, maxLines: Int = Int.MAX_VALUE, + minSize: Float = 0f ) { var scaledTextStyle by remember { mutableStateOf(style) } var readyToDraw by remember { mutableStateOf(false) } @@ -849,9 +850,8 @@ fun AutoSizeText( softWrap = false, maxLines = maxLines, onTextLayout = { textLayoutResult -> - if (textLayoutResult.didOverflowWidth) { - scaledTextStyle = - scaledTextStyle.copy(fontSize = scaledTextStyle.fontSize * 0.9) + if (textLayoutResult.didOverflowWidth && scaledTextStyle.fontSize.value > minSize) { + scaledTextStyle = scaledTextStyle.copy(fontSize = scaledTextStyle.fontSize * 0.9) } else { readyToDraw = true } 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 a59317193..7c7423e60 100644 --- a/core/src/main/java/org/openedx/core/utils/FileUtil.kt +++ b/core/src/main/java/org/openedx/core/utils/FileUtil.kt @@ -1,8 +1,11 @@ package org.openedx.core.utils import android.content.Context +import android.util.Log import com.google.gson.Gson import com.google.gson.GsonBuilder +import net.lingala.zip4j.ZipFile +import net.lingala.zip4j.exception.ZipException import java.io.File import java.util.Collections @@ -72,6 +75,48 @@ class FileUtil(val context: Context) { // noinspection ResultOfMethodCallIgnored fileOrDirectory.delete() } + + fun unzipFile(filepath: String): String? { + val archive = File(filepath) + val destinationFolder = File( + archive.parentFile.absolutePath + "/" + archive.name + "-unzipped" + ) + try { + if (!destinationFolder.exists()) { + destinationFolder.mkdirs() + } + val zip = ZipFile(archive) + zip.extractAll(destinationFolder.absolutePath) + deleteFile(archive.absolutePath) + return destinationFolder.absolutePath + } catch (e: ZipException) { + e.printStackTrace() + deleteFile(destinationFolder.absolutePath) + } + return null + } + + private fun deleteFile(filepath: String?): Boolean { + try { + if (filepath != null) { + val file = File(filepath) + if (file.exists()) { + if (file.delete()) { + Log.d(this.javaClass.name, "Deleted: " + file.path) + return true + } else { + Log.d(this.javaClass.name, "Delete failed: " + file.path) + } + } else { + Log.d(this.javaClass.name, "Delete failed, file does NOT exist: " + file.path) + return true + } + } + } catch (e: Exception) { + e.printStackTrace() + } + return false + } } enum class Directories { diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index 9aded8c31..00b02502a 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -182,6 +182,10 @@ Discussions More Dates + Confirm Download + Edit + Offline Progress Sync + Close Calendar Sync Failed Synced to Calendar diff --git a/core/src/openedx/org/openedx/core/ui/theme/Colors.kt b/core/src/openedx/org/openedx/core/ui/theme/Colors.kt index ffc0b64e2..69f550018 100644 --- a/core/src/openedx/org/openedx/core/ui/theme/Colors.kt +++ b/core/src/openedx/org/openedx/core/ui/theme/Colors.kt @@ -8,7 +8,7 @@ val light_secondary = Color(0xFF94D3DD) val light_secondary_variant = Color(0xFF94D3DD) val light_background = Color.White val light_surface = Color(0xFFF7F7F8) -val light_error = Color(0xFFFF3D71) +val light_error = Color(0xFFE8174F) val light_onPrimary = Color.White val light_onSecondary = Color.White val light_onBackground = Color.Black @@ -73,7 +73,7 @@ val light_course_home_header_shade = Color(0xFFBABABA) val light_course_home_back_btn_background = Color.White val light_settings_title_content = Color.White val light_progress_bar_color = light_primary -val light_progress_bar_background_color = Color(0xFF97A5BB) +val light_progress_bar_background_color = Color(0xFFCCD4E0) val dark_primary = Color(0xFF3F68F8) diff --git a/course/build.gradle b/course/build.gradle index f746f4d09..49946ca92 100644 --- a/course/build.gradle +++ b/course/build.gradle @@ -29,6 +29,7 @@ android { } kotlinOptions { jvmTarget = JavaVersion.VERSION_17 + freeCompilerArgs = List.of("-Xstring-concat=inline") } buildFeatures { diff --git a/course/proguard-rules.pro b/course/proguard-rules.pro index 481bb4348..dccbe504f 100644 --- a/course/proguard-rules.pro +++ b/course/proguard-rules.pro @@ -1,21 +1,7 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +# Prevent shrinking, optimization, and obfuscation of the library when consumed by other modules. +# This ensures that all classes and methods remain available for use by the consumer of the library. +# Disabling these steps at the library level is important because the main app module will handle +# shrinking, optimization, and obfuscation for the entire application, including this library. +-dontshrink +-dontoptimize +-dontobfuscate \ No newline at end of file diff --git a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt index c32397a48..8eaafe721 100644 --- a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt +++ b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt @@ -1,9 +1,12 @@ package org.openedx.course.data.repository import kotlinx.coroutines.flow.map +import okhttp3.MultipartBody import org.openedx.core.ApiConstants import org.openedx.core.data.api.CourseApi import org.openedx.core.data.model.BlocksCompletionBody +import org.openedx.core.data.model.room.OfflineXBlockProgress +import org.openedx.core.data.model.room.XBlockProgressData import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.CourseComponentStatus import org.openedx.core.domain.model.CourseStructure @@ -11,6 +14,8 @@ import org.openedx.core.exception.NoCachedDataException import org.openedx.core.module.db.DownloadDao import org.openedx.core.system.connection.NetworkConnection import org.openedx.course.data.storage.CourseDao +import java.net.URLDecoder +import java.nio.charset.StandardCharsets class CourseRepository( private val api: CourseApi, @@ -25,12 +30,19 @@ class CourseRepository( downloadDao.removeDownloadModel(id) } - fun getDownloadModels() = downloadDao.readAllData().map { list -> + fun getDownloadModels() = downloadDao.getAllDataFlow().map { list -> list.map { it.mapToDomain() } } - fun hasCourses(courseId: String): Boolean { - return courseStructure[courseId] != null + suspend fun getAllDownloadModels() = downloadDao.readAllData().map { it.mapToDomain() } + + suspend fun getCourseStructureFromCache(courseId: String): CourseStructure { + val cachedCourseStructure = courseDao.getCourseStructureById(courseId) + if (cachedCourseStructure != null) { + return cachedCourseStructure.mapToDomain() + } else { + throw NoCachedDataException() + } } suspend fun getCourseStructure(courseId: String, isNeedRefresh: Boolean): CourseStructure { @@ -39,7 +51,7 @@ class CourseRepository( if (networkConnection.isOnline()) { val response = api.getCourseStructure( "stale-if-error=0", - "v3", + "v4", preferencesManager.user?.username, courseId ) @@ -86,4 +98,41 @@ class CourseRepository( suspend fun getAnnouncements(courseId: String) = api.getAnnouncements(courseId).map { it.mapToDomain() } + + suspend fun saveOfflineXBlockProgress(blockId: String, courseId: String, jsonProgress: String) { + val offlineXBlockProgress = OfflineXBlockProgress( + blockId = blockId, + courseId = courseId, + jsonProgress = XBlockProgressData.parseJson(jsonProgress) + ) + downloadDao.insertOfflineXBlockProgress(offlineXBlockProgress) + } + + suspend fun getXBlockProgress(blockId: String) = downloadDao.getOfflineXBlockProgress(blockId) + + suspend fun submitAllOfflineXBlockProgress() { + val allOfflineXBlockProgress = downloadDao.getAllOfflineXBlockProgress() + allOfflineXBlockProgress.forEach { + submitOfflineXBlockProgress(it.blockId, it.courseId, it.jsonProgress.data) + } + } + + suspend fun submitOfflineXBlockProgress(blockId: String, courseId: String) { + val jsonProgressData = getXBlockProgress(blockId)?.jsonProgress?.data + submitOfflineXBlockProgress(blockId, courseId, jsonProgressData) + } + + private suspend fun submitOfflineXBlockProgress(blockId: String, courseId: String, jsonProgressData: String?) { + if (!jsonProgressData.isNullOrEmpty()) { + val parts = mutableListOf() + val decodedQuery = URLDecoder.decode(jsonProgressData, StandardCharsets.UTF_8.name()) + val keyValuePairs = decodedQuery.split("&") + for (pair in keyValuePairs) { + val (key, value) = pair.split("=") + parts.add(MultipartBody.Part.createFormData(key, value)) + } + api.submitOfflineXBlockProgress(courseId, blockId, parts) + downloadDao.removeOfflineXBlockProgress(listOf(blockId)) + } + } } diff --git a/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt b/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt index 5bc859120..22248d57d 100644 --- a/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt +++ b/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt @@ -16,6 +16,10 @@ class CourseInteractor( return repository.getCourseStructure(courseId, isNeedRefresh) } + suspend fun getCourseStructureFromCache(courseId: String): CourseStructure { + return repository.getCourseStructureFromCache(courseId) + } + suspend fun getCourseStructureForVideos( courseId: String, isNeedRefresh: Boolean = false @@ -72,4 +76,16 @@ class CourseInteractor( fun getDownloadModels() = repository.getDownloadModels() + suspend fun getAllDownloadModels() = repository.getAllDownloadModels() + + suspend fun saveXBlockProgress(blockId: String, courseId: String, jsonProgress: String) { + repository.saveOfflineXBlockProgress(blockId, courseId, jsonProgress) + } + + suspend fun getXBlockProgress(blockId: String) = repository.getXBlockProgress(blockId) + + suspend fun submitAllOfflineXBlockProgress() = repository.submitAllOfflineXBlockProgress() + + suspend fun submitOfflineXBlockProgress(blockId: String, courseId: String) = + repository.submitOfflineXBlockProgress(blockId, courseId) } diff --git a/course/src/main/java/org/openedx/course/domain/model/DownloadDialogResource.kt b/course/src/main/java/org/openedx/course/domain/model/DownloadDialogResource.kt new file mode 100644 index 000000000..cded4944a --- /dev/null +++ b/course/src/main/java/org/openedx/course/domain/model/DownloadDialogResource.kt @@ -0,0 +1,9 @@ +package org.openedx.course.domain.model + +import androidx.compose.ui.graphics.painter.Painter + +data class DownloadDialogResource( + val title: String, + val description: String, + val icon: Painter? = null, +) 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 9168d3148..9e3db405c 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 @@ -70,6 +70,7 @@ import org.openedx.course.databinding.FragmentCourseContainerBinding import org.openedx.course.presentation.dates.CourseDatesScreen import org.openedx.course.presentation.handouts.HandoutsScreen import org.openedx.course.presentation.handouts.HandoutsType +import org.openedx.course.presentation.offline.CourseOfflineScreen import org.openedx.course.presentation.outline.CourseOutlineScreen import org.openedx.course.presentation.ui.CourseVideosScreen import org.openedx.course.presentation.ui.DatesShiftedSnackBar @@ -437,6 +438,21 @@ fun DashboardPager( ) } + CourseContainerTab.OFFLINE -> { + CourseOfflineScreen( + windowSize = windowSize, + viewModel = koinViewModel( + parameters = { + parametersOf( + bundle.getString(CourseContainerFragment.ARG_COURSE_ID, ""), + bundle.getString(CourseContainerFragment.ARG_TITLE, "") + ) + } + ), + fragmentManager = fragmentManager, + ) + } + CourseContainerTab.DISCUSSIONS -> { DiscussionTopicsScreen( discussionTopicsViewModel = koinViewModel( diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerTab.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerTab.kt index fbdbb60fc..255b7e88b 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerTab.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerTab.kt @@ -6,6 +6,7 @@ import androidx.compose.material.icons.automirrored.filled.Chat import androidx.compose.material.icons.automirrored.filled.TextSnippet import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.outlined.CalendarMonth +import androidx.compose.material.icons.outlined.CloudDownload import androidx.compose.material.icons.rounded.PlayCircleFilled import androidx.compose.ui.graphics.vector.ImageVector import org.openedx.core.ui.TabItem @@ -19,6 +20,7 @@ enum class CourseContainerTab( HOME(R.string.course_container_nav_home, Icons.Default.Home), VIDEOS(R.string.course_container_nav_videos, Icons.Rounded.PlayCircleFilled), DATES(R.string.course_container_nav_dates, Icons.Outlined.CalendarMonth), + OFFLINE(R.string.course_container_nav_downloads, Icons.Outlined.CloudDownload), 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 8d0f404c3..d30d68c00 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 @@ -23,6 +23,7 @@ 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.extension.toImageLink import org.openedx.core.presentation.settings.calendarsync.CalendarSyncDialogType import org.openedx.core.presentation.settings.calendarsync.CalendarSyncUIState import org.openedx.core.system.ResourceManager @@ -193,7 +194,7 @@ class CourseContainerViewModel( private fun loadCourseImage(imageUrl: String?) { imageProcessor.loadImage( - imageUrl = config.getApiHostURL() + imageUrl, + imageUrl = imageUrl?.toImageLink(config.getApiHostURL()) ?: "", defaultImage = CoreR.drawable.core_no_image_course, onComplete = { drawable -> val bitmap = (drawable as BitmapDrawable).bitmap.apply { @@ -219,6 +220,10 @@ class CourseContainerViewModel( updateData() } + CourseContainerTab.OFFLINE -> { + updateData() + } + CourseContainerTab.DATES -> { viewModelScope.launch { courseNotifier.send(RefreshDates) @@ -262,6 +267,7 @@ class CourseContainerViewModel( CourseContainerTab.DISCUSSIONS -> discussionTabClickedEvent() CourseContainerTab.DATES -> datesTabClickedEvent() CourseContainerTab.MORE -> moreTabClickedEvent() + CourseContainerTab.OFFLINE -> {} } } 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 69f6e0559..b148c8acb 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 @@ -100,7 +100,7 @@ fun CourseDatesScreen( isFragmentResumed: Boolean, updateCourseStructure: () -> Unit ) { - val uiState by viewModel.uiState.collectAsState(DatesUIState.Loading) + val uiState by viewModel.uiState.collectAsState(CourseDatesUIState.Loading) val uiMessage by viewModel.uiMessage.collectAsState(null) val context = LocalContext.current @@ -175,7 +175,7 @@ fun CourseDatesScreen( @Composable private fun CourseDatesUI( windowSize: WindowSize, - uiState: DatesUIState, + uiState: CourseDatesUIState, uiMessage: UIMessage?, isSelfPaced: Boolean, onItemClick: (CourseDateBlock) -> Unit, @@ -210,7 +210,7 @@ private fun CourseDatesUI( HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) - val isPLSBannerAvailable = (uiState as? DatesUIState.Dates) + val isPLSBannerAvailable = (uiState as? CourseDatesUIState.CourseDates) ?.courseDatesResult ?.courseBanner ?.isBannerAvailableForUserType(isSelfPaced) @@ -236,7 +236,7 @@ private fun CourseDatesUI( .fillMaxWidth() ) { when (uiState) { - is DatesUIState.Dates -> { + is CourseDatesUIState.CourseDates -> { LazyColumn( modifier = Modifier .fillMaxSize() @@ -332,7 +332,7 @@ private fun CourseDatesUI( } } - DatesUIState.Empty -> { + CourseDatesUIState.Empty -> { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center @@ -347,7 +347,7 @@ private fun CourseDatesUI( } } - DatesUIState.Loading -> {} + CourseDatesUIState.Loading -> {} } } } @@ -677,7 +677,7 @@ private fun CourseDatesScreenPreview() { OpenEdXTheme { CourseDatesUI( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - uiState = DatesUIState.Dates( + uiState = CourseDatesUIState.CourseDates( CourseDatesResult(mockedResponse, mockedCourseBannerInfo), CalendarSyncState.SYNCED ), @@ -698,7 +698,7 @@ private fun CourseDatesScreenTabletPreview() { OpenEdXTheme { CourseDatesUI( windowSize = WindowSize(WindowType.Medium, WindowType.Medium), - uiState = DatesUIState.Dates( + uiState = CourseDatesUIState.CourseDates( CourseDatesResult(mockedResponse, mockedCourseBannerInfo), CalendarSyncState.SYNCED ), diff --git a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesUIState.kt b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesUIState.kt new file mode 100644 index 000000000..5623129d0 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesUIState.kt @@ -0,0 +1,14 @@ +package org.openedx.course.presentation.dates + +import org.openedx.core.domain.model.CourseDatesResult +import org.openedx.core.presentation.settings.calendarsync.CalendarSyncState + +sealed interface CourseDatesUIState { + data class CourseDates( + val courseDatesResult: CourseDatesResult, + val calendarSyncState: CalendarSyncState, + ) : CourseDatesUIState + + data object Empty : CourseDatesUIState + data object Loading : CourseDatesUIState +} 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 addad3199..589c103fc 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 @@ -54,8 +54,8 @@ class CourseDatesViewModel( var isSelfPaced = true - private val _uiState = MutableStateFlow(DatesUIState.Loading) - val uiState: StateFlow + private val _uiState = MutableStateFlow(CourseDatesUIState.Loading) + val uiState: StateFlow get() = _uiState.asStateFlow() private val _uiMessage = MutableSharedFlow() @@ -82,7 +82,7 @@ class CourseDatesViewModel( (_uiState.value as? DatesUIState.Dates)?.let { currentUiState -> val courseDates = currentUiState.courseDatesResult.datesSection.values.flatten() _uiState.update { - (it as DatesUIState.Dates).copy(calendarSyncState = getCalendarState(courseDates)) + (it as CourseDatesUIState.CourseDates).copy(calendarSyncState = getCalendarState(courseDates)) } } } @@ -98,11 +98,11 @@ class CourseDatesViewModel( isSelfPaced = courseStructure?.isSelfPaced ?: false val datesResponse = interactor.getCourseDates(courseId = courseId) if (datesResponse.datesSection.isEmpty()) { - _uiState.value = DatesUIState.Empty + _uiState.value = CourseDatesUIState.Empty } else { val courseDates = datesResponse.datesSection.values.flatten() val calendarState = getCalendarState(courseDates) - _uiState.value = DatesUIState.Dates(datesResponse, calendarState) + _uiState.value = CourseDatesUIState.CourseDates(datesResponse, calendarState) courseBannerType = datesResponse.courseBanner.bannerType checkIfCalendarOutOfDate() } @@ -156,7 +156,7 @@ class CourseDatesViewModel( private fun checkIfCalendarOutOfDate() { val value = _uiState.value - if (value is DatesUIState.Dates) { + if (value is CourseDatesUIState.CourseDates) { viewModelScope.launch { courseNotifier.send( CreateCalendarSyncEvent( diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt new file mode 100644 index 000000000..1c220903f --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt @@ -0,0 +1,264 @@ +package org.openedx.course.presentation.download + +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CloudDownload +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.os.bundleOf +import androidx.fragment.app.DialogFragment +import org.openedx.core.extension.parcelable +import org.openedx.core.extension.toFileSize +import org.openedx.core.presentation.dialog.DefaultDialogBox +import org.openedx.core.system.PreviewFragmentManager +import org.openedx.core.ui.AutoSizeText +import org.openedx.core.ui.IconText +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.OpenEdXOutlinedButton +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appTypography +import org.openedx.course.R +import org.openedx.course.domain.model.DownloadDialogResource +import androidx.compose.ui.graphics.Color as ComposeColor +import org.openedx.core.R as coreR + +class DownloadConfirmDialogFragment : DialogFragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val dialogType = + requireArguments().parcelable(ARG_DIALOG_TYPE) ?: return@OpenEdXTheme + val uiState = requireArguments().parcelable(ARG_UI_STATE) ?: return@OpenEdXTheme + val sizeSumString = uiState.sizeSum.toFileSize(1, false) + val dialogData = when (dialogType) { + DownloadConfirmDialogType.CONFIRM -> DownloadDialogResource( + title = stringResource(id = coreR.string.course_confirm_download), + description = stringResource( + id = R.string.course_download_confirm_dialog_description, + sizeSumString + ), + ) + + DownloadConfirmDialogType.DOWNLOAD_ON_CELLULAR -> DownloadDialogResource( + title = stringResource(id = R.string.course_download_on_cellural), + description = stringResource( + id = R.string.course_download_on_cellural_dialog_description, + sizeSumString + ), + icon = painterResource(id = coreR.drawable.core_ic_warning), + ) + + DownloadConfirmDialogType.REMOVE -> DownloadDialogResource( + title = stringResource(id = R.string.course_download_remove_offline_content), + description = stringResource( + id = R.string.course_download_remove_dialog_description, + sizeSumString + ) + ) + } + + DownloadConfirmDialogView( + downloadDialogResource = dialogData, + uiState = uiState, + dialogType = dialogType, + onConfirmClick = { + uiState.saveDownloadModels() + dismiss() + }, + onRemoveClick = { + uiState.removeDownloadModels() + dismiss() + }, + onCancelClick = { + dismiss() + } + ) + } + } + } + + companion object { + const val DIALOG_TAG = "DownloadConfirmDialogFragment" + const val ARG_DIALOG_TYPE = "dialogType" + const val ARG_UI_STATE = "uiState" + + fun newInstance( + dialogType: DownloadConfirmDialogType, + uiState: DownloadDialogUIState + ): DownloadConfirmDialogFragment { + val dialog = DownloadConfirmDialogFragment() + dialog.arguments = bundleOf( + ARG_DIALOG_TYPE to dialogType, + ARG_UI_STATE to uiState + ) + return dialog + } + } +} + +@Composable +private fun DownloadConfirmDialogView( + modifier: Modifier = Modifier, + uiState: DownloadDialogUIState, + downloadDialogResource: DownloadDialogResource, + dialogType: DownloadConfirmDialogType, + onRemoveClick: () -> Unit, + onConfirmClick: () -> Unit, + onCancelClick: () -> Unit +) { + val scrollState = rememberScrollState() + DefaultDialogBox( + modifier = modifier, + onDismissClick = onCancelClick + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(scrollState) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + downloadDialogResource.icon?.let { icon -> + Image( + painter = icon, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + } + AutoSizeText( + text = downloadDialogResource.title, + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textDark, + minSize = MaterialTheme.appTypography.titleLarge.fontSize.value - 1 + ) + } + Column { + uiState.downloadDialogItems.forEach { + DownloadDialogItem(downloadDialogItem = it) + } + } + Text( + text = downloadDialogResource.description, + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.textDark + ) + + val buttonText: String + val buttonIcon: ImageVector + val buttonColor: ComposeColor + val onClick: () -> Unit + when (dialogType) { + DownloadConfirmDialogType.REMOVE -> { + buttonText = stringResource(id = R.string.course_remove) + buttonIcon = Icons.Rounded.Delete + buttonColor = MaterialTheme.appColors.error + onClick = onRemoveClick + } + + else -> { + buttonText = stringResource(id = R.string.course_download) + buttonIcon = Icons.Outlined.CloudDownload + buttonColor = MaterialTheme.appColors.secondaryButtonBackground + onClick = onConfirmClick + } + } + OpenEdXButton( + text = buttonText, + backgroundColor = buttonColor, + onClick = onClick, + content = { + IconText( + text = buttonText, + icon = buttonIcon, + color = MaterialTheme.appColors.primaryButtonText, + textStyle = MaterialTheme.appTypography.labelLarge + ) + } + ) + OpenEdXOutlinedButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = coreR.string.core_cancel), + backgroundColor = MaterialTheme.appColors.background, + borderColor = MaterialTheme.appColors.primaryButtonBackground, + textColor = MaterialTheme.appColors.primaryButtonBackground, + onClick = { + onCancelClick() + } + ) + } + } +} + +@Preview +@Composable +private fun DownloadConfirmDialogViewPreview() { + OpenEdXTheme { + DownloadConfirmDialogView( + downloadDialogResource = DownloadDialogResource( + title = "Title", + description = "Description Description Description Description Description Description Description " + ), + uiState = DownloadDialogUIState( + downloadDialogItems = listOf( + DownloadDialogItem( + title = "Subsection title 1", + size = 20000 + ), + DownloadDialogItem( + title = "Subsection title 2", + size = 10000000 + ) + ), + sizeSum = 1000000, + isAllBlocksDownloaded = false, + isDownloadFailed = false, + saveDownloadModels = {}, + removeDownloadModels = {}, + fragmentManager = PreviewFragmentManager + ), + dialogType = DownloadConfirmDialogType.CONFIRM, + onConfirmClick = {}, + onRemoveClick = {}, + onCancelClick = {} + ) + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogType.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogType.kt new file mode 100644 index 000000000..9c0833ff3 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogType.kt @@ -0,0 +1,9 @@ +package org.openedx.course.presentation.download + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +enum class DownloadConfirmDialogType : Parcelable { + DOWNLOAD_ON_CELLULAR, CONFIRM, REMOVE +} diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogItem.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogItem.kt new file mode 100644 index 000000000..9f3cfc4d4 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogItem.kt @@ -0,0 +1,13 @@ +package org.openedx.course.presentation.download + +import android.os.Parcelable +import androidx.compose.ui.graphics.vector.ImageVector +import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.RawValue + +@Parcelize +data class DownloadDialogItem( + val title: String, + val size: Long, + val icon: @RawValue ImageVector? = null +) : Parcelable diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt new file mode 100644 index 000000000..64a95d2d8 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt @@ -0,0 +1,263 @@ +package org.openedx.course.presentation.download + +import androidx.fragment.app.FragmentManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch +import org.openedx.core.BlockType +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.Block +import org.openedx.core.module.DownloadWorkerController +import org.openedx.core.module.db.DownloadModel +import org.openedx.core.system.StorageManager +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.course.domain.interactor.CourseInteractor + +class DownloadDialogManager( + private val networkConnection: NetworkConnection, + private val corePreferences: CorePreferences, + private val interactor: CourseInteractor, + private val workerController: DownloadWorkerController +) { + + companion object { + const val MAX_CELLULAR_SIZE = 104857600 // 100MB + const val DOWNLOAD_SIZE_FACTOR = 2 // Multiplier to match required disk size + } + + private val uiState = MutableSharedFlow() + + init { + CoroutineScope(Dispatchers.IO).launch { + uiState.collect { uiState -> + when { + uiState.isDownloadFailed -> { + val dialog = DownloadErrorDialogFragment.newInstance( + dialogType = DownloadErrorDialogType.DOWNLOAD_FAILED, + uiState = uiState + ) + dialog.show( + uiState.fragmentManager, + DownloadErrorDialogFragment.DIALOG_TAG + ) + } + + uiState.isAllBlocksDownloaded -> { + val dialog = DownloadConfirmDialogFragment.newInstance( + dialogType = DownloadConfirmDialogType.REMOVE, + uiState = uiState + ) + dialog.show( + uiState.fragmentManager, + DownloadConfirmDialogFragment.DIALOG_TAG + ) + } + + !networkConnection.isOnline() -> { + val dialog = DownloadErrorDialogFragment.newInstance( + dialogType = DownloadErrorDialogType.NO_CONNECTION, + uiState = uiState + ) + dialog.show( + uiState.fragmentManager, + DownloadErrorDialogFragment.DIALOG_TAG + ) + } + + StorageManager.getFreeStorage() < uiState.sizeSum * DOWNLOAD_SIZE_FACTOR -> { + val dialog = DownloadStorageErrorDialogFragment.newInstance( + uiState = uiState + ) + dialog.show( + uiState.fragmentManager, + DownloadStorageErrorDialogFragment.DIALOG_TAG + ) + } + + corePreferences.videoSettings.wifiDownloadOnly && !networkConnection.isWifiConnected() -> { + val dialog = DownloadErrorDialogFragment.newInstance( + dialogType = DownloadErrorDialogType.WIFI_REQUIRED, + uiState = uiState + ) + dialog.show( + uiState.fragmentManager, + DownloadErrorDialogFragment.DIALOG_TAG + ) + } + + !corePreferences.videoSettings.wifiDownloadOnly && !networkConnection.isWifiConnected() -> { + val dialog = DownloadConfirmDialogFragment.newInstance( + dialogType = DownloadConfirmDialogType.DOWNLOAD_ON_CELLULAR, + uiState = uiState + ) + dialog.show( + uiState.fragmentManager, + DownloadConfirmDialogFragment.DIALOG_TAG + ) + } + + uiState.sizeSum >= MAX_CELLULAR_SIZE -> { + val dialog = DownloadConfirmDialogFragment.newInstance( + dialogType = DownloadConfirmDialogType.CONFIRM, + uiState = uiState + ) + dialog.show( + uiState.fragmentManager, + DownloadConfirmDialogFragment.DIALOG_TAG + ) + } + + else -> { + uiState.saveDownloadModels() + } + } + } + } + } + + fun showPopup( + subSectionsBlocks: List, + courseId: String, + isBlocksDownloaded: Boolean, + onlyVideoBlocks: Boolean = false, + fragmentManager: FragmentManager, + removeDownloadModels: (blockId: String) -> Unit, + saveDownloadModels: (blockId: String) -> Unit, + ) { + createDownloadItems( + subSectionsBlocks = subSectionsBlocks, + courseId = courseId, + fragmentManager = fragmentManager, + isBlocksDownloaded = isBlocksDownloaded, + onlyVideoBlocks = onlyVideoBlocks, + removeDownloadModels = removeDownloadModels, + saveDownloadModels = saveDownloadModels + ) + } + + fun showRemoveDownloadModelPopup( + downloadDialogItem: DownloadDialogItem, + fragmentManager: FragmentManager, + removeDownloadModels: () -> Unit, + ) { + CoroutineScope(Dispatchers.IO).launch { + uiState.emit( + DownloadDialogUIState( + downloadDialogItems = listOf(downloadDialogItem), + isAllBlocksDownloaded = true, + isDownloadFailed = false, + sizeSum = downloadDialogItem.size, + fragmentManager = fragmentManager, + removeDownloadModels = removeDownloadModels, + saveDownloadModels = {} + ) + ) + } + } + + fun showDownloadFailedPopup( + downloadModel: List, + fragmentManager: FragmentManager, + ) { + createDownloadItems( + downloadModel = downloadModel, + fragmentManager = fragmentManager, + ) + } + + private fun createDownloadItems( + downloadModel: List, + fragmentManager: FragmentManager, + ) { + CoroutineScope(Dispatchers.IO).launch { + val courseIds = downloadModel.map { it.courseId }.distinct() + val blockIds = downloadModel.map { it.id } + val notDownloadedSubSections = mutableListOf() + val allDownloadDialogItems = mutableListOf() + courseIds.forEach { courseId -> + val courseStructure = interactor.getCourseStructureFromCache(courseId) + val allSubSectionBlocks = courseStructure.blockData.filter { it.type == BlockType.SEQUENTIAL } + allSubSectionBlocks.forEach { subSectionsBlock -> + val verticalBlocks = courseStructure.blockData.filter { it.id in subSectionsBlock.descendants } + val blocks = courseStructure.blockData.filter { + it.id in verticalBlocks.flatMap { it.descendants } && it.id in blockIds + } + val size = blocks.sumOf { getFileSize(it) } + if (blocks.isNotEmpty()) notDownloadedSubSections.add(subSectionsBlock) + if (size > 0) { + val downloadDialogItem = DownloadDialogItem( + title = subSectionsBlock.displayName, + size = size + ) + allDownloadDialogItems.add(downloadDialogItem) + } + } + } + uiState.emit( + DownloadDialogUIState( + downloadDialogItems = allDownloadDialogItems, + isAllBlocksDownloaded = false, + isDownloadFailed = true, + sizeSum = allDownloadDialogItems.sumOf { it.size }, + fragmentManager = fragmentManager, + removeDownloadModels = {}, + saveDownloadModels = { + CoroutineScope(Dispatchers.IO).launch { + workerController.saveModels(downloadModel) + } + } + ) + ) + } + } + + private fun createDownloadItems( + subSectionsBlocks: List, + courseId: String, + fragmentManager: FragmentManager, + isBlocksDownloaded: Boolean, + onlyVideoBlocks: Boolean, + removeDownloadModels: (blockId: String) -> Unit, + saveDownloadModels: (blockId: String) -> Unit, + ) { + CoroutineScope(Dispatchers.IO).launch { + val courseStructure = interactor.getCourseStructure(courseId, false) + val downloadModelIds = interactor.getAllDownloadModels().map { it.id } + + val downloadDialogItems = subSectionsBlocks.mapNotNull { subSectionsBlock -> + val verticalBlocks = courseStructure.blockData.filter { it.id in subSectionsBlock.descendants } + val blocks = verticalBlocks.flatMap { verticalBlock -> + courseStructure.blockData.filter { + it.id in verticalBlock.descendants && + (isBlocksDownloaded == (it.id in downloadModelIds)) && + (!onlyVideoBlocks || it.type == BlockType.VIDEO) + } + } + val size = blocks.sumOf { getFileSize(it) } + if (size > 0) DownloadDialogItem(title = subSectionsBlock.displayName, size = size) else null + } + + uiState.emit( + DownloadDialogUIState( + downloadDialogItems = downloadDialogItems, + isAllBlocksDownloaded = isBlocksDownloaded, + isDownloadFailed = false, + sizeSum = downloadDialogItems.sumOf { it.size }, + fragmentManager = fragmentManager, + removeDownloadModels = { subSectionsBlocks.forEach { removeDownloadModels(it.id) } }, + saveDownloadModels = { subSectionsBlocks.forEach { saveDownloadModels(it.id) } } + ) + ) + } + } + + + private fun getFileSize(block: Block): Long { + return when { + block.type == BlockType.VIDEO -> block.downloadModel?.size ?: 0 + block.isxBlock -> block.offlineDownload?.fileSize ?: 0 + else -> 0 + } + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogUIState.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogUIState.kt new file mode 100644 index 000000000..b58e856bd --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogUIState.kt @@ -0,0 +1,17 @@ +package org.openedx.course.presentation.download + +import android.os.Parcelable +import androidx.fragment.app.FragmentManager +import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.RawValue + +@Parcelize +data class DownloadDialogUIState( + val downloadDialogItems: List = emptyList(), + val sizeSum: Long, + val isAllBlocksDownloaded: Boolean, + val isDownloadFailed: Boolean, + val fragmentManager: @RawValue FragmentManager, + val removeDownloadModels: () -> Unit, + val saveDownloadModels: () -> Unit +) : Parcelable diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogFragment.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogFragment.kt new file mode 100644 index 000000000..05d7e0243 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogFragment.kt @@ -0,0 +1,222 @@ +package org.openedx.course.presentation.download + +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.os.bundleOf +import androidx.fragment.app.DialogFragment +import org.openedx.core.extension.parcelable +import org.openedx.core.presentation.dialog.DefaultDialogBox +import org.openedx.core.system.PreviewFragmentManager +import org.openedx.core.ui.AutoSizeText +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.OpenEdXOutlinedButton +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appTypography +import org.openedx.course.R +import org.openedx.course.domain.model.DownloadDialogResource +import org.openedx.core.R as coreR + +class DownloadErrorDialogFragment : DialogFragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val dialogType = + requireArguments().parcelable(ARG_DIALOG_TYPE) ?: return@OpenEdXTheme + val uiState = requireArguments().parcelable(ARG_UI_STATE) ?: return@OpenEdXTheme + val downloadDialogResource = when (dialogType) { + DownloadErrorDialogType.NO_CONNECTION -> DownloadDialogResource( + title = stringResource(id = coreR.string.core_no_internet_connection), + description = stringResource(id = R.string.course_download_no_internet_dialog_description), + icon = painterResource(id = R.drawable.course_ic_error), + ) + + DownloadErrorDialogType.WIFI_REQUIRED -> DownloadDialogResource( + title = stringResource(id = R.string.course_wifi_required), + description = stringResource(id = R.string.course_download_wifi_required_dialog_description), + icon = painterResource(id = R.drawable.course_ic_error), + ) + + DownloadErrorDialogType.DOWNLOAD_FAILED -> DownloadDialogResource( + title = stringResource(id = R.string.course_download_failed), + description = stringResource(id = R.string.course_download_failed_dialog_description), + icon = painterResource(id = R.drawable.course_ic_error), + ) + } + + DownloadErrorDialogView( + downloadDialogResource = downloadDialogResource, + uiState = uiState, + dialogType = dialogType, + onTryAgainClick = { + uiState.saveDownloadModels() + dismiss() + }, + onCancelClick = { + dismiss() + } + ) + } + } + } + + companion object { + const val DIALOG_TAG = "DownloadErrorDialogFragment" + const val ARG_DIALOG_TYPE = "dialogType" + const val ARG_UI_STATE = "uiState" + + fun newInstance( + dialogType: DownloadErrorDialogType, + uiState: DownloadDialogUIState + ): DownloadErrorDialogFragment { + val dialog = DownloadErrorDialogFragment() + dialog.arguments = bundleOf( + ARG_DIALOG_TYPE to dialogType, + ARG_UI_STATE to uiState + ) + return dialog + } + } +} + +@Composable +private fun DownloadErrorDialogView( + modifier: Modifier = Modifier, + uiState: DownloadDialogUIState, + downloadDialogResource: DownloadDialogResource, + dialogType: DownloadErrorDialogType, + onTryAgainClick: () -> Unit, + onCancelClick: () -> Unit, +) { + val scrollState = rememberScrollState() + val dismissButtonText = when (dialogType) { + DownloadErrorDialogType.DOWNLOAD_FAILED -> stringResource(id = coreR.string.core_cancel) + else -> stringResource(id = coreR.string.core_close) + } + DefaultDialogBox( + modifier = modifier, + onDismissClick = onCancelClick + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(scrollState) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + downloadDialogResource.icon?.let { icon -> + Image( + painter = icon, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + } + AutoSizeText( + text = downloadDialogResource.title, + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textDark, + minSize = MaterialTheme.appTypography.titleLarge.fontSize.value - 1 + ) + } + Column { + uiState.downloadDialogItems.forEach { + DownloadDialogItem(downloadDialogItem = it) + } + } + Text( + text = downloadDialogResource.description, + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.textDark + ) + if (dialogType == DownloadErrorDialogType.DOWNLOAD_FAILED) { + OpenEdXButton( + text = stringResource(id = coreR.string.core_error_try_again), + backgroundColor = MaterialTheme.appColors.secondaryButtonBackground, + onClick = onTryAgainClick, + ) + } + OpenEdXOutlinedButton( + modifier = Modifier.fillMaxWidth(), + text = dismissButtonText, + backgroundColor = MaterialTheme.appColors.background, + borderColor = MaterialTheme.appColors.primaryButtonBackground, + textColor = MaterialTheme.appColors.primaryButtonBackground, + onClick = { + onCancelClick() + } + ) + } + } +} + +@Preview +@Composable +private fun DownloadErrorDialogViewPreview() { + OpenEdXTheme { + DownloadErrorDialogView( + downloadDialogResource = DownloadDialogResource( + title = "Title", + description = "Description Description Description Description Description Description Description ", + icon = painterResource(id = R.drawable.course_ic_error) + ), + uiState = DownloadDialogUIState( + downloadDialogItems = listOf( + DownloadDialogItem( + title = "Subsection title 1", + size = 20000 + ), + DownloadDialogItem( + title = "Subsection title 2", + size = 10000000 + ) + ), + sizeSum = 100000, + isAllBlocksDownloaded = false, + isDownloadFailed = false, + fragmentManager = PreviewFragmentManager, + removeDownloadModels = {}, + saveDownloadModels = {} + ), + onCancelClick = {}, + onTryAgainClick = {}, + dialogType = DownloadErrorDialogType.DOWNLOAD_FAILED + ) + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogType.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogType.kt new file mode 100644 index 000000000..85f01cf1a --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogType.kt @@ -0,0 +1,9 @@ +package org.openedx.course.presentation.download + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +enum class DownloadErrorDialogType : Parcelable { + NO_CONNECTION, WIFI_REQUIRED, DOWNLOAD_FAILED +} diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadStorageErrorDialogFragment.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadStorageErrorDialogFragment.kt new file mode 100644 index 000000000..0059f2bec --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadStorageErrorDialogFragment.kt @@ -0,0 +1,283 @@ +package org.openedx.course.presentation.download + +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.os.bundleOf +import androidx.fragment.app.DialogFragment +import org.openedx.core.extension.parcelable +import org.openedx.core.extension.toFileSize +import org.openedx.core.presentation.dialog.DefaultDialogBox +import org.openedx.core.system.PreviewFragmentManager +import org.openedx.core.system.StorageManager +import org.openedx.core.ui.AutoSizeText +import org.openedx.core.ui.OpenEdXOutlinedButton +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appTypography +import org.openedx.course.R +import org.openedx.course.domain.model.DownloadDialogResource +import org.openedx.course.presentation.download.DownloadDialogManager.Companion.DOWNLOAD_SIZE_FACTOR +import org.openedx.core.R as coreR + +class DownloadStorageErrorDialogFragment : DialogFragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val uiState = requireArguments().parcelable(ARG_UI_STATE) ?: return@OpenEdXTheme + val downloadDialogResource = DownloadDialogResource( + title = stringResource(id = R.string.course_device_storage_full), + description = stringResource(id = R.string.course_download_device_storage_full_dialog_description), + icon = painterResource(id = R.drawable.course_ic_error), + ) + + DownloadStorageErrorDialogView( + uiState = uiState, + downloadDialogResource = downloadDialogResource, + onCancelClick = { + dismiss() + } + ) + } + } + } + + companion object { + const val DIALOG_TAG = "DownloadStorageErrorDialogFragment" + const val ARG_UI_STATE = "uiState" + + fun newInstance( + uiState: DownloadDialogUIState + ): DownloadStorageErrorDialogFragment { + val dialog = DownloadStorageErrorDialogFragment() + dialog.arguments = bundleOf( + ARG_UI_STATE to uiState + ) + return dialog + } + } +} + +@Composable +private fun DownloadStorageErrorDialogView( + modifier: Modifier = Modifier, + uiState: DownloadDialogUIState, + downloadDialogResource: DownloadDialogResource, + onCancelClick: () -> Unit, +) { + val scrollState = rememberScrollState() + DefaultDialogBox( + modifier = modifier, + onDismissClick = onCancelClick + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(scrollState) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + downloadDialogResource.icon?.let { icon -> + Image( + painter = icon, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + } + AutoSizeText( + text = downloadDialogResource.title, + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textDark, + minSize = MaterialTheme.appTypography.titleLarge.fontSize.value - 1 + ) + } + Column { + uiState.downloadDialogItems.forEach { + DownloadDialogItem(downloadDialogItem = it.copy(size = it.size * DOWNLOAD_SIZE_FACTOR)) + } + } + StorageBar( + freeSpace = StorageManager.getFreeStorage(), + totalSpace = StorageManager.getTotalStorage(), + requiredSpace = uiState.sizeSum * DOWNLOAD_SIZE_FACTOR + ) + Text( + text = downloadDialogResource.description, + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.textDark + ) + OpenEdXOutlinedButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = coreR.string.core_cancel), + backgroundColor = MaterialTheme.appColors.background, + borderColor = MaterialTheme.appColors.primaryButtonBackground, + textColor = MaterialTheme.appColors.primaryButtonBackground, + onClick = { + onCancelClick() + } + ) + } + } +} + +@Composable +private fun StorageBar( + freeSpace: Long, + totalSpace: Long, + requiredSpace: Long +) { + val cornerRadius = 2.dp + val boxPadding = 1.dp + val usedSpace = totalSpace - freeSpace + val minSize = 0.1f + val freePercentage = freeSpace / requiredSpace.toFloat() + minSize + val reqPercentage = (requiredSpace - freeSpace) / requiredSpace.toFloat() + minSize + + val animReqPercentage = remember { Animatable(Float.MIN_VALUE) } + LaunchedEffect(Unit) { + animReqPercentage.animateTo( + targetValue = reqPercentage, + animationSpec = tween( + durationMillis = 1000, + easing = LinearOutSlowInEasing + ) + ) + } + + Column( + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(36.dp) + .background(MaterialTheme.appColors.background) + .clip(RoundedCornerShape(cornerRadius)) + .border( + 2.dp, + MaterialTheme.appColors.cardViewBorder, + RoundedCornerShape(cornerRadius * 2) + ) + .padding(2.dp) + .background(MaterialTheme.appColors.background), + ) { + Box( + modifier = Modifier + .weight(freePercentage) + .fillMaxHeight() + .padding(top = boxPadding, bottom = boxPadding, start = boxPadding, end = boxPadding / 2) + .clip(RoundedCornerShape(topStart = cornerRadius, bottomStart = cornerRadius)) + .background(MaterialTheme.appColors.cardViewBorder) + ) + Box( + modifier = Modifier + .weight(animReqPercentage.value) + .fillMaxHeight() + .padding(top = boxPadding, bottom = boxPadding, end = boxPadding, start = boxPadding / 2) + .clip(RoundedCornerShape(topEnd = cornerRadius, bottomEnd = cornerRadius)) + .background(MaterialTheme.appColors.error) + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + ) { + Text( + text = stringResource( + R.string.course_used_free_storage, + usedSpace.toFileSize(1, false), + freeSpace.toFileSize(1, false) + ), + style = MaterialTheme.appTypography.labelSmall, + color = MaterialTheme.appColors.textFieldHint, + modifier = Modifier.weight(1f) + ) + Text( + text = requiredSpace.toFileSize(1, false), + style = MaterialTheme.appTypography.labelSmall, + color = MaterialTheme.appColors.error, + ) + } + } +} + +@Preview +@Composable +private fun DownloadStorageErrorDialogViewPreview() { + OpenEdXTheme { + DownloadStorageErrorDialogView( + downloadDialogResource = DownloadDialogResource( + title = "Title", + description = "Description Description Description Description Description Description Description ", + icon = painterResource(id = R.drawable.course_ic_error) + ), + uiState = DownloadDialogUIState( + downloadDialogItems = listOf( + DownloadDialogItem( + title = "Subsection title 1", + size = 20000 + ), + DownloadDialogItem( + title = "Subsection title 2", + size = 10000000 + ) + ), + sizeSum = 100000, + isAllBlocksDownloaded = false, + isDownloadFailed = false, + fragmentManager = PreviewFragmentManager, + removeDownloadModels = {}, + saveDownloadModels = {} + ), + onCancelClick = {} + ) + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadView.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadView.kt new file mode 100644 index 000000000..2a760c772 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadView.kt @@ -0,0 +1,59 @@ +package org.openedx.course.presentation.download + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import org.openedx.core.R +import org.openedx.core.extension.toFileSize +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appTypography + +@Composable +fun DownloadDialogItem( + modifier: Modifier = Modifier, + downloadDialogItem: DownloadDialogItem, +) { + val icon = if (downloadDialogItem.icon != null) { + rememberVectorPainter(downloadDialogItem.icon) + } else { + painterResource(id = R.drawable.ic_core_chapter_icon) + } + Row( + modifier = modifier.padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + modifier = Modifier + .size(24.dp) + .align(Alignment.Top), + painter = icon, + tint = MaterialTheme.appColors.textDark, + contentDescription = null, + ) + Text( + modifier = Modifier.weight(1f), + text = downloadDialogItem.title, + style = MaterialTheme.appTypography.titleSmall, + color = MaterialTheme.appColors.textDark, + overflow = TextOverflow.Ellipsis, + maxLines = 2 + ) + Text( + text = downloadDialogItem.size.toFileSize(1, false), + style = MaterialTheme.appTypography.bodySmall, + color = MaterialTheme.appColors.textFieldHint + ) + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt new file mode 100644 index 000000000..cdad27742 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt @@ -0,0 +1,489 @@ +package org.openedx.course.presentation.offline + +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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Divider +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.outlined.InsertDriveFile +import androidx.compose.material.icons.filled.CloudDone +import androidx.compose.material.icons.outlined.CloudDownload +import androidx.compose.material.icons.outlined.SmartDisplay +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Delete +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 +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.vector.ImageVector +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.FragmentManager +import org.openedx.core.extension.toFileSize +import org.openedx.core.module.db.DownloadModel +import org.openedx.core.module.db.DownloadedState +import org.openedx.core.module.db.FileType +import org.openedx.core.ui.IconText +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.OpenEdXOutlinedButton +import org.openedx.core.ui.WindowSize +import org.openedx.core.ui.displayCutoutForLandscape +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.appTypography +import org.openedx.core.ui.windowSizeValue +import org.openedx.course.R +import org.openedx.core.R as coreR + +@Composable +fun CourseOfflineScreen( + windowSize: WindowSize, + viewModel: CourseOfflineViewModel, + fragmentManager: FragmentManager, +) { + val uiState by viewModel.uiState.collectAsState() + + CourseOfflineUI( + windowSize = windowSize, + uiState = uiState, + hasInternetConnection = viewModel.hasInternetConnection, + onDownloadAllClick = { + viewModel.downloadAllBlocks(fragmentManager) + }, + onCancelDownloadClick = { + viewModel.removeDownloadModel() + }, + onDeleteClick = { downloadModel -> + viewModel.removeDownloadModel( + downloadModel, + fragmentManager + ) + }, + onDeleteAllClick = { + viewModel.deleteAll(fragmentManager) + }, + ) +} + +@Composable +private fun CourseOfflineUI( + windowSize: WindowSize, + uiState: CourseOfflineUIState, + hasInternetConnection: Boolean, + onDownloadAllClick: () -> Unit, + onCancelDownloadClick: () -> Unit, + onDeleteClick: (downloadModel: DownloadModel) -> Unit, + onDeleteAllClick: () -> Unit +) { + val scaffoldState = rememberScaffoldState() + + Scaffold( + modifier = Modifier.fillMaxSize(), + scaffoldState = scaffoldState, + backgroundColor = MaterialTheme.appColors.background + ) { + val modifierScreenWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), + compact = Modifier.fillMaxWidth() + ) + ) + } + + val horizontalPadding by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.padding(horizontal = 6.dp), + compact = Modifier.padding(horizontal = 24.dp) + ) + ) + } + + Box( + modifier = Modifier + .fillMaxSize() + .padding(it) + .displayCutoutForLandscape(), contentAlignment = Alignment.TopCenter + ) { + Surface( + modifier = modifierScreenWidth, + color = MaterialTheme.appColors.background, + ) { + LazyColumn( + Modifier + .fillMaxWidth() + .padding(top = 20.dp, bottom = 24.dp) + .then(horizontalPadding) + ) { + item { + if (uiState.isHaveDownloadableBlocks) { + DownloadProgress( + uiState = uiState, + ) + } else { + NoDownloadableBlocksProgress() + } + if (uiState.progressBarValue != 1f && !uiState.isDownloading && hasInternetConnection) { + Spacer(modifier = Modifier.height(20.dp)) + OpenEdXButton( + text = stringResource(R.string.course_download_all), + backgroundColor = MaterialTheme.appColors.secondaryButtonBackground, + onClick = onDownloadAllClick, + enabled = uiState.isHaveDownloadableBlocks, + content = { + val textColor = if (uiState.isHaveDownloadableBlocks) { + MaterialTheme.appColors.primaryButtonText + } else { + MaterialTheme.appColors.textPrimaryVariant + } + IconText( + text = stringResource(R.string.course_download_all), + icon = Icons.Outlined.CloudDownload, + color = textColor, + textStyle = MaterialTheme.appTypography.labelLarge + ) + } + ) + } else if (uiState.isDownloading) { + Spacer(modifier = Modifier.height(20.dp)) + OpenEdXOutlinedButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.course_cancel_course_download), + backgroundColor = MaterialTheme.appColors.background, + borderColor = MaterialTheme.appColors.error, + textColor = MaterialTheme.appColors.error, + onClick = onCancelDownloadClick, + content = { + IconText( + text = stringResource(R.string.course_cancel_course_download), + icon = Icons.Rounded.Close, + color = MaterialTheme.appColors.error, + textStyle = MaterialTheme.appTypography.labelLarge + ) + } + ) + } + if (uiState.largestDownloads.isNotEmpty()) { + Spacer(modifier = Modifier.height(20.dp)) + LargestDownloads( + largestDownloads = uiState.largestDownloads, + isDownloading = uiState.isDownloading, + onDeleteClick = onDeleteClick, + onDeleteAllClick = onDeleteAllClick, + ) + } + } + } + } + } + } +} + +@Composable +private fun LargestDownloads( + largestDownloads: List, + isDownloading: Boolean, + onDeleteClick: (downloadModel: DownloadModel) -> Unit, + onDeleteAllClick: () -> Unit, +) { + var isEditingEnabled by rememberSaveable { + mutableStateOf(false) + } + val text = if (!isEditingEnabled) { + stringResource(coreR.string.core_edit) + } else { + stringResource(coreR.string.core_label_done) + } + + LaunchedEffect(isDownloading) { + if (isDownloading) { + isEditingEnabled = false + } + } + + Column { + Row { + Text( + modifier = Modifier.weight(1f), + text = stringResource(R.string.course_largest_downloads), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textDark + ) + if (!isDownloading) { + Text( + modifier = Modifier.clickable { + isEditingEnabled = !isEditingEnabled + }, + text = text, + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.textAccent, + ) + } + } + Spacer(modifier = Modifier.height(20.dp)) + largestDownloads.forEach { + DownloadItem( + downloadModel = it, + isEditingEnabled = isEditingEnabled, + onDeleteClick = onDeleteClick + ) + } + if (!isDownloading) { + OpenEdXOutlinedButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.course_remove_all_downloads), + backgroundColor = MaterialTheme.appColors.background, + borderColor = MaterialTheme.appColors.error, + textColor = MaterialTheme.appColors.error, + onClick = onDeleteAllClick, + content = { + IconText( + text = stringResource(R.string.course_remove_all_downloads), + icon = Icons.Rounded.Delete, + color = MaterialTheme.appColors.error, + textStyle = MaterialTheme.appTypography.labelLarge + ) + } + ) + } + } +} + +@Composable +private fun DownloadItem( + modifier: Modifier = Modifier, + downloadModel: DownloadModel, + isEditingEnabled: Boolean, + onDeleteClick: (downloadModel: DownloadModel) -> Unit +) { + val fileIcon = if (downloadModel.type == FileType.VIDEO) { + Icons.Outlined.SmartDisplay + } else { + Icons.AutoMirrored.Outlined.InsertDriveFile + } + val downloadIcon: ImageVector + val downloadIconTint: Color + val downloadIconClick: Modifier + if (isEditingEnabled) { + downloadIcon = Icons.Rounded.Delete + downloadIconTint = MaterialTheme.appColors.error + downloadIconClick = Modifier.clickable { + onDeleteClick(downloadModel) + } + } else { + downloadIcon = Icons.Default.CloudDone + downloadIconTint = MaterialTheme.appColors.successGreen + downloadIconClick = Modifier + } + + Column { + Row( + modifier = modifier + .fillMaxWidth() + .padding(start = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = fileIcon, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = downloadModel.title, + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textDark + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = downloadModel.size.toFileSize(1, false), + style = MaterialTheme.appTypography.labelSmall, + color = MaterialTheme.appColors.textDark + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Icon( + modifier = Modifier + .size(24.dp) + .then(downloadIconClick), + imageVector = downloadIcon, + tint = downloadIconTint, + contentDescription = null + ) + } + Spacer(modifier = Modifier.height(12.dp)) + Divider() + Spacer(modifier = Modifier.height(12.dp)) + } +} + +@Composable +private fun DownloadProgress( + modifier: Modifier = Modifier, + uiState: CourseOfflineUIState, +) { + Column( + modifier = modifier + ) { + Row( + modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = uiState.downloadedSize, + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.successGreen + ) + Text( + text = uiState.readyToDownloadSize, + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textDark + ) + } + Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier + .fillMaxWidth() + .height(40.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + IconText( + text = stringResource(R.string.course_downloaded), + icon = Icons.Default.CloudDone, + color = MaterialTheme.appColors.successGreen, + textStyle = MaterialTheme.appTypography.labelLarge + ) + if (!uiState.isDownloading) { + IconText( + text = stringResource(R.string.course_ready_to_download), + icon = Icons.Outlined.CloudDownload, + color = MaterialTheme.appColors.textDark, + textStyle = MaterialTheme.appTypography.labelLarge + ) + } else { + IconText( + text = stringResource(R.string.course_downloading), + icon = Icons.Outlined.CloudDownload, + color = MaterialTheme.appColors.textDark, + textStyle = MaterialTheme.appTypography.labelLarge + ) + } + } + if (uiState.progressBarValue != 0f) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(6.dp) + .clip(CircleShape), + progress = uiState.progressBarValue, + strokeCap = StrokeCap.Round, + color = MaterialTheme.appColors.successGreen, + backgroundColor = MaterialTheme.appColors.progressBarBackgroundColor + ) + } else { + Text( + text = stringResource(R.string.course_you_can_download_course_content_offline), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textDark + ) + } + } +} + +@Composable +private fun NoDownloadableBlocksProgress( + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + ) { + Text( + text = stringResource(R.string.course_0mb), + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textFieldHint + ) + Spacer(modifier = Modifier.height(4.dp)) + IconText( + text = stringResource(R.string.course_available_to_download), + icon = Icons.Outlined.CloudDownload, + color = MaterialTheme.appColors.textFieldHint, + textStyle = MaterialTheme.appTypography.labelLarge + ) + Spacer(modifier = Modifier.height(20.dp)) + Text( + text = stringResource(R.string.course_no_available_to_download_offline), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textDark + ) + } +} + +@Preview +@Composable +private fun CourseOfflineUIPreview() { + OpenEdXTheme { + CourseOfflineUI( + windowSize = rememberWindowSize(), + hasInternetConnection = true, + uiState = CourseOfflineUIState( + isHaveDownloadableBlocks = true, + readyToDownloadSize = "159MB", + downloadedSize = "0MB", + progressBarValue = 0f, + isDownloading = true, + largestDownloads = listOf( + DownloadModel( + "", + "", + "", + 0, + "", + "", + FileType.X_BLOCK, + DownloadedState.DOWNLOADED, + null + ) + ), + ), + onDownloadAllClick = {}, + onCancelDownloadClick = {}, + onDeleteClick = {}, + onDeleteAllClick = {} + ) + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineUIState.kt b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineUIState.kt new file mode 100644 index 000000000..8abde204f --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineUIState.kt @@ -0,0 +1,12 @@ +package org.openedx.course.presentation.offline + +import org.openedx.core.module.db.DownloadModel + +data class CourseOfflineUIState( + val isHaveDownloadableBlocks: Boolean, + val largestDownloads: List, + val isDownloading: Boolean, + val readyToDownloadSize: String, + val downloadedSize: String, + val progressBarValue: Float, +) diff --git a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt new file mode 100644 index 000000000..230b30deb --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt @@ -0,0 +1,216 @@ +package org.openedx.course.presentation.offline + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.InsertDriveFile +import androidx.compose.material.icons.outlined.SmartDisplay +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.openedx.core.BlockType +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.Block +import org.openedx.core.extension.toFileSize +import org.openedx.core.module.DownloadWorkerController +import org.openedx.core.module.db.DownloadDao +import org.openedx.core.module.db.DownloadModel +import org.openedx.core.module.db.FileType +import org.openedx.core.module.download.BaseDownloadViewModel +import org.openedx.core.module.download.DownloadHelper +import org.openedx.core.presentation.CoreAnalytics +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.utils.FileUtil +import org.openedx.course.domain.interactor.CourseInteractor +import org.openedx.course.presentation.download.DownloadDialogItem +import org.openedx.course.presentation.download.DownloadDialogManager + +class CourseOfflineViewModel( + val courseId: String, + val courseTitle: String, + val courseInteractor: CourseInteractor, + private val preferencesManager: CorePreferences, + private val downloadDialogManager: DownloadDialogManager, + private val fileUtil: FileUtil, + private val networkConnection: NetworkConnection, + coreAnalytics: CoreAnalytics, + downloadDao: DownloadDao, + workerController: DownloadWorkerController, + downloadHelper: DownloadHelper, +) : BaseDownloadViewModel( + courseId, + downloadDao, + preferencesManager, + workerController, + coreAnalytics, + downloadHelper, +) { + private val _uiState = MutableStateFlow( + CourseOfflineUIState( + isHaveDownloadableBlocks = false, + largestDownloads = emptyList(), + isDownloading = false, + readyToDownloadSize = "", + downloadedSize = "", + progressBarValue = 0f, + ) + ) + val uiState: StateFlow + get() = _uiState.asStateFlow() + + val hasInternetConnection: Boolean + get() = networkConnection.isOnline() + + init { + viewModelScope.launch { + downloadModelsStatusFlow.collect { + val isDownloading = it.any { it.value.isWaitingOrDownloading } + _uiState.update { it.copy(isDownloading = isDownloading) } + } + } + + viewModelScope.launch { + async { initDownloadFragment() }.await() + getOfflineData() + } + } + + fun downloadAllBlocks(fragmentManager: FragmentManager) { + viewModelScope.launch { + val courseStructure = courseInteractor.getCourseStructureFromCache(courseId) + val downloadModels = courseInteractor.getAllDownloadModels() + val subSectionsBlocks = allBlocks.values.filter { it.type == BlockType.SEQUENTIAL } + val notDownloadedSubSectionBlocks = subSectionsBlocks.mapNotNull { subSectionsBlock -> + val verticalBlocks = allBlocks.values.filter { it.id in subSectionsBlock.descendants } + val notDownloadedBlocks = courseStructure.blockData.filter { block -> + block.id in verticalBlocks.flatMap { it.descendants } && block.isDownloadable && !downloadModels.any { it.id == block.id } + } + if (notDownloadedBlocks.isNotEmpty()) subSectionsBlock else null + } + + downloadDialogManager.showPopup( + subSectionsBlocks = notDownloadedSubSectionBlocks, + courseId = courseId, + isBlocksDownloaded = false, + fragmentManager = fragmentManager, + removeDownloadModels = ::removeDownloadModels, + saveDownloadModels = { blockId -> + saveDownloadModels(fileUtil.getExternalAppDir().path, blockId) + } + ) + } + } + + fun removeDownloadModel(downloadModel: DownloadModel, fragmentManager: FragmentManager) { + val icon = if (downloadModel.type == FileType.VIDEO) { + Icons.Outlined.SmartDisplay + } else { + Icons.AutoMirrored.Outlined.InsertDriveFile + } + val downloadDialogItem = DownloadDialogItem( + title = downloadModel.title, + size = downloadModel.size, + icon = icon + ) + downloadDialogManager.showRemoveDownloadModelPopup( + downloadDialogItem = downloadDialogItem, + fragmentManager = fragmentManager, + removeDownloadModels = { + super.removeBlockDownloadModel(downloadModel.id) + }, + ) + } + + fun deleteAll(fragmentManager: FragmentManager) { + viewModelScope.launch { + val downloadModels = courseInteractor.getAllDownloadModels().filter { it.courseId == courseId } + val downloadDialogItem = DownloadDialogItem( + title = courseTitle, + size = downloadModels.sumOf { it.size }, + icon = Icons.AutoMirrored.Outlined.InsertDriveFile + ) + downloadDialogManager.showRemoveDownloadModelPopup( + downloadDialogItem = downloadDialogItem, + fragmentManager = fragmentManager, + removeDownloadModels = { + downloadModels.forEach { + super.removeBlockDownloadModel(it.id) + } + }, + ) + } + } + + fun removeDownloadModel() { + viewModelScope.launch { + courseInteractor.getAllDownloadModels() + .filter { it.courseId == courseId && it.downloadedState.isWaitingOrDownloading } + .forEach { + removeBlockDownloadModel(it.id) + } + } + } + + private suspend fun initDownloadFragment() { + val courseStructure = courseInteractor.getCourseStructureFromCache(courseId) + setBlocks(courseStructure.blockData) + allBlocks.values + .filter { it.type == BlockType.SEQUENTIAL } + .forEach { + addDownloadableChildrenForSequentialBlock(it) + } + + } + + private fun getOfflineData() { + viewModelScope.launch { + val courseStructure = courseInteractor.getCourseStructureFromCache(courseId) + val downloadableFilesSize = getFilesSize(courseStructure.blockData) + if (downloadableFilesSize == 0L) return@launch + + courseInteractor.getDownloadModels().collect { + val downloadModels = it.filter { it.downloadedState.isDownloaded && it.courseId == courseId } + val downloadedModelsIds = downloadModels.map { it.id } + val downloadedBlocks = courseStructure.blockData.filter { it.id in downloadedModelsIds } + val downloadedFilesSize = getFilesSize(downloadedBlocks) + val realDownloadedFilesSize = downloadModels.sumOf { it.size } + val largestDownloads = downloadModels + .sortedByDescending { it.size } + .take(5) + + _uiState.update { + it.copy( + isHaveDownloadableBlocks = true, + largestDownloads = largestDownloads, + readyToDownloadSize = (downloadableFilesSize - downloadedFilesSize).toFileSize(1, false), + downloadedSize = realDownloadedFilesSize.toFileSize(1, false), + progressBarValue = downloadedFilesSize.toFloat() / downloadableFilesSize.toFloat() + ) + } + } + } + } + + private fun getFilesSize(block: List): Long { + return block.filter { it.isDownloadable }.sumOf { + when (it.downloadableType) { + FileType.VIDEO -> { + val videoInfo = + it.studentViewData?.encodedVideos?.getPreferredVideoInfoForDownloading( + preferencesManager.videoSettings.videoDownloadQuality + ) + videoInfo?.fileSize ?: 0 + } + + FileType.X_BLOCK -> { + it.offlineDownload?.fileSize ?: 0 + } + + null -> 0 + } + } + } +} 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 1f31b32de..d40ae18b6 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 @@ -52,6 +52,7 @@ import org.openedx.core.domain.model.BlockCounts import org.openedx.core.domain.model.CourseDatesBannerInfo import org.openedx.core.domain.model.CourseStructure import org.openedx.core.domain.model.CoursewareAccess +import org.openedx.core.domain.model.OfflineDownload import org.openedx.core.domain.model.Progress import org.openedx.core.extension.takeIfNotEmpty import org.openedx.core.presentation.course.CourseViewMode @@ -104,7 +105,7 @@ fun CourseOutlineScreen( } }, onSubSectionClick = { subSectionBlock -> - if (viewModel.isCourseNestedListEnabled) { + if (viewModel.isCourseDropdownNavigationEnabled) { viewModel.courseSubSectionUnit[subSectionBlock.id]?.let { unit -> viewModel.logUnitDetailViewedEvent( unit.blockId, @@ -139,8 +140,7 @@ fun CourseOutlineScreen( onDownloadClick = { blocksIds -> viewModel.downloadBlocks( blocksIds = blocksIds, - fragmentManager = fragmentManager, - context = context + fragmentManager = fragmentManager ) }, onResetDatesClick = { @@ -581,7 +581,8 @@ private val mockChapterBlock = Block( completion = 0.0, containsGatedContent = false, assignmentProgress = mockAssignmentProgress, - due = Date() + due = Date(), + offlineDownload = null ) private val mockSequentialBlock = Block( id = "id", @@ -600,7 +601,8 @@ private val mockSequentialBlock = Block( completion = 0.0, containsGatedContent = false, assignmentProgress = mockAssignmentProgress, - due = Date() + due = Date(), + offlineDownload = OfflineDownload("fileUrl", "", 1) ) private val mockCourseStructure = CourseStructure( 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 b65b3b62a..193b5c7e9 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,6 +1,5 @@ package org.openedx.course.presentation.outline -import android.content.Context import androidx.fragment.app.FragmentManager import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableSharedFlow @@ -20,12 +19,14 @@ import org.openedx.core.domain.model.CourseComponentStatus import org.openedx.core.domain.model.CourseDateBlock import org.openedx.core.domain.model.CourseDatesBannerInfo import org.openedx.core.domain.model.CourseDatesResult +import org.openedx.core.domain.model.CourseStructure import org.openedx.core.extension.getSequentialBlocks import org.openedx.core.extension.getVerticalBlocks import org.openedx.core.extension.isInternetError import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.download.BaseDownloadViewModel +import org.openedx.core.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.presentation.settings.calendarsync.CalendarSyncDialogType @@ -43,7 +44,7 @@ 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 +import org.openedx.course.presentation.download.DownloadDialogManager class CourseOutlineViewModel( val courseId: String, @@ -55,18 +56,22 @@ class CourseOutlineViewModel( private val networkConnection: NetworkConnection, private val preferencesManager: CorePreferences, private val analytics: CourseAnalytics, + private val downloadDialogManager: DownloadDialogManager, + private val fileUtil: FileUtil, val courseRouter: CourseRouter, coreAnalytics: CoreAnalytics, downloadDao: DownloadDao, workerController: DownloadWorkerController, + downloadHelper: DownloadHelper, ) : BaseDownloadViewModel( courseId, downloadDao, preferencesManager, workerController, - coreAnalytics + coreAnalytics, + downloadHelper ) { - val isCourseNestedListEnabled get() = config.getCourseUIConfig().isCourseDropdownNavigationEnabled + val isCourseDropdownNavigationEnabled get() = config.getCourseUIConfig().isCourseDropdownNavigationEnabled private val _uiState = MutableStateFlow(CourseOutlineUIState.Loading) val uiState: StateFlow @@ -89,6 +94,8 @@ class CourseOutlineViewModel( private val subSectionsDownloadsCount = mutableMapOf() val courseSubSectionUnit = mutableMapOf() + private var isOfflineBlocksUpToDate = false + init { viewModelScope.launch { courseNotifier.notifier.collect { event -> @@ -126,20 +133,6 @@ class CourseOutlineViewModel( getCourseData() } - override fun saveDownloadModels(folder: String, id: String) { - if (preferencesManager.videoSettings.wifiDownloadOnly) { - if (networkConnection.isWifiConnected()) { - super.saveDownloadModels(folder, id) - } else { - viewModelScope.launch { - _uiMessage.emit(UIMessage.ToastMessage(resourceManager.getString(courseR.string.course_can_download_only_with_wifi))) - } - } - } else { - super.saveDownloadModels(folder, id) - } - } - fun updateCourseData() { getCourseDataInternal() } @@ -203,6 +196,7 @@ class CourseOutlineViewModel( val datesBannerInfo = courseDatesResult.courseBanner checkIfCalendarOutOfDate(courseDatesResult.datesSection.values.flatten()) + updateOutdatedOfflineXBlocks(courseStructure) setBlocks(blocks) courseSubSections.clear() @@ -388,23 +382,87 @@ class CourseOutlineViewModel( } } - fun downloadBlocks( - blocksIds: List, - fragmentManager: FragmentManager, - context: Context - ) { - if (blocksIds.find { isBlockDownloading(it) } != null) { - courseRouter.navigateToDownloadQueue(fm = fragmentManager) - return - } - blocksIds.forEach { blockId -> - if (isBlockDownloaded(blockId)) { - removeDownloadModels(blockId) + fun downloadBlocks(blocksIds: List, fragmentManager: FragmentManager) { + viewModelScope.launch { + val courseData = _uiState.value as? CourseOutlineUIState.CourseData ?: return@launch + + val subSectionsBlocks = courseData.courseSubSections.values.flatten().filter { it.id in blocksIds } + + val blocks = subSectionsBlocks.flatMap { subSectionsBlock -> + val verticalBlocks = allBlocks.values.filter { it.id in subSectionsBlock.descendants } + allBlocks.values.filter { it.id in verticalBlocks.flatMap { it.descendants } } + } + + val downloadableBlocks = blocks.filter { it.isDownloadable } + val downloadingBlocks = blocksIds.filter { isBlockDownloading(it) } + val isAllBlocksDownloaded = downloadableBlocks.all { isBlockDownloaded(it.id) } + + val notDownloadedSubSectionBlocks = subSectionsBlocks.mapNotNull { subSectionsBlock -> + val verticalBlocks = allBlocks.values.filter { it.id in subSectionsBlock.descendants } + val notDownloadedBlocks = allBlocks.values.filter { + it.id in verticalBlocks.flatMap { it.descendants } && it.isDownloadable && !isBlockDownloaded(it.id) + } + if (notDownloadedBlocks.isNotEmpty()) { + subSectionsBlock + } else { + null + } + } + + val requiredSubSections = notDownloadedSubSectionBlocks.ifEmpty { + subSectionsBlocks + } + + if (downloadingBlocks.isNotEmpty()) { + val downloadableChildren = downloadingBlocks.flatMap { getDownloadableChildren(it).orEmpty() } + if (config.getCourseUIConfig().isCourseDownloadQueueEnabled) { + courseRouter.navigateToDownloadQueue(fragmentManager, downloadableChildren) + } else { + downloadableChildren.forEach { + if (!isBlockDownloaded(it)) { + removeBlockDownloadModel(it) + } + } + } } else { - saveDownloadModels( - FileUtil(context).getExternalAppDir().path, blockId + downloadDialogManager.showPopup( + subSectionsBlocks = requiredSubSections, + courseId = courseId, + isBlocksDownloaded = isAllBlocksDownloaded, + fragmentManager = fragmentManager, + removeDownloadModels = ::removeDownloadModels, + saveDownloadModels = { blockId -> + saveDownloadModels(fileUtil.getExternalAppDir().path, blockId) + } ) } } } + + private fun updateOutdatedOfflineXBlocks(courseStructure: CourseStructure) { + viewModelScope.launch { + if (!isOfflineBlocksUpToDate) { + val xBlocks = courseStructure.blockData.filter { it.isxBlock } + if (xBlocks.isNotEmpty()) { + val xBlockIds = xBlocks.map { it.id }.toSet() + val savedDownloadModelsMap = interactor.getAllDownloadModels() + .filter { it.id in xBlockIds } + .associateBy { it.id } + + val outdatedBlockIds = xBlocks + .filter { block -> + val savedBlock = savedDownloadModelsMap[block.id] + savedBlock != null && block.offlineDownload?.lastModified != savedBlock.lastModified + } + .map { it.id } + + outdatedBlockIds.forEach { blockId -> + interactor.removeDownloadModel(blockId) + } + saveDownloadModels(fileUtil.getExternalAppDir().path, outdatedBlockIds) + } + isOfflineBlocksUpToDate = true + } + } + } } 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 0c83b264b..2aee3cbc5 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 @@ -17,7 +17,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn @@ -25,13 +24,10 @@ import androidx.compose.foundation.lazy.items import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Divider import androidx.compose.material.Icon -import androidx.compose.material.IconButton 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.Close import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -42,7 +38,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.res.painterResource @@ -65,7 +60,6 @@ import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts import org.openedx.core.extension.serializable -import org.openedx.core.module.db.DownloadedState import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage @@ -79,12 +73,9 @@ 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 @@ -133,15 +124,6 @@ class CourseSectionFragment : Fragment() { ) } }, - onDownloadClick = { - if (viewModel.isBlockDownloading(it.id) || viewModel.isBlockDownloaded(it.id)) { - viewModel.removeDownloadModels(it.id) - } else { - viewModel.saveDownloadModels( - FileUtil(context).getExternalAppDir().path, it.id - ) - } - } ) LaunchedEffect(rememberSaveable { true }) { @@ -194,7 +176,6 @@ private fun CourseSectionScreen( uiMessage: UIMessage?, onBackClick: () -> Unit, onItemClick: (Block) -> Unit, - onDownloadClick: (Block) -> Unit ) { val scaffoldState = rememberScaffoldState() val title = when (uiState) { @@ -283,11 +264,9 @@ private fun CourseSectionScreen( items(uiState.blocks) { block -> CourseSubsectionItem( block = block, - downloadedState = uiState.downloadedState[block.id], onClick = { onItemClick(it) }, - onDownloadClick = onDownloadClick ) Divider() } @@ -304,9 +283,7 @@ private fun CourseSectionScreen( @Composable private fun CourseSubsectionItem( block: Block, - downloadedState: DownloadedState?, onClick: (Block) -> Unit, - onDownloadClick: (Block) -> Unit ) { val completedIconPainter = if (block.isCompleted()) painterResource(R.drawable.course_ic_task_alt) else painterResource( @@ -320,8 +297,6 @@ private fun CourseSubsectionItem( stringResource(id = R.string.course_accessibility_section_uncompleted) } - val iconModifier = Modifier.size(24.dp) - Column(Modifier.clickable { onClick(block) }) { Row( Modifier @@ -354,47 +329,6 @@ private fun CourseSubsectionItem( horizontalArrangement = Arrangement.spacedBy(24.dp), verticalAlignment = Alignment.CenterVertically ) { - if (downloadedState == DownloadedState.DOWNLOADED || downloadedState == DownloadedState.NOT_DOWNLOADED) { - val downloadIconPainter = if (downloadedState == DownloadedState.DOWNLOADED) { - painterResource(id = R.drawable.course_ic_remove_download) - } else { - painterResource(id = R.drawable.course_ic_start_download) - } - val downloadIconDescription = - if (downloadedState == DownloadedState.DOWNLOADED) { - stringResource(id = R.string.course_accessibility_remove_course_section) - } else { - stringResource(id = R.string.course_accessibility_download_course_section) - } - IconButton(modifier = iconModifier, - onClick = { onDownloadClick(block) }) { - Icon( - painter = downloadIconPainter, - contentDescription = downloadIconDescription, - tint = MaterialTheme.appColors.textPrimary - ) - } - } else if (downloadedState != null) { - Box(contentAlignment = Alignment.Center) { - if (downloadedState == DownloadedState.DOWNLOADING || downloadedState == DownloadedState.WAITING) { - CircularProgressIndicator( - modifier = Modifier.size(34.dp), - backgroundColor = Color.LightGray, - strokeWidth = 2.dp, - color = MaterialTheme.appColors.primary - ) - } - IconButton( - modifier = iconModifier.padding(top = 2.dp), - onClick = { onDownloadClick(block) }) { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = stringResource(id = R.string.course_accessibility_stop_downloading_course_section), - tint = MaterialTheme.appColors.error - ) - } - } - } CardArrow( degrees = 0f ) @@ -427,14 +361,12 @@ private fun CourseSectionScreenPreview() { mockBlock, mockBlock ), - mapOf(), "", "Course default" ), uiMessage = null, onBackClick = {}, onItemClick = {}, - onDownloadClick = {} ) } } @@ -453,14 +385,12 @@ private fun CourseSectionScreenTabletPreview() { mockBlock, mockBlock ), - mapOf(), "", "Course default", ), uiMessage = null, onBackClick = {}, onItemClick = {}, - onDownloadClick = {} ) } } @@ -482,5 +412,6 @@ private val mockBlock = Block( completion = 0.0, containsGatedContent = false, assignmentProgress = AssignmentProgress("", 1f, 2f), - due = Date() + due = Date(), + offlineDownload = null ) diff --git a/course/src/main/java/org/openedx/course/presentation/section/CourseSectionUIState.kt b/course/src/main/java/org/openedx/course/presentation/section/CourseSectionUIState.kt index a8a16681a..1606de1e7 100644 --- a/course/src/main/java/org/openedx/course/presentation/section/CourseSectionUIState.kt +++ b/course/src/main/java/org/openedx/course/presentation/section/CourseSectionUIState.kt @@ -1,12 +1,10 @@ package org.openedx.course.presentation.section import org.openedx.core.domain.model.Block -import org.openedx.core.module.db.DownloadedState sealed class CourseSectionUIState { data class Blocks( val blocks: List, - val downloadedState: Map, val sectionName: String, val courseName: String ) : CourseSectionUIState() diff --git a/course/src/main/java/org/openedx/course/presentation/section/CourseSectionViewModel.kt b/course/src/main/java/org/openedx/course/presentation/section/CourseSectionViewModel.kt index 33870c69c..7f12a314f 100644 --- a/course/src/main/java/org/openedx/course/presentation/section/CourseSectionViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/section/CourseSectionViewModel.kt @@ -5,20 +5,15 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.launch +import org.openedx.core.BaseViewModel import org.openedx.core.BlockType import org.openedx.core.R import org.openedx.core.SingleEventLiveData import org.openedx.core.UIMessage -import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Block import org.openedx.core.extension.isInternetError -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.system.ResourceManager -import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseSectionChanged import org.openedx.course.domain.interactor.CourseInteractor @@ -30,20 +25,9 @@ class CourseSectionViewModel( val courseId: String, private val interactor: CourseInteractor, private val resourceManager: ResourceManager, - private val networkConnection: NetworkConnection, - private val preferencesManager: CorePreferences, private val notifier: CourseNotifier, private val analytics: CourseAnalytics, - coreAnalytics: CoreAnalytics, - workerController: DownloadWorkerController, - downloadDao: DownloadDao, -) : BaseDownloadViewModel( - courseId, - downloadDao, - preferencesManager, - workerController, - coreAnalytics -) { +) : BaseViewModel() { private val _uiState = MutableLiveData(CourseSectionUIState.Loading) val uiState: LiveData @@ -57,24 +41,6 @@ class CourseSectionViewModel( override fun onCreate(owner: LifecycleOwner) { super.onCreate(owner) - viewModelScope.launch { - downloadModelsStatusFlow.collect { downloadModels -> - when (val state = uiState.value) { - is CourseSectionUIState.Blocks -> { - val list = (uiState.value as CourseSectionUIState.Blocks).blocks - _uiState.value = CourseSectionUIState.Blocks( - sectionName = state.sectionName, - courseName = state.courseName, - blocks = ArrayList(list), - downloadedState = downloadModels.toMap() - ) - } - - else -> {} - } - } - } - viewModelScope.launch { notifier.notifier.collect { event -> if (event is CourseSectionChanged) { @@ -93,14 +59,11 @@ class CourseSectionViewModel( CourseViewMode.VIDEOS -> interactor.getCourseStructureForVideos(courseId) } val blocks = courseStructure.blockData - setBlocks(blocks) val newList = getDescendantBlocks(blocks, blockId) val sequentialBlock = getSequentialBlock(blocks, blockId) - initDownloadModelsStatus() _uiState.value = CourseSectionUIState.Blocks( blocks = ArrayList(newList), - downloadedState = getDownloadModelsStatus(), courseName = courseStructure.name, sectionName = sequentialBlock.displayName ) @@ -116,19 +79,6 @@ class CourseSectionViewModel( } } - override fun saveDownloadModels(folder: String, id: String) { - if (preferencesManager.videoSettings.wifiDownloadOnly) { - if (networkConnection.isWifiConnected()) { - super.saveDownloadModels(folder, id) - } else { - _uiMessage.value = - UIMessage.ToastMessage(resourceManager.getString(org.openedx.course.R.string.course_can_download_only_with_wifi)) - } - } else { - super.saveDownloadModels(folder, id) - } - } - private fun getDescendantBlocks(blocks: List, id: String): List { val resultList = mutableListOf() if (blocks.isEmpty()) return emptyList() @@ -140,7 +90,6 @@ class CourseSectionViewModel( if (blockDescendant != null) { if (blockDescendant.type == BlockType.VERTICAL) { resultList.add(blockDescendant) - addDownloadableChildrenForVerticalBlock(blockDescendant) } } else continue } 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 b111dd1f0..f1bbe6086 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 @@ -47,6 +47,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.CloudDone +import androidx.compose.material.icons.outlined.CloudDownload import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -63,7 +64,6 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource @@ -161,10 +161,10 @@ fun CourseSectionCard( verticalAlignment = Alignment.CenterVertically ) { if (downloadedState == DownloadedState.DOWNLOADED || downloadedState == DownloadedState.NOT_DOWNLOADED) { - val downloadIconPainter = if (downloadedState == DownloadedState.DOWNLOADED) { - painterResource(id = R.drawable.course_ic_remove_download) + val downloadIcon = if (downloadedState == DownloadedState.DOWNLOADED) { + Icons.Default.CloudDone } else { - painterResource(id = R.drawable.course_ic_start_download) + Icons.Outlined.CloudDownload } val downloadIconDescription = if (downloadedState == DownloadedState.DOWNLOADED) { @@ -175,7 +175,7 @@ fun CourseSectionCard( IconButton(modifier = iconModifier, onClick = { onDownloadClick(block) }) { Icon( - painter = downloadIconPainter, + imageVector = downloadIcon, contentDescription = downloadIconDescription, tint = MaterialTheme.appColors.textPrimary ) @@ -609,7 +609,6 @@ fun CourseSection( filteredStatuses.any { it.isWaitingOrDownloading } -> DownloadedState.DOWNLOADING else -> DownloadedState.NOT_DOWNLOADED } - val downloadBlockIds = downloadedStateMap.keys.filter { it in block.descendants } Column(modifier = modifier .clip(MaterialTheme.appShapes.cardShape) @@ -626,7 +625,7 @@ fun CourseSection( arrowDegrees = arrowRotation, downloadedState = downloadedState, onDownloadClick = { - onDownloadClick(downloadBlockIds) + onDownloadClick(block.descendants) } ) courseSubSections?.forEach { subSectionBlock -> @@ -685,11 +684,11 @@ fun CourseExpandableChapterCard( verticalAlignment = Alignment.CenterVertically ) { if (downloadedState == DownloadedState.DOWNLOADED || downloadedState == DownloadedState.NOT_DOWNLOADED) { - val downloadIconPainter = + val downloadIcon = if (downloadedState == DownloadedState.DOWNLOADED) { - rememberVectorPainter(Icons.Default.CloudDone) + Icons.Default.CloudDone } else { - painterResource(id = R.drawable.course_ic_start_download) + Icons.Outlined.CloudDownload } val downloadIconDescription = if (downloadedState == DownloadedState.DOWNLOADED) { @@ -706,7 +705,7 @@ fun CourseExpandableChapterCard( IconButton(modifier = iconModifier, onClick = { onDownloadClick() }) { Icon( - painter = downloadIconPainter, + imageVector = downloadIcon, contentDescription = downloadIconDescription, tint = downloadIconTint ) @@ -1277,6 +1276,7 @@ private fun OfflineQueueCardPreview() { Surface(color = MaterialTheme.appColors.background) { OfflineQueueCard( downloadModel = DownloadModel( + courseId = "", id = "", title = "Problems of society", size = 4000, @@ -1284,7 +1284,6 @@ private fun OfflineQueueCardPreview() { url = "", type = FileType.VIDEO, downloadedState = DownloadedState.DOWNLOADING, - progress = 0f ), progressValue = 10, progressSize = 30, @@ -1332,5 +1331,6 @@ private val mockChapterBlock = Block( completion = 0.0, containsGatedContent = false, assignmentProgress = AssignmentProgress("", 1f, 2f), - due = Date() + due = Date(), + offlineDownload = null ) 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 1a406181d..64022f498 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 @@ -103,15 +103,24 @@ fun CourseVideosScreen( viewModel.switchCourseSections(block.id) }, onSubSectionClick = { subSectionBlock -> - viewModel.courseSubSectionUnit[subSectionBlock.id]?.let { unit -> + if (viewModel.isCourseDropdownNavigationEnabled) { + viewModel.courseSubSectionUnit[subSectionBlock.id]?.let { unit -> + viewModel.courseRouter.navigateToCourseContainer( + fragmentManager, + courseId = viewModel.courseId, + unitId = unit.id, + mode = CourseViewMode.VIDEOS + ) + } + } else { viewModel.sequentialClickedEvent( - unit.blockId, - unit.displayName + subSectionBlock.blockId, + subSectionBlock.displayName ) - viewModel.courseRouter.navigateToCourseContainer( + viewModel.courseRouter.navigateToCourseSubsections( fm = fragmentManager, courseId = viewModel.courseId, - unitId = unit.id, + subSectionId = subSectionBlock.id, mode = CourseViewMode.VIDEOS ) } @@ -120,7 +129,6 @@ fun CourseVideosScreen( viewModel.downloadBlocks( blocksIds = blocksIds, fragmentManager = fragmentManager, - context = context ) }, onDownloadAllClick = { isAllBlocksDownloadedOrDownloading -> @@ -718,7 +726,8 @@ private val mockChapterBlock = Block( completion = 0.0, containsGatedContent = false, assignmentProgress = mockAssignmentProgress, - due = Date() + due = Date(), + offlineDownload = null ) private val mockSequentialBlock = Block( @@ -738,7 +747,8 @@ private val mockSequentialBlock = Block( completion = 0.0, containsGatedContent = false, assignmentProgress = mockAssignmentProgress, - due = Date() + due = Date(), + offlineDownload = null ) private val mockCourseStructure = CourseStructure( diff --git a/course/src/main/java/org/openedx/course/presentation/unit/NotSupportedUnitFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/NotAvailableUnitFragment.kt similarity index 52% rename from course/src/main/java/org/openedx/course/presentation/unit/NotSupportedUnitFragment.kt rename to course/src/main/java/org/openedx/course/presentation/unit/NotAvailableUnitFragment.kt index 0aaae4a3c..b29a7ac8f 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/NotSupportedUnitFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/NotAvailableUnitFragment.kt @@ -3,10 +3,25 @@ package org.openedx.course.presentation.unit import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.* +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -22,6 +37,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.core.os.bundleOf import androidx.fragment.app.Fragment +import org.openedx.core.extension.parcelable import org.openedx.core.ui.WindowSize import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.theme.OpenEdXTheme @@ -31,7 +47,7 @@ import org.openedx.core.ui.theme.appTypography import org.openedx.core.ui.windowSizeValue import org.openedx.course.R as courseR -class NotSupportedUnitFragment : Fragment() { +class NotAvailableUnitFragment : Fragment() { private var blockId: String? = null @@ -49,9 +65,40 @@ class NotSupportedUnitFragment : Fragment() { setContent { OpenEdXTheme { val windowSize = rememberWindowSize() - NotSupportedUnitScreen( + val uriHandler = LocalUriHandler.current + val uri = requireArguments().getString(ARG_BLOCK_URL, "") + val title: String + val description: String + var buttonAction: (() -> Unit)? = null + when (requireArguments().parcelable(ARG_UNIT_TYPE)) { + NotAvailableUnitType.MOBILE_UNSUPPORTED -> { + title = stringResource(id = courseR.string.course_this_interactive_component) + description = stringResource(id = courseR.string.course_explore_other_parts_on_web) + buttonAction = { + uriHandler.openUri(uri) + } + } + + NotAvailableUnitType.OFFLINE_UNSUPPORTED -> { + title = stringResource(id = courseR.string.course_not_available_offline) + description = stringResource(id = courseR.string.course_explore_other_parts_when_reconnect) + } + + NotAvailableUnitType.NOT_DOWNLOADED -> { + title = stringResource(id = courseR.string.course_not_downloaded) + description = + stringResource(id = courseR.string.course_explore_other_parts_when_reconnect_or_download) + } + + else -> { + return@OpenEdXTheme + } + } + NotAvailableUnitScreen( windowSize = windowSize, - uri = requireArguments().getString(ARG_BLOCK_URL, "") + title = title, + description = description, + buttonAction = buttonAction ) } } @@ -60,14 +107,17 @@ class NotSupportedUnitFragment : Fragment() { companion object { private const val ARG_BLOCK_ID = "blockId" private const val ARG_BLOCK_URL = "blockUrl" + private const val ARG_UNIT_TYPE = "notAvailableUnitType" fun newInstance( blockId: String, - blockUrl: String - ): NotSupportedUnitFragment { - val fragment = NotSupportedUnitFragment() + blockUrl: String, + unitType: NotAvailableUnitType, + ): NotAvailableUnitFragment { + val fragment = NotAvailableUnitFragment() fragment.arguments = bundleOf( ARG_BLOCK_ID to blockId, - ARG_BLOCK_URL to blockUrl + ARG_BLOCK_URL to blockUrl, + ARG_UNIT_TYPE to unitType ) return fragment } @@ -76,12 +126,13 @@ class NotSupportedUnitFragment : Fragment() { } @Composable -private fun NotSupportedUnitScreen( +private fun NotAvailableUnitScreen( windowSize: WindowSize, - uri: String + title: String, + description: String, + buttonAction: (() -> Unit)? = null, ) { val scaffoldState = rememberScaffoldState() - val uriHandler = LocalUriHandler.current val scrollState = rememberScrollState() Scaffold( modifier = Modifier.fillMaxSize(), @@ -120,7 +171,7 @@ private fun NotSupportedUnitScreen( Spacer(Modifier.height(36.dp)) Text( modifier = Modifier.fillMaxWidth(), - text = stringResource(id = courseR.string.course_this_interactive_component), + text = title, style = MaterialTheme.appTypography.titleLarge, color = MaterialTheme.appColors.textPrimary, textAlign = TextAlign.Center @@ -128,29 +179,31 @@ private fun NotSupportedUnitScreen( Spacer(Modifier.height(12.dp)) Text( modifier = Modifier.fillMaxWidth(), - text = stringResource(id = courseR.string.course_explore_other_parts), + text = description, style = MaterialTheme.appTypography.bodyLarge, color = MaterialTheme.appColors.textPrimary, textAlign = TextAlign.Center ) Spacer(Modifier.height(40.dp)) - Button(modifier = Modifier - .width(216.dp) - .height(42.dp), - shape = MaterialTheme.appShapes.buttonShape, - colors = ButtonDefaults.buttonColors( - backgroundColor = MaterialTheme.appColors.primaryButtonBackground - ), - onClick = { - uriHandler.openUri(uri) - }) { - Text( - text = stringResource(id = courseR.string.course_open_in_browser), - color = MaterialTheme.appColors.primaryButtonText, - style = MaterialTheme.appTypography.labelLarge - ) + if (buttonAction != null) { + Button( + modifier = Modifier + .width(216.dp) + .height(42.dp), + shape = MaterialTheme.appShapes.buttonShape, + colors = ButtonDefaults.buttonColors( + backgroundColor = MaterialTheme.appColors.primaryButtonBackground + ), + onClick = buttonAction + ) { + Text( + text = stringResource(id = courseR.string.course_open_in_browser), + color = MaterialTheme.appColors.primaryButtonText, + style = MaterialTheme.appTypography.labelLarge + ) + } + Spacer(Modifier.height(20.dp)) } - Spacer(Modifier.height(20.dp)) } } } diff --git a/course/src/main/java/org/openedx/course/presentation/unit/NotAvailableUnitType.kt b/course/src/main/java/org/openedx/course/presentation/unit/NotAvailableUnitType.kt new file mode 100644 index 000000000..0b02b876e --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/unit/NotAvailableUnitType.kt @@ -0,0 +1,9 @@ +package org.openedx.course.presentation.unit + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +enum class NotAvailableUnitType : Parcelable { + MOBILE_UNSUPPORTED, OFFLINE_UNSUPPORTED, NOT_DOWNLOADED +} diff --git a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt index 6d37954ee..a8953baf1 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt @@ -4,12 +4,14 @@ import androidx.fragment.app.Fragment import androidx.viewpager2.adapter.FragmentStateAdapter import org.openedx.core.FragmentViewType import org.openedx.core.domain.model.Block -import org.openedx.course.presentation.unit.NotSupportedUnitFragment +import org.openedx.course.presentation.unit.NotAvailableUnitFragment +import org.openedx.course.presentation.unit.NotAvailableUnitType import org.openedx.course.presentation.unit.html.HtmlUnitFragment import org.openedx.course.presentation.unit.video.VideoUnitFragment import org.openedx.course.presentation.unit.video.YoutubeVideoUnitFragment import org.openedx.discussion.presentation.threads.DiscussionThreadsFragment import org.openedx.discussion.presentation.topics.DiscussionTopicsViewModel +import java.io.File class CourseUnitContainerAdapter( fragment: Fragment, @@ -22,73 +24,92 @@ class CourseUnitContainerAdapter( override fun createFragment(position: Int): Fragment = unitBlockFragment(blocks[position]) private fun unitBlockFragment(block: Block): Fragment { + val downloadedModel = viewModel.getDownloadModelById(block.id) + val offlineUrl = downloadedModel?.let { it.path + File.separator + "index.html" } ?: "" + val noNetwork = !viewModel.hasNetworkConnection + return when { - (block.isVideoBlock && - (block.studentViewData?.encodedVideos?.hasVideoUrl == true || - block.studentViewData?.encodedVideos?.hasYoutubeUrl == true)) -> { - val encodedVideos = block.studentViewData?.encodedVideos!! - val transcripts = block.studentViewData!!.transcripts - with(encodedVideos) { - var isDownloaded = false - val videoUrl = if (viewModel.getDownloadModelById(block.id) != null) { - isDownloaded = true - viewModel.getDownloadModelById(block.id)!!.path - } else videoUrl - if (videoUrl.isNotEmpty()) { - VideoUnitFragment.newInstance( - block.id, - viewModel.courseId, - videoUrl, - transcripts?.toMap() ?: emptyMap(), - block.displayName, - isDownloaded - ) - } else { - YoutubeVideoUnitFragment.newInstance( - block.id, - viewModel.courseId, - encodedVideos.youtube?.url ?: "", - transcripts?.toMap() ?: emptyMap(), - block.displayName - ) - } - } + noNetwork && block.isDownloadable && offlineUrl.isEmpty() -> { + createNotAvailableUnitFragment(block, NotAvailableUnitType.NOT_DOWNLOADED) } - (block.isDiscussionBlock && block.studentViewData?.topicId.isNullOrEmpty().not()) -> { - DiscussionThreadsFragment.newInstance( - DiscussionTopicsViewModel.TOPIC, - viewModel.courseId, - block.studentViewData?.topicId ?: "", - block.displayName, - FragmentViewType.MAIN_CONTENT.name, - block.id - ) + noNetwork && !block.isDownloadable -> { + createNotAvailableUnitFragment(block, NotAvailableUnitType.OFFLINE_UNSUPPORTED) } - block.studentViewMultiDevice.not() -> { - NotSupportedUnitFragment.newInstance( - block.id, - block.lmsWebUrl - ) + block.isVideoBlock && block.studentViewData?.encodedVideos?.run { hasVideoUrl || hasYoutubeUrl } == true -> { + createVideoFragment(block) } - block.isHTMLBlock || - block.isProblemBlock || - block.isOpenAssessmentBlock || - block.isDragAndDropBlock || - block.isWordCloudBlock || - block.isLTIConsumerBlock || - block.isSurveyBlock -> { - HtmlUnitFragment.newInstance(block.id, block.studentViewUrl) + block.isDiscussionBlock && !block.studentViewData?.topicId.isNullOrEmpty() -> { + createDiscussionFragment(block) } - else -> { - NotSupportedUnitFragment.newInstance( + !block.studentViewMultiDevice -> { + createNotAvailableUnitFragment(block, NotAvailableUnitType.MOBILE_UNSUPPORTED) + } + + block.isHTMLBlock || block.isProblemBlock || block.isOpenAssessmentBlock || block.isDragAndDropBlock || + block.isWordCloudBlock || block.isLTIConsumerBlock || block.isSurveyBlock -> { + val lastModified = if (downloadedModel != null && noNetwork) { + downloadedModel.lastModified ?: "" + } else { + "" + } + HtmlUnitFragment.newInstance( block.id, - block.lmsWebUrl + block.studentViewUrl, + viewModel.courseId, + offlineUrl, + lastModified ) } + + else -> { + createNotAvailableUnitFragment(block, NotAvailableUnitType.MOBILE_UNSUPPORTED) + } } } + + private fun createNotAvailableUnitFragment(block: Block, type: NotAvailableUnitType): Fragment { + return NotAvailableUnitFragment.newInstance(block.id, block.lmsWebUrl, type) + } + + private fun createVideoFragment(block: Block): Fragment { + val encodedVideos = block.studentViewData!!.encodedVideos!! + val transcripts = block.studentViewData!!.transcripts ?: emptyMap() + val downloadedModel = viewModel.getDownloadModelById(block.id) + val isDownloaded = downloadedModel != null + val videoUrl = downloadedModel?.path ?: encodedVideos.videoUrl + + return if (videoUrl.isNotEmpty()) { + VideoUnitFragment.newInstance( + block.id, + viewModel.courseId, + videoUrl, + transcripts, + block.displayName, + isDownloaded + ) + } else { + YoutubeVideoUnitFragment.newInstance( + block.id, + viewModel.courseId, + encodedVideos.youtube?.url ?: "", + transcripts, + block.displayName + ) + } + } + + private fun createDiscussionFragment(block: Block): Fragment { + return DiscussionThreadsFragment.newInstance( + DiscussionTopicsViewModel.TOPIC, + viewModel.courseId, + block.studentViewData?.topicId ?: "", + block.displayName, + FragmentViewType.MAIN_CONTENT.name, + block.id + ) + } } diff --git a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt index af4d839e7..20c0c7c3c 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt @@ -18,6 +18,7 @@ import org.openedx.core.extension.indexOfFirstFromIndex import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadedState import org.openedx.core.presentation.course.CourseViewMode +import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseSectionChanged import org.openedx.core.system.notifier.CourseStructureUpdated @@ -33,6 +34,7 @@ class CourseUnitContainerViewModel( private val interactor: CourseInteractor, private val notifier: CourseNotifier, private val analytics: CourseAnalytics, + private val networkConnection: NetworkConnection, ) : BaseViewModel() { private val blocks = ArrayList() @@ -82,6 +84,9 @@ class CourseUnitContainerViewModel( private val _descendantsBlocks = MutableStateFlow>(listOf()) val descendantsBlocks = _descendantsBlocks.asStateFlow() + val hasNetworkConnection: Boolean + get() = networkConnection.isOnline() + fun loadBlocks(mode: CourseViewMode, componentId: String = "") { currentMode = mode viewModelScope.launch { diff --git a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt index 392fa07fa..db88ae6c8 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt @@ -12,6 +12,7 @@ import android.view.ViewGroup import android.webkit.JavascriptInterface import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse +import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebViewClient import androidx.compose.foundation.background @@ -32,6 +33,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -49,6 +51,7 @@ import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import kotlinx.coroutines.launch import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf import org.openedx.core.extension.applyDarkModeIfEnabled import org.openedx.core.extension.isEmailValid import org.openedx.core.extension.loadUrl @@ -64,14 +67,23 @@ import org.openedx.core.utils.EmailUtil class HtmlUnitFragment : Fragment() { - private val viewModel by viewModel() - private var blockId: String = "" + private val viewModel by viewModel { + parametersOf( + requireArguments().getString(ARG_BLOCK_ID, ""), + requireArguments().getString(ARG_COURSE_ID, "") + ) + } private var blockUrl: String = "" + private var offlineUrl: String = "" + private var lastModified: String = "" + private var fromDownloadedContent: Boolean = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - blockId = requireArguments().getString(ARG_BLOCK_ID, "") blockUrl = requireArguments().getString(ARG_BLOCK_URL, "") + offlineUrl = requireArguments().getString(ARG_OFFLINE_URL, "") + lastModified = requireArguments().getString(ARG_LAST_MODIFIED, "") + fromDownloadedContent = lastModified.isNotEmpty() } override fun onCreateView( @@ -92,7 +104,18 @@ class HtmlUnitFragment : Fragment() { mutableStateOf(viewModel.isOnline) } + val url by rememberSaveable { + mutableStateOf( + if (!hasInternetConnection && offlineUrl.isNotEmpty()) { + offlineUrl + } else { + blockUrl + } + ) + } + val injectJSList by viewModel.injectJSList.collectAsState() + val uiState by viewModel.uiState.collectAsState() val configuration = LocalConfiguration.current @@ -125,43 +148,49 @@ class HtmlUnitFragment : Fragment() { .then(border), contentAlignment = Alignment.TopCenter ) { - if (hasInternetConnection) { - HTMLContentView( - windowSize = windowSize, - url = blockUrl, - cookieManager = viewModel.cookieManager, - apiHostURL = viewModel.apiHostURL, - isLoading = isLoading, - injectJSList = injectJSList, - onCompletionSet = { - viewModel.notifyCompletionSet() - }, - onWebPageLoading = { - isLoading = true - }, - onWebPageLoaded = { - isLoading = false - if (isAdded) viewModel.setWebPageLoaded(requireContext().assets) + if (uiState.isLoadingEnabled) { + if (hasInternetConnection || fromDownloadedContent) { + HTMLContentView( + uiState = uiState, + windowSize = windowSize, + url = url, + cookieManager = viewModel.cookieManager, + apiHostURL = viewModel.apiHostURL, + isLoading = isLoading, + injectJSList = injectJSList, + onCompletionSet = { + viewModel.notifyCompletionSet() + }, + onWebPageLoading = { + isLoading = true + }, + onWebPageLoaded = { + isLoading = false + if (isAdded) viewModel.setWebPageLoaded(requireContext().assets) + }, + saveXBlockProgress = { jsonProgress -> + viewModel.saveXBlockProgress(jsonProgress) + }, + ) + } else { + ConnectionErrorView( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .background(MaterialTheme.appColors.background) + ) { + hasInternetConnection = viewModel.isOnline } - ) - } else { - ConnectionErrorView( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight() - .background(MaterialTheme.appColors.background) - ) { - hasInternetConnection = viewModel.isOnline } - } - if (isLoading && hasInternetConnection) { - Box( - modifier = Modifier - .fillMaxSize() - .zIndex(1f), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator(color = MaterialTheme.appColors.primary) + if (isLoading && hasInternetConnection) { + Box( + modifier = Modifier + .fillMaxSize() + .zIndex(1f), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } } } } @@ -173,15 +202,24 @@ class HtmlUnitFragment : Fragment() { companion object { private const val ARG_BLOCK_ID = "blockId" + private const val ARG_COURSE_ID = "courseId" private const val ARG_BLOCK_URL = "blockUrl" + private const val ARG_OFFLINE_URL = "offlineUrl" + private const val ARG_LAST_MODIFIED = "lastModified" fun newInstance( blockId: String, blockUrl: String, + courseId: String, + offlineUrl: String = "", + lastModified: String = "" ): HtmlUnitFragment { val fragment = HtmlUnitFragment() fragment.arguments = bundleOf( ARG_BLOCK_ID to blockId, - ARG_BLOCK_URL to blockUrl + ARG_BLOCK_URL to blockUrl, + ARG_OFFLINE_URL to offlineUrl, + ARG_LAST_MODIFIED to lastModified, + ARG_COURSE_ID to courseId ) return fragment } @@ -191,6 +229,7 @@ class HtmlUnitFragment : Fragment() { @Composable @SuppressLint("SetJavaScriptEnabled") private fun HTMLContentView( + uiState: HtmlUnitUIState, windowSize: WindowSize, url: String, cookieManager: AppCookieManager, @@ -200,6 +239,7 @@ private fun HTMLContentView( onCompletionSet: () -> Unit, onWebPageLoading: () -> Unit, onWebPageLoaded: () -> Unit, + saveXBlockProgress: (String) -> Unit ) { val coroutineScope = rememberCoroutineScope() val context = LocalContext.current @@ -228,6 +268,17 @@ private fun HTMLContentView( onCompletionSet() } }, "callback") + addJavascriptInterface( + JSBridge( + postMessageCallback = { + coroutineScope.launch { + saveXBlockProgress(it) + setupOfflineProgress(it) + } + } + ), + "AndroidBridge" + ) webViewClient = object : WebViewClient() { override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { @@ -290,7 +341,10 @@ private fun HTMLContentView( setSupportZoom(true) loadsImagesAutomatically = true domStorageEnabled = true - + allowFileAccess = true + allowContentAccess = true + useWideViewPort = true + cacheMode = WebSettings.LOAD_NO_CACHE } isVerticalScrollBarEnabled = false isHorizontalScrollBarEnabled = false @@ -302,8 +356,23 @@ private fun HTMLContentView( update = { webView -> if (!isLoading && injectJSList.isNotEmpty()) { injectJSList.forEach { webView.evaluateJavascript(it, null) } + val jsonProgress = uiState.jsonProgress + if (!jsonProgress.isNullOrEmpty()) { + webView.setupOfflineProgress(jsonProgress) + } } } ) } +private fun WebView.setupOfflineProgress(jsonProgress: String) { + loadUrl("javascript:markProblemCompleted('$jsonProgress');") +} + +class JSBridge(val postMessageCallback: (String) -> Unit) { + @Suppress("unused") + @JavascriptInterface + fun postMessage(str: String) { + postMessageCallback(str) + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitUIState.kt b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitUIState.kt new file mode 100644 index 000000000..2dc14424c --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitUIState.kt @@ -0,0 +1,6 @@ +package org.openedx.course.presentation.unit.html + +data class HtmlUnitUIState( + val jsonProgress: String?, + val isLoadingEnabled: Boolean +) diff --git a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitViewModel.kt index 9d52c979b..f852c1f2d 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitViewModel.kt @@ -2,8 +2,11 @@ package org.openedx.course.presentation.unit.html import android.content.res.AssetManager import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.openedx.core.BaseViewModel import org.openedx.core.config.Config @@ -12,14 +15,24 @@ import org.openedx.core.system.AppCookieManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseCompletionSet import org.openedx.core.system.notifier.CourseNotifier +import org.openedx.course.domain.interactor.CourseInteractor +import org.openedx.course.worker.OfflineProgressSyncScheduler class HtmlUnitViewModel( + private val blockId: String, + private val courseId: String, private val config: Config, private val edxCookieManager: AppCookieManager, private val networkConnection: NetworkConnection, - private val notifier: CourseNotifier + private val notifier: CourseNotifier, + private val courseInteractor: CourseInteractor, + private val offlineProgressSyncScheduler: OfflineProgressSyncScheduler ) : BaseViewModel() { + private val _uiState = MutableStateFlow(HtmlUnitUIState(null, false)) + val uiState: StateFlow + get() = _uiState.asStateFlow() + private val _injectJSList = MutableStateFlow>(listOf()) val injectJSList = _injectJSList.asStateFlow() @@ -28,6 +41,10 @@ class HtmlUnitViewModel( val apiHostURL get() = config.getApiHostURL() val cookieManager get() = edxCookieManager + init { + tryToSyncProgress() + } + fun setWebPageLoaded(assets: AssetManager) { if (_injectJSList.value.isNotEmpty()) return @@ -39,6 +56,7 @@ class HtmlUnitViewModel( assets.readAsText("js_injection/survey_css.js")?.let { jsList.add(it) } _injectJSList.value = jsList + getXBlockProgress() } fun notifyCompletionSet() { @@ -46,4 +64,34 @@ class HtmlUnitViewModel( notifier.send(CourseCompletionSet()) } } + + fun saveXBlockProgress(jsonProgress: String) { + viewModelScope.launch { + courseInteractor.saveXBlockProgress(blockId, courseId, jsonProgress) + offlineProgressSyncScheduler.scheduleSync() + } + } + + private fun tryToSyncProgress() { + viewModelScope.launch { + try { + if (isOnline) { + courseInteractor.submitOfflineXBlockProgress(blockId, courseId) + } + } catch (e: Exception) { + } finally { + _uiState.update { it.copy(isLoadingEnabled = true) } + } + } + } + + private fun getXBlockProgress() { + viewModelScope.launch { + if (!isOnline) { + val xBlockProgress = courseInteractor.getXBlockProgress(blockId) + delay(500) + _uiState.update { it.copy(jsonProgress = xBlockProgress?.jsonProgress?.toJson()) } + } + } + } } 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 a5bf069cd..eb2c2d155 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 @@ -1,6 +1,5 @@ package org.openedx.course.presentation.videos -import android.content.Context import androidx.fragment.app.FragmentManager import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableSharedFlow @@ -19,6 +18,7 @@ import org.openedx.core.domain.model.VideoSettings import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.download.BaseDownloadViewModel +import org.openedx.core.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection @@ -32,6 +32,7 @@ import org.openedx.course.R import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseRouter +import org.openedx.course.presentation.download.DownloadDialogManager class CourseVideoViewModel( val courseId: String, @@ -44,19 +45,23 @@ class CourseVideoViewModel( private val courseNotifier: CourseNotifier, private val videoNotifier: VideoNotifier, private val analytics: CourseAnalytics, + private val downloadDialogManager: DownloadDialogManager, + private val fileUtil: FileUtil, val courseRouter: CourseRouter, coreAnalytics: CoreAnalytics, downloadDao: DownloadDao, - workerController: DownloadWorkerController + workerController: DownloadWorkerController, + downloadHelper: DownloadHelper, ) : BaseDownloadViewModel( courseId, downloadDao, preferencesManager, workerController, - coreAnalytics + coreAnalytics, + downloadHelper, ) { - val isCourseNestedListEnabled get() = config.getCourseUIConfig().isCourseDropdownNavigationEnabled + val isCourseDropdownNavigationEnabled get() = config.getCourseUIConfig().isCourseDropdownNavigationEnabled private val _uiState = MutableStateFlow(CourseVideosUIState.Loading) val uiState: StateFlow @@ -214,21 +219,55 @@ class CourseVideoViewModel( return resultBlocks.toList() } - fun downloadBlocks( - blocksIds: List, - fragmentManager: FragmentManager, - context: Context - ) { - if (blocksIds.find { isBlockDownloading(it) } != null) { - courseRouter.navigateToDownloadQueue(fm = fragmentManager) - return - } - blocksIds.forEach { blockId -> - if (isBlockDownloaded(blockId)) { - removeDownloadModels(blockId) + fun downloadBlocks(blocksIds: List, fragmentManager: FragmentManager) { + viewModelScope.launch { + val courseData = _uiState.value as? CourseVideosUIState.CourseData ?: return@launch + + val subSectionsBlocks = courseData.courseSubSections.values.flatten().filter { it.id in blocksIds } + + val blocks = subSectionsBlocks.flatMap { subSectionsBlock -> + val verticalBlocks = allBlocks.values.filter { it.id in subSectionsBlock.descendants } + allBlocks.values.filter { it.id in verticalBlocks.flatMap { it.descendants } } + } + + val downloadableBlocks = blocks.filter { it.isDownloadable } + val downloadingBlocks = blocksIds.filter { isBlockDownloading(it) } + val isAllBlocksDownloaded = downloadableBlocks.all { isBlockDownloaded(it.id) } + + val notDownloadedSubSectionBlocks = subSectionsBlocks.mapNotNull { subSectionsBlock -> + val verticalBlocks = allBlocks.values.filter { it.id in subSectionsBlock.descendants } + val notDownloadedBlocks = allBlocks.values.filter { + it.id in verticalBlocks.flatMap { it.descendants } && it.isDownloadable && !isBlockDownloaded(it.id) + } + if (notDownloadedBlocks.isNotEmpty()) subSectionsBlock else null + } + + val requiredSubSections = notDownloadedSubSectionBlocks.ifEmpty { + subSectionsBlocks + } + + if (downloadingBlocks.isNotEmpty()) { + val downloadableChildren = downloadingBlocks.flatMap { getDownloadableChildren(it).orEmpty() } + if (config.getCourseUIConfig().isCourseDownloadQueueEnabled) { + courseRouter.navigateToDownloadQueue(fragmentManager, downloadableChildren) + } else { + downloadableChildren.forEach { + if (!isBlockDownloaded(it)) { + removeBlockDownloadModel(it) + } + } + } } else { - saveDownloadModels( - FileUtil(context).getExternalAppDir().path, blockId + downloadDialogManager.showPopup( + subSectionsBlocks = requiredSubSections, + courseId = courseId, + isBlocksDownloaded = isAllBlocksDownloaded, + onlyVideoBlocks = true, + fragmentManager = fragmentManager, + removeDownloadModels = ::removeDownloadModels, + saveDownloadModels = { blockId -> + saveDownloadModels(fileUtil.getExternalAppDir().path, blockId) + } ) } } diff --git a/course/src/main/java/org/openedx/course/settings/download/DownloadQueueFragment.kt b/course/src/main/java/org/openedx/course/settings/download/DownloadQueueFragment.kt index 5e50ecf39..ea2b40e4b 100644 --- a/course/src/main/java/org/openedx/course/settings/download/DownloadQueueFragment.kt +++ b/course/src/main/java/org/openedx/course/settings/download/DownloadQueueFragment.kt @@ -223,6 +223,7 @@ private fun DownloadQueueScreenPreview() { uiState = DownloadQueueUIState.Models( listOf( DownloadModel( + courseId = "", id = "", title = "1", size = 0, @@ -230,9 +231,9 @@ private fun DownloadQueueScreenPreview() { url = "", type = FileType.VIDEO, downloadedState = DownloadedState.DOWNLOADING, - progress = 0f ), DownloadModel( + courseId = "", id = "", title = "2", size = 0, @@ -240,7 +241,6 @@ private fun DownloadQueueScreenPreview() { url = "", type = FileType.VIDEO, downloadedState = DownloadedState.DOWNLOADING, - progress = 0f ) ), currentProgressId = "", diff --git a/course/src/main/java/org/openedx/course/settings/download/DownloadQueueViewModel.kt b/course/src/main/java/org/openedx/course/settings/download/DownloadQueueViewModel.kt index 3b9f3d1aa..1c74e3b80 100644 --- a/course/src/main/java/org/openedx/course/settings/download/DownloadQueueViewModel.kt +++ b/course/src/main/java/org/openedx/course/settings/download/DownloadQueueViewModel.kt @@ -8,6 +8,7 @@ import org.openedx.core.data.storage.CorePreferences import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.download.BaseDownloadViewModel +import org.openedx.core.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.system.notifier.DownloadNotifier import org.openedx.core.system.notifier.DownloadProgressChanged @@ -19,7 +20,15 @@ class DownloadQueueViewModel( private val workerController: DownloadWorkerController, private val downloadNotifier: DownloadNotifier, coreAnalytics: CoreAnalytics, -) : BaseDownloadViewModel("", downloadDao, preferencesManager, workerController, coreAnalytics) { + downloadHelper: DownloadHelper, +) : BaseDownloadViewModel( + "", + downloadDao, + preferencesManager, + workerController, + coreAnalytics, + downloadHelper +) { private val _uiState = MutableStateFlow(DownloadQueueUIState.Loading) val uiState = _uiState.asStateFlow() diff --git a/course/src/main/java/org/openedx/course/worker/OfflineProgressSyncScheduler.kt b/course/src/main/java/org/openedx/course/worker/OfflineProgressSyncScheduler.kt new file mode 100644 index 000000000..667772d33 --- /dev/null +++ b/course/src/main/java/org/openedx/course/worker/OfflineProgressSyncScheduler.kt @@ -0,0 +1,35 @@ +package org.openedx.course.worker + +import android.content.Context +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import java.util.concurrent.TimeUnit + +class OfflineProgressSyncScheduler(private val context: Context) { + + fun scheduleSync() { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.UNMETERED) + .build() + + val workRequest = OneTimeWorkRequestBuilder() + .addTag(OfflineProgressSyncWorker.WORKER_TAG) + .setConstraints(constraints) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + 1, + TimeUnit.HOURS + ) + .build() + + WorkManager.getInstance(context).enqueueUniqueWork( + OfflineProgressSyncWorker.WORKER_TAG, + ExistingWorkPolicy.REPLACE, + workRequest + ) + } +} diff --git a/course/src/main/java/org/openedx/course/worker/OfflineProgressSyncWorker.kt b/course/src/main/java/org/openedx/course/worker/OfflineProgressSyncWorker.kt new file mode 100644 index 000000000..d41e9909e --- /dev/null +++ b/course/src/main/java/org/openedx/course/worker/OfflineProgressSyncWorker.kt @@ -0,0 +1,82 @@ +package org.openedx.course.worker + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.content.pm.ServiceInfo +import android.os.Build +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.core.app.NotificationCompat +import androidx.work.CoroutineWorker +import androidx.work.ForegroundInfo +import androidx.work.WorkerParameters +import com.google.firebase.crashlytics.ktx.crashlytics +import com.google.firebase.ktx.Firebase +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.openedx.core.R +import org.openedx.course.domain.interactor.CourseInteractor + +class OfflineProgressSyncWorker( + private val context: Context, + workerParams: WorkerParameters +) : CoroutineWorker(context, workerParams), KoinComponent { + + private val courseInteractor: CourseInteractor by inject() + + private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + private val notificationBuilder = NotificationCompat.Builder(context, NOTIFICATION_CHANEL_ID) + + override suspend fun doWork(): Result { + return try { + setForeground(createForegroundInfo()) + tryToSyncProgress() + Result.success() + } catch (e: Exception) { + Log.e(WORKER_TAG, "$e") + Firebase.crashlytics.log("$e") + Result.failure() + } + } + + private fun createForegroundInfo(): ForegroundInfo { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + createChannel() + } + val serviceType = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC else 0 + + return ForegroundInfo( + NOTIFICATION_ID, + notificationBuilder + .setSmallIcon(R.drawable.core_ic_offline) + .setContentText(context.getString(R.string.core_title_syncing_calendar)) + .setContentTitle("") + .build(), + serviceType + ) + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun createChannel() { + val notificationChannel = + NotificationChannel( + NOTIFICATION_CHANEL_ID, + context.getString(R.string.core_offline_progress_sync), + NotificationManager.IMPORTANCE_LOW + ) + notificationManager.createNotificationChannel(notificationChannel) + } + + private suspend fun tryToSyncProgress() { + courseInteractor.submitAllOfflineXBlockProgress() + } + + companion object { + const val WORKER_TAG = "progress_sync_worker_tag" + const val NOTIFICATION_ID = 5678 + const val NOTIFICATION_CHANEL_ID = "progress_sync_channel" + } +} diff --git a/course/src/main/res/drawable/course_ic_error.xml b/course/src/main/res/drawable/course_ic_error.xml new file mode 100644 index 000000000..4454ecf7c --- /dev/null +++ b/course/src/main/res/drawable/course_ic_error.xml @@ -0,0 +1,9 @@ + + + diff --git a/course/src/main/res/drawable/course_ic_remove_download.xml b/course/src/main/res/drawable/course_ic_remove_download.xml deleted file mode 100644 index 6fa45832e..000000000 --- a/course/src/main/res/drawable/course_ic_remove_download.xml +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - diff --git a/course/src/main/res/drawable/course_ic_start_download.xml b/course/src/main/res/drawable/course_ic_start_download.xml deleted file mode 100644 index 67d565694..000000000 --- a/course/src/main/res/drawable/course_ic_start_download.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index 51ac39e95..8be55b9d4 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -27,8 +27,8 @@ This course hasn’t started yet. You are not connected to the Internet. Please check your Internet connection. You can download content only from Wi-fi - This interactive component isn\'t available on mobile. - Explore other parts of this course or view this on web. + This interactive component isn’t yet available + Explore other parts of this course or view this on web. Open in browser Subtitles Continue with: @@ -46,6 +46,7 @@ Discussions More Dates + Downloads Video player @@ -63,6 +64,36 @@ Are you sure you want to delete all video(s) for \"%s\"? Are you sure you want to delete video(s) for \"%s\"? %1$s - %2$s - %3$d / %4$d + Downloading this content requires an active internet connection. Please connect to the internet and try again. + Wi-Fi Required + Downloading this content requires an active WiFi connection. Please connect to a WiFi network and try again. + Download Failed + Unfortunately, this content failed to download. Please try again later or report this issue. + Downloading this %1$s of content will save available blocks offline. + Download on Cellular? + Downloading this content will use %1$s of cellular data. + Remove Offline Content? + Removing this content will free up %1$s. + Download + Remove + Device Storage Full + Your device does not have enough free space to download this content. Please free up some space and try again. + %1$s used, %2$s free + 0MB + Available to download + None of this course’s content is currently available to download offline. + Download all + Downloaded + Ready to Download + You can download course content offline to learn on the go, without requiring an active internet connection or using mobile data. + Downloading + Largest Downloads + Remove all downloads + Cancel Course Download + This component is not yet available offline + Explore other parts of this course or view this when you reconnect. + This component is not downloaded + Explore other parts of this course or download this when you reconnect. %1$s of %2$s assignment complete diff --git a/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt index ca0c18c79..2fb055011 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 @@ -176,7 +176,7 @@ class CourseDatesViewModelTest { coVerify(exactly = 1) { interactor.getCourseDates(any()) } Assert.assertEquals(noInternet, message.await()?.message) - assert(viewModel.uiState.value is DatesUIState.Loading) + assert(viewModel.uiState.value is CourseDatesUIState.Loading) } @Test @@ -205,7 +205,7 @@ class CourseDatesViewModelTest { coVerify(exactly = 1) { interactor.getCourseDates(any()) } Assert.assertEquals(somethingWrong, message.await()?.message) - assert(viewModel.uiState.value is DatesUIState.Loading) + assert(viewModel.uiState.value is CourseDatesUIState.Loading) } @Test @@ -234,7 +234,7 @@ class CourseDatesViewModelTest { coVerify(exactly = 1) { interactor.getCourseDates(any()) } assert(message.await()?.message.isNullOrEmpty()) - assert(viewModel.uiState.value is DatesUIState.Dates) + assert(viewModel.uiState.value is CourseDatesUIState.CourseDates) } @Test @@ -266,6 +266,6 @@ class CourseDatesViewModelTest { coVerify(exactly = 1) { interactor.getCourseDates(any()) } assert(message.await()?.message.isNullOrEmpty()) - assert(viewModel.uiState.value is DatesUIState.Empty) + assert(viewModel.uiState.value is CourseDatesUIState.Empty) } } 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 aad650b28..15901d1b3 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 @@ -49,15 +49,18 @@ import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadModelEntity import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.db.FileType +import org.openedx.core.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.presentation.CoreAnalyticsEvent import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseStructureUpdated +import org.openedx.core.utils.FileUtil import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseRouter +import org.openedx.course.presentation.download.DownloadDialogManager import java.net.UnknownHostException import java.util.Date @@ -80,6 +83,9 @@ class CourseOutlineViewModelTest { private val analytics = mockk() private val coreAnalytics = mockk() private val courseRouter = mockk() + private val fileUtil = mockk() + private val downloadDialogManager = mockk() + private val downloadHelper = mockk() private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" @@ -108,7 +114,8 @@ class CourseOutlineViewModelTest { descendantsType = BlockType.HTML, completion = 0.0, assignmentProgress = assignmentProgress, - due = Date() + due = Date(), + offlineDownload = null, ), Block( id = "id1", @@ -126,7 +133,8 @@ class CourseOutlineViewModelTest { descendantsType = BlockType.HTML, completion = 0.0, assignmentProgress = assignmentProgress, - due = Date() + due = Date(), + offlineDownload = null, ), Block( id = "id2", @@ -144,7 +152,8 @@ class CourseOutlineViewModelTest { descendantsType = BlockType.HTML, completion = 0.0, assignmentProgress = assignmentProgress, - due = Date() + due = Date(), + offlineDownload = null, ) ) @@ -208,6 +217,7 @@ class CourseOutlineViewModelTest { private val downloadModel = DownloadModel( "id", "title", + "", 0, "", "url", @@ -223,6 +233,7 @@ class CourseOutlineViewModelTest { every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong every { resourceManager.getString(org.openedx.course.R.string.course_can_download_only_with_wifi) } returns cantDownload every { config.getApiHostURL() } returns "http://localhost:8000" + every { downloadDialogManager.showDownloadFailedPopup(any(), any()) } returns Unit coEvery { interactor.getCourseDates(any()) } returns mockedCourseDatesResult } @@ -236,7 +247,8 @@ class CourseOutlineViewModelTest { fun `getCourseDataInternal no internet connection exception`() = runTest(UnconfinedTestDispatcher()) { coEvery { interactor.getCourseStructure(any()) } returns courseStructure every { networkConnection.isOnline() } returns true - every { downloadDao.readAllData() } returns flow { emit(emptyList()) } + every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } + every { downloadDialogManager.showPopup(any(), any(), any(), any(), any(), any(), any()) } returns Unit coEvery { interactor.getCourseStatus(any()) } throws UnknownHostException() val viewModel = CourseOutlineViewModel( @@ -249,10 +261,13 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + downloadDialogManager, + fileUtil, courseRouter, coreAnalytics, downloadDao, workerController, + downloadHelper, ) val message = async { @@ -272,7 +287,7 @@ class CourseOutlineViewModelTest { fun `getCourseDataInternal unknown exception`() = runTest(UnconfinedTestDispatcher()) { coEvery { interactor.getCourseStructure(any()) } returns courseStructure every { networkConnection.isOnline() } returns true - every { downloadDao.readAllData() } returns flow { emit(emptyList()) } + every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } coEvery { interactor.getCourseStatus(any()) } throws Exception() val viewModel = CourseOutlineViewModel( "", @@ -284,10 +299,13 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + downloadDialogManager, + fileUtil, courseRouter, coreAnalytics, downloadDao, - workerController + workerController, + downloadHelper, ) val message = async { @@ -307,7 +325,7 @@ class CourseOutlineViewModelTest { fun `getCourseDataInternal success with internet connection`() = runTest(UnconfinedTestDispatcher()) { coEvery { interactor.getCourseStructure(any()) } returns courseStructure every { networkConnection.isOnline() } returns true - coEvery { downloadDao.readAllData() } returns flow { + coEvery { downloadDao.getAllDataFlow() } returns flow { emit( listOf( DownloadModelEntity.createFrom( @@ -329,10 +347,13 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + downloadDialogManager, + fileUtil, courseRouter, coreAnalytics, downloadDao, - workerController + workerController, + downloadHelper, ) val message = async { @@ -355,7 +376,7 @@ class CourseOutlineViewModelTest { fun `getCourseDataInternal success without internet connection`() = runTest(UnconfinedTestDispatcher()) { coEvery { interactor.getCourseStructure(any()) } returns courseStructure every { networkConnection.isOnline() } returns false - coEvery { downloadDao.readAllData() } returns flow { + coEvery { downloadDao.getAllDataFlow() } returns flow { emit( listOf( DownloadModelEntity.createFrom( @@ -377,10 +398,13 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + downloadDialogManager, + fileUtil, courseRouter, coreAnalytics, downloadDao, - workerController + workerController, + downloadHelper, ) val message = async { @@ -402,7 +426,7 @@ class CourseOutlineViewModelTest { fun `updateCourseData success with internet connection`() = runTest(UnconfinedTestDispatcher()) { coEvery { interactor.getCourseStructure(any()) } returns courseStructure every { networkConnection.isOnline() } returns true - coEvery { downloadDao.readAllData() } returns flow { + coEvery { downloadDao.getAllDataFlow() } returns flow { emit( listOf( DownloadModelEntity.createFrom( @@ -424,10 +448,13 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + downloadDialogManager, + fileUtil, courseRouter, coreAnalytics, downloadDao, - workerController + workerController, + downloadHelper, ) val message = async { @@ -448,7 +475,7 @@ class CourseOutlineViewModelTest { @Test fun `CourseStructureUpdated notifier test`() = runTest(UnconfinedTestDispatcher()) { - coEvery { downloadDao.readAllData() } returns flow { emit(emptyList()) } + coEvery { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } val viewModel = CourseOutlineViewModel( "", "", @@ -459,10 +486,13 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + downloadDialogManager, + fileUtil, courseRouter, coreAnalytics, downloadDao, - workerController + workerController, + downloadHelper, ) coEvery { notifier.notifier } returns flow { emit(CourseStructureUpdated("")) } coEvery { interactor.getCourseStructure(any()) } returns courseStructure @@ -495,7 +525,7 @@ class CourseOutlineViewModelTest { } returns Unit coEvery { workerController.saveModels(any()) } returns Unit coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") - coEvery { downloadDao.readAllData() } returns flow { emit(emptyList()) } + coEvery { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false val viewModel = CourseOutlineViewModel( @@ -508,10 +538,13 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + downloadDialogManager, + fileUtil, courseRouter, coreAnalytics, downloadDao, - workerController + workerController, + downloadHelper, ) val message = async { withTimeoutOrNull(5000) { @@ -538,7 +571,7 @@ class CourseOutlineViewModelTest { every { networkConnection.isWifiConnected() } returns true every { networkConnection.isOnline() } returns true coEvery { workerController.saveModels(any()) } returns Unit - coEvery { downloadDao.readAllData() } returns flow { emit(emptyList()) } + coEvery { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false every { coreAnalytics.logEvent(any(), any()) } returns Unit @@ -552,46 +585,13 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + downloadDialogManager, + fileUtil, courseRouter, coreAnalytics, downloadDao, - workerController - ) - val message = async { - withTimeoutOrNull(5000) { - viewModel.uiMessage.first() as? UIMessage.SnackBarMessage - } - } - viewModel.saveDownloadModels("", "") - advanceUntilIdle() - - assert(message.await()?.message.isNullOrEmpty()) - } - - @Test - fun `saveDownloadModels only wifi download, without connection`() = runTest(UnconfinedTestDispatcher()) { - coEvery { interactor.getCourseStructure(any()) } returns courseStructure - every { preferencesManager.videoSettings.wifiDownloadOnly } returns true - every { networkConnection.isWifiConnected() } returns false - every { networkConnection.isOnline() } returns false - coEvery { workerController.saveModels(any()) } returns Unit - coEvery { downloadDao.readAllData() } returns flow { emit(emptyList()) } - every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false - - val viewModel = CourseOutlineViewModel( - "", - "", - config, - interactor, - resourceManager, - notifier, - networkConnection, - preferencesManager, - analytics, - courseRouter, - coreAnalytics, - downloadDao, - workerController + workerController, + downloadHelper, ) val message = async { withTimeoutOrNull(5000) { @@ -599,7 +599,6 @@ class CourseOutlineViewModelTest { } } viewModel.saveDownloadModels("", "") - advanceUntilIdle() assert(message.await()?.message.isNullOrEmpty()) diff --git a/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt index 01c685c48..45ff2a72f 100644 --- a/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt @@ -38,6 +38,7 @@ import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadModelEntity import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.db.FileType +import org.openedx.core.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.system.ResourceManager @@ -65,6 +66,7 @@ class CourseSectionViewModelTest { private val notifier = mockk() private val analytics = mockk() private val coreAnalytics = mockk() + private val downloadHelper = mockk() private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" @@ -93,7 +95,8 @@ class CourseSectionViewModelTest { descendantsType = BlockType.HTML, completion = 0.0, assignmentProgress = assignmentProgress, - due = Date() + due = Date(), + offlineDownload = null, ), Block( id = "id1", @@ -111,7 +114,8 @@ class CourseSectionViewModelTest { descendantsType = BlockType.HTML, completion = 0.0, assignmentProgress = assignmentProgress, - due = Date() + due = Date(), + offlineDownload = null, ), Block( id = "id2", @@ -129,7 +133,8 @@ class CourseSectionViewModelTest { descendantsType = BlockType.HTML, completion = 0.0, assignmentProgress = assignmentProgress, - due = Date() + due = Date(), + offlineDownload = null, ) ) @@ -161,6 +166,7 @@ class CourseSectionViewModelTest { private val downloadModel = DownloadModel( "id", "title", + "", 0, "", "url", @@ -184,18 +190,13 @@ class CourseSectionViewModelTest { @Test fun `getBlocks no internet connection exception`() = runTest { - every { downloadDao.readAllData() } returns flow { emit(emptyList()) } + every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } val viewModel = CourseSectionViewModel( "", interactor, resourceManager, - networkConnection, - preferencesManager, notifier, analytics, - coreAnalytics, - workerController, - downloadDao, ) coEvery { interactor.getCourseStructure(any()) } throws UnknownHostException() @@ -214,18 +215,13 @@ class CourseSectionViewModelTest { @Test fun `getBlocks unknown exception`() = runTest { - every { downloadDao.readAllData() } returns flow { emit(emptyList()) } + every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } val viewModel = CourseSectionViewModel( "", interactor, resourceManager, - networkConnection, - preferencesManager, notifier, analytics, - coreAnalytics, - workerController, - downloadDao, ) coEvery { interactor.getCourseStructure(any()) } throws Exception() @@ -244,23 +240,18 @@ class CourseSectionViewModelTest { @Test fun `getBlocks success`() = runTest { - coEvery { downloadDao.readAllData() } returns flow { + coEvery { downloadDao.getAllDataFlow() } returns flow { emit(listOf(DownloadModelEntity.createFrom(downloadModel))) } val viewModel = CourseSectionViewModel( "", interactor, resourceManager, - networkConnection, - preferencesManager, notifier, analytics, - coreAnalytics, - workerController, - downloadDao, ) - coEvery { downloadDao.readAllData() } returns flow { + coEvery { downloadDao.getAllDataFlow() } returns flow { emit(listOf(DownloadModelEntity.createFrom(downloadModel))) } coEvery { interactor.getCourseStructure(any()) } returns courseStructure @@ -278,27 +269,21 @@ class CourseSectionViewModelTest { @Test fun `saveDownloadModels test`() = runTest { - coEvery { downloadDao.readAllData() } returns flow { + coEvery { downloadDao.getAllDataFlow() } returns flow { emit(listOf(DownloadModelEntity.createFrom(downloadModel))) } val viewModel = CourseSectionViewModel( "", interactor, resourceManager, - networkConnection, - preferencesManager, notifier, analytics, - coreAnalytics, - workerController, - downloadDao, ) every { preferencesManager.videoSettings.wifiDownloadOnly } returns false every { networkConnection.isWifiConnected() } returns true coEvery { workerController.saveModels(any()) } returns Unit every { coreAnalytics.logEvent(any(), any()) } returns Unit - viewModel.saveDownloadModels("", "") advanceUntilIdle() assert(viewModel.uiMessage.value == null) @@ -306,63 +291,29 @@ class CourseSectionViewModelTest { @Test fun `saveDownloadModels only wifi download, with connection`() = runTest { - coEvery { downloadDao.readAllData() } returns flow { + coEvery { downloadDao.getAllDataFlow() } returns flow { emit(listOf(DownloadModelEntity.createFrom(downloadModel))) } val viewModel = CourseSectionViewModel( "", interactor, resourceManager, - networkConnection, - preferencesManager, notifier, analytics, - coreAnalytics, - workerController, - downloadDao, ) every { preferencesManager.videoSettings.wifiDownloadOnly } returns true every { networkConnection.isWifiConnected() } returns true coEvery { workerController.saveModels(any()) } returns Unit every { coreAnalytics.logEvent(any(), any()) } returns Unit - viewModel.saveDownloadModels("", "") advanceUntilIdle() assert(viewModel.uiMessage.value == null) } - @Test - fun `saveDownloadModels only wifi download, without connection`() = runTest { - every { downloadDao.readAllData() } returns flow { emit(emptyList()) } - val viewModel = CourseSectionViewModel( - "", - interactor, - resourceManager, - networkConnection, - preferencesManager, - notifier, - analytics, - coreAnalytics, - workerController, - downloadDao, - ) - every { preferencesManager.videoSettings.wifiDownloadOnly } returns true - every { networkConnection.isWifiConnected() } returns false - every { networkConnection.isOnline() } returns false - coEvery { workerController.saveModels(any()) } returns Unit - - viewModel.saveDownloadModels("", "") - - advanceUntilIdle() - - assert(viewModel.uiMessage.value != null) - } - - @Test fun `updateVideos success`() = runTest { - every { downloadDao.readAllData() } returns flow { + every { downloadDao.getAllDataFlow() } returns flow { repeat(5) { delay(10000) emit(emptyList()) @@ -372,13 +323,8 @@ class CourseSectionViewModelTest { "", interactor, resourceManager, - networkConnection, - preferencesManager, notifier, analytics, - coreAnalytics, - workerController, - downloadDao, ) coEvery { notifier.notifier } returns flow { } @@ -394,7 +340,6 @@ class CourseSectionViewModelTest { advanceUntilIdle() assert(viewModel.uiState.value is CourseSectionUIState.Blocks) - } } diff --git a/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt index 166d7751e..a63cbddf7 100644 --- a/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt @@ -26,6 +26,7 @@ import org.openedx.core.domain.model.BlockCounts import org.openedx.core.domain.model.CourseStructure import org.openedx.core.domain.model.CoursewareAccess import org.openedx.core.presentation.course.CourseViewMode +import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics @@ -44,6 +45,7 @@ class CourseUnitContainerViewModelTest { private val interactor = mockk() private val notifier = mockk() private val analytics = mockk() + private val networkConnection = mockk() private val assignmentProgress = AssignmentProgress( assignmentType = "Homework", @@ -68,7 +70,8 @@ class CourseUnitContainerViewModelTest { descendantsType = BlockType.HTML, completion = 0.0, assignmentProgress = assignmentProgress, - due = Date() + due = Date(), + offlineDownload = null, ), Block( id = "id1", @@ -86,7 +89,8 @@ class CourseUnitContainerViewModelTest { descendantsType = BlockType.HTML, completion = 0.0, assignmentProgress = assignmentProgress, - due = Date() + due = Date(), + offlineDownload = null, ), Block( id = "id2", @@ -104,7 +108,8 @@ class CourseUnitContainerViewModelTest { descendantsType = BlockType.HTML, completion = 0.0, assignmentProgress = assignmentProgress, - due = Date() + due = Date(), + offlineDownload = null, ), Block( id = "id3", @@ -122,7 +127,8 @@ class CourseUnitContainerViewModelTest { descendantsType = BlockType.HTML, completion = 0.0, assignmentProgress = assignmentProgress, - due = Date() + due = Date(), + offlineDownload = null, ) ) @@ -166,7 +172,7 @@ class CourseUnitContainerViewModelTest { fun `getBlocks no internet connection exception`() = runTest { every { notifier.notifier } returns MutableSharedFlow() val viewModel = - CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics) + CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics, networkConnection) coEvery { interactor.getCourseStructure(any()) } throws UnknownHostException() coEvery { interactor.getCourseStructureForVideos(any()) } throws UnknownHostException() @@ -181,7 +187,7 @@ class CourseUnitContainerViewModelTest { fun `getBlocks unknown exception`() = runTest { every { notifier.notifier } returns MutableSharedFlow() val viewModel = - CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics) + CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics, networkConnection) coEvery { interactor.getCourseStructure(any()) } throws UnknownHostException() coEvery { interactor.getCourseStructureForVideos(any()) } throws UnknownHostException() @@ -196,7 +202,7 @@ class CourseUnitContainerViewModelTest { fun `getBlocks unknown success`() = runTest { every { notifier.notifier } returns MutableSharedFlow() val viewModel = - CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics) + CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics, networkConnection) coEvery { interactor.getCourseStructure(any()) } returns courseStructure coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure @@ -213,7 +219,7 @@ class CourseUnitContainerViewModelTest { fun setupCurrentIndex() = runTest { every { notifier.notifier } returns MutableSharedFlow() val viewModel = - CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics) + CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics, networkConnection) coEvery { interactor.getCourseStructure(any()) } returns courseStructure coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure @@ -228,7 +234,7 @@ class CourseUnitContainerViewModelTest { fun `getCurrentBlock test`() = runTest { every { notifier.notifier } returns MutableSharedFlow() val viewModel = - CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics) + CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics, networkConnection) coEvery { interactor.getCourseStructure(any()) } returns courseStructure coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure @@ -245,7 +251,7 @@ class CourseUnitContainerViewModelTest { fun `moveToPrevBlock null`() = runTest { every { notifier.notifier } returns MutableSharedFlow() val viewModel = - CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics) + CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics, networkConnection) coEvery { interactor.getCourseStructure(any()) } returns courseStructure coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure @@ -262,7 +268,7 @@ class CourseUnitContainerViewModelTest { fun `moveToPrevBlock not null`() = runTest { every { notifier.notifier } returns MutableSharedFlow() val viewModel = - CourseUnitContainerViewModel("", "id", config, interactor, notifier, analytics) + CourseUnitContainerViewModel("", "id", config, interactor, notifier, analytics, networkConnection) coEvery { interactor.getCourseStructure(any()) } returns courseStructure coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure @@ -279,7 +285,7 @@ class CourseUnitContainerViewModelTest { fun `moveToNextBlock null`() = runTest { every { notifier.notifier } returns MutableSharedFlow() val viewModel = - CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics) + CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics, networkConnection) coEvery { interactor.getCourseStructure(any()) } returns courseStructure coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure @@ -296,7 +302,7 @@ class CourseUnitContainerViewModelTest { fun `moveToNextBlock not null`() = runTest { every { notifier.notifier } returns MutableSharedFlow() val viewModel = - CourseUnitContainerViewModel("", "id", config, interactor, notifier, analytics) + CourseUnitContainerViewModel("", "id", config, interactor, notifier, analytics, networkConnection) coEvery { interactor.getCourseStructure("") } returns courseStructure coEvery { interactor.getCourseStructureForVideos("") } returns courseStructure @@ -313,7 +319,7 @@ class CourseUnitContainerViewModelTest { fun `currentIndex isLastIndex`() = runTest { every { notifier.notifier } returns MutableSharedFlow() val viewModel = - CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics) + CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics, networkConnection) coEvery { interactor.getCourseStructure(any()) } returns courseStructure coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure 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 9bb8d0f5f..b8a4d543c 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 @@ -44,6 +44,7 @@ import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadModelEntity import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.db.FileType +import org.openedx.core.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection @@ -51,10 +52,12 @@ import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseStructureUpdated import org.openedx.core.system.notifier.VideoNotifier +import org.openedx.core.utils.FileUtil import org.openedx.course.R import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseRouter +import org.openedx.course.presentation.download.DownloadDialogManager import java.util.Date @OptIn(ExperimentalCoroutinesApi::class) @@ -76,6 +79,9 @@ class CourseVideoViewModelTest { private val downloadDao = mockk() private val workerController = mockk() private val courseRouter = mockk() + private val downloadHelper = mockk() + private val downloadDialogManager = mockk() + private val fileUtil = mockk() private val cantDownload = "You can download content only from Wi-fi" @@ -102,7 +108,8 @@ class CourseVideoViewModelTest { descendantsType = BlockType.HTML, completion = 0.0, assignmentProgress = assignmentProgress, - due = Date() + due = Date(), + offlineDownload = null, ), Block( id = "id1", @@ -120,7 +127,8 @@ class CourseVideoViewModelTest { descendantsType = BlockType.HTML, completion = 0.0, assignmentProgress = assignmentProgress, - due = Date() + due = Date(), + offlineDownload = null, ), Block( id = "id2", @@ -138,7 +146,8 @@ class CourseVideoViewModelTest { descendantsType = BlockType.HTML, completion = 0.0, assignmentProgress = assignmentProgress, - due = Date() + due = Date(), + offlineDownload = null, ) ) @@ -168,11 +177,12 @@ class CourseVideoViewModelTest { ) private val downloadModelEntity = - DownloadModelEntity("", "", 1, "", "", "VIDEO", "DOWNLOADED", null) + DownloadModelEntity("", "", "", 1, "", "", "VIDEO", "DOWNLOADED", null) private val downloadModel = DownloadModel( "id", "title", + "", 0, "", "url", @@ -188,6 +198,7 @@ class CourseVideoViewModelTest { Dispatchers.setMain(dispatcher) every { config.getApiHostURL() } returns "http://localhost:8000" every { courseNotifier.notifier } returns flowOf(CourseLoading(false)) + every { downloadDialogManager.showPopup(any(), any(), any(), any(), any(), any(), any()) } returns Unit } @After @@ -200,7 +211,7 @@ class CourseVideoViewModelTest { every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure.copy(blockData = emptyList()) - every { downloadDao.readAllData() } returns flow { emit(emptyList()) } + every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( "", @@ -213,10 +224,13 @@ class CourseVideoViewModelTest { courseNotifier, videoNotifier, analytics, + downloadDialogManager, + fileUtil, courseRouter, coreAnalytics, downloadDao, workerController, + downloadHelper, ) viewModel.getVideos() @@ -231,7 +245,7 @@ class CourseVideoViewModelTest { fun `getVideos success`() = runTest { every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - every { downloadDao.readAllData() } returns flow { emit(emptyList()) } + every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( @@ -245,10 +259,13 @@ class CourseVideoViewModelTest { courseNotifier, videoNotifier, analytics, + downloadDialogManager, + fileUtil, courseRouter, coreAnalytics, downloadDao, - workerController + workerController, + downloadHelper, ) @@ -267,7 +284,7 @@ class CourseVideoViewModelTest { coEvery { courseNotifier.notifier } returns flow { emit(CourseStructureUpdated("")) } - every { downloadDao.readAllData() } returns flow { + every { downloadDao.getAllDataFlow() } returns flow { repeat(5) { delay(10000) emit(emptyList()) @@ -285,10 +302,13 @@ class CourseVideoViewModelTest { courseNotifier, videoNotifier, analytics, + downloadDialogManager, + fileUtil, courseRouter, coreAnalytics, downloadDao, - workerController + workerController, + downloadHelper, ) val mockLifeCycleOwner: LifecycleOwner = mockk() @@ -308,7 +328,7 @@ class CourseVideoViewModelTest { every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false every { preferencesManager.videoSettings } returns VideoSettings.default coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - coEvery { downloadDao.readAllData() } returns flow { emit(listOf(downloadModelEntity)) } + coEvery { downloadDao.getAllDataFlow() } returns flow { emit(listOf(downloadModelEntity)) } advanceUntilIdle() } @@ -327,13 +347,16 @@ class CourseVideoViewModelTest { courseNotifier, videoNotifier, analytics, + downloadDialogManager, + fileUtil, courseRouter, coreAnalytics, downloadDao, - workerController + workerController, + downloadHelper, ) coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - coEvery { downloadDao.readAllData() } returns flow { emit(listOf(downloadModelEntity)) } + coEvery { downloadDao.getAllDataFlow() } returns flow { emit(listOf(downloadModelEntity)) } every { preferencesManager.videoSettings.wifiDownloadOnly } returns false every { networkConnection.isWifiConnected() } returns true coEvery { workerController.saveModels(any()) } returns Unit @@ -364,17 +387,20 @@ class CourseVideoViewModelTest { courseNotifier, videoNotifier, analytics, + downloadDialogManager, + fileUtil, courseRouter, coreAnalytics, downloadDao, - workerController + workerController, + downloadHelper, ) coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - coEvery { downloadDao.readAllData() } returns flow { emit(listOf(downloadModelEntity)) } + coEvery { downloadDao.getAllDataFlow() } returns flow { emit(listOf(downloadModelEntity)) } every { preferencesManager.videoSettings.wifiDownloadOnly } returns true every { networkConnection.isWifiConnected() } returns true coEvery { workerController.saveModels(any()) } returns Unit - coEvery { downloadDao.readAllData() } returns flow { + coEvery { downloadDao.getAllDataFlow() } returns flow { emit(listOf(DownloadModelEntity.createFrom(downloadModel))) } every { coreAnalytics.logEvent(any(), any()) } returns Unit @@ -405,16 +431,19 @@ class CourseVideoViewModelTest { courseNotifier, videoNotifier, analytics, + downloadDialogManager, + fileUtil, courseRouter, coreAnalytics, downloadDao, - workerController + workerController, + downloadHelper, ) every { preferencesManager.videoSettings.wifiDownloadOnly } returns true every { networkConnection.isWifiConnected() } returns false every { networkConnection.isOnline() } returns false coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - coEvery { downloadDao.readAllData() } returns flow { emit(listOf(downloadModelEntity)) } + coEvery { downloadDao.getAllDataFlow() } returns flow { emit(listOf(downloadModelEntity)) } coEvery { workerController.saveModels(any()) } returns Unit val message = async { withTimeoutOrNull(5000) { diff --git a/dashboard/build.gradle b/dashboard/build.gradle index c0c3192d0..2fea01174 100644 --- a/dashboard/build.gradle +++ b/dashboard/build.gradle @@ -29,6 +29,7 @@ android { } kotlinOptions { jvmTarget = JavaVersion.VERSION_17 + freeCompilerArgs = List.of("-Xstring-concat=inline") } buildFeatures { diff --git a/dashboard/proguard-rules.pro b/dashboard/proguard-rules.pro index 481bb4348..cdb308aa0 100644 --- a/dashboard/proguard-rules.pro +++ b/dashboard/proguard-rules.pro @@ -1,21 +1,7 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +# Prevent shrinking, optimization, and obfuscation of the library when consumed by other modules. +# This ensures that all classes and methods remain available for use by the consumer of the library. +# Disabling these steps at the library level is important because the main app module will handle +# shrinking, optimization, and obfuscation for the entire application, including this library. +-dontshrink +-dontoptimize +-dontobfuscate diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt index c2668f766..e7e22ba1c 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt @@ -81,6 +81,7 @@ 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.extension.toImageLink import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OfflineModeDialog @@ -418,7 +419,7 @@ fun CourseItem( Column { AsyncImage( model = ImageRequest.Builder(LocalContext.current) - .data(apiHostUrl + course.course.courseImage) + .data(course.course.courseImage.toImageLink(apiHostUrl) ?: "") .error(R.drawable.core_no_image_course) .placeholder(R.drawable.core_no_image_course) .build(), diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/CourseTab.kt b/dashboard/src/main/java/org/openedx/courses/presentation/CourseTab.kt index f0da7c186..f29e0a110 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/CourseTab.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/CourseTab.kt @@ -1,5 +1,5 @@ package org.openedx.courses.presentation enum class CourseTab { - HOME, VIDEOS, DATES, DISCUSSIONS, MORE + HOME, VIDEOS, DATES, OFFLINE, DISCUSSIONS, MORE } diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt index a6d375569..5de4c78c5 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt @@ -83,6 +83,7 @@ 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.extension.toImageLink import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OfflineModeDialog import org.openedx.core.ui.OpenEdXButton @@ -420,7 +421,7 @@ private fun CourseListItem( Column { AsyncImage( model = ImageRequest.Builder(LocalContext.current) - .data(apiHostUrl + course.course.courseImage) + .data(course.course.courseImage.toImageLink(apiHostUrl) ?: "") .error(CoreR.drawable.core_no_image_course) .placeholder(CoreR.drawable.core_no_image_course) .build(), diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt index 2d8e81d6b..579076b96 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt @@ -81,6 +81,7 @@ 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.extension.toImageLink import org.openedx.core.presentation.global.app_upgrade.AppUpgradeRecommendedBox import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.core.ui.HandleUIMessage @@ -373,7 +374,6 @@ private fun CourseItem( ) ) } - val imageUrl = apiHostUrl + enrolledCourse.course.courseImage val context = LocalContext.current Surface( modifier = Modifier @@ -392,7 +392,7 @@ private fun CourseItem( ) { AsyncImage( model = ImageRequest.Builder(LocalContext.current) - .data(imageUrl) + .data(enrolledCourse.course.courseImage.toImageLink(apiHostUrl) ?: "") .error(CoreR.drawable.core_no_image_course) .placeholder(CoreR.drawable.core_no_image_course) .build(), diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index a97d7c351..99f2f0f3d 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -88,3 +88,4 @@ SOCIAL_AUTH_ENABLED: false UI_COMPONENTS: COURSE_DROPDOWN_NAVIGATION_ENABLED: false COURSE_UNIT_PROGRESS_ENABLED: false + COURSE_DOWNLOAD_QUEUE_SCREEN: false diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml index a97d7c351..99f2f0f3d 100644 --- a/default_config/prod/config.yaml +++ b/default_config/prod/config.yaml @@ -88,3 +88,4 @@ SOCIAL_AUTH_ENABLED: false UI_COMPONENTS: COURSE_DROPDOWN_NAVIGATION_ENABLED: false COURSE_UNIT_PROGRESS_ENABLED: false + COURSE_DOWNLOAD_QUEUE_SCREEN: false diff --git a/default_config/stage/config.yaml b/default_config/stage/config.yaml index a97d7c351..99f2f0f3d 100644 --- a/default_config/stage/config.yaml +++ b/default_config/stage/config.yaml @@ -88,3 +88,4 @@ SOCIAL_AUTH_ENABLED: false UI_COMPONENTS: COURSE_DROPDOWN_NAVIGATION_ENABLED: false COURSE_UNIT_PROGRESS_ENABLED: false + COURSE_DOWNLOAD_QUEUE_SCREEN: false diff --git a/discovery/build.gradle b/discovery/build.gradle index 881d8c05a..5e6e1887b 100644 --- a/discovery/build.gradle +++ b/discovery/build.gradle @@ -31,6 +31,7 @@ android { } kotlinOptions { jvmTarget = JavaVersion.VERSION_17 + freeCompilerArgs = List.of("-Xstring-concat=inline") } buildFeatures { diff --git a/discovery/proguard-rules.pro b/discovery/proguard-rules.pro index 481bb4348..cdb308aa0 100644 --- a/discovery/proguard-rules.pro +++ b/discovery/proguard-rules.pro @@ -1,21 +1,7 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +# Prevent shrinking, optimization, and obfuscation of the library when consumed by other modules. +# This ensures that all classes and methods remain available for use by the consumer of the library. +# Disabling these steps at the library level is important because the main app module will handle +# shrinking, optimization, and obfuscation for the entire application, including this library. +-dontshrink +-dontoptimize +-dontobfuscate diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/ui/DiscoveryUI.kt b/discovery/src/main/java/org/openedx/discovery/presentation/ui/DiscoveryUI.kt index 30c2a63d2..5d0f527bb 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/ui/DiscoveryUI.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/ui/DiscoveryUI.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import coil.request.ImageRequest import org.openedx.core.extension.isLinkValid +import org.openedx.core.extension.toImageLink import org.openedx.core.ui.WindowSize import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.theme.OpenEdXTheme @@ -108,7 +109,6 @@ fun DiscoveryCourseItem( ) } - val imageUrl = apiHostUrl + course.media.courseImage?.uri Surface( modifier = Modifier .testTag("btn_course_card") @@ -126,7 +126,7 @@ fun DiscoveryCourseItem( ) { AsyncImage( model = ImageRequest.Builder(LocalContext.current) - .data(imageUrl) + .data(course.media.courseImage?.uri?.toImageLink(apiHostUrl) ?: "") .error(org.openedx.core.R.drawable.core_no_image_course) .placeholder(org.openedx.core.R.drawable.core_no_image_course) .build(), diff --git a/discussion/build.gradle b/discussion/build.gradle index 77d393d7a..70ed3c39f 100644 --- a/discussion/build.gradle +++ b/discussion/build.gradle @@ -28,6 +28,7 @@ android { } kotlinOptions { jvmTarget = JavaVersion.VERSION_17 + freeCompilerArgs = List.of("-Xstring-concat=inline") } buildFeatures { diff --git a/discussion/proguard-rules.pro b/discussion/proguard-rules.pro index 481bb4348..cdb308aa0 100644 --- a/discussion/proguard-rules.pro +++ b/discussion/proguard-rules.pro @@ -1,21 +1,7 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +# Prevent shrinking, optimization, and obfuscation of the library when consumed by other modules. +# This ensures that all classes and methods remain available for use by the consumer of the library. +# Disabling these steps at the library level is important because the main app module will handle +# shrinking, optimization, and obfuscation for the entire application, including this library. +-dontshrink +-dontoptimize +-dontobfuscate diff --git a/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt b/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt index 9fc56f6af..29a38a6a9 100644 --- a/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt +++ b/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt @@ -29,8 +29,6 @@ import org.openedx.core.UIMessage import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts -import org.openedx.core.domain.model.CourseStructure -import org.openedx.core.domain.model.CoursewareAccess import org.openedx.core.system.ResourceManager import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier @@ -80,7 +78,8 @@ class DiscussionTopicsViewModelTest { descendantsType = BlockType.HTML, completion = 0.0, assignmentProgress = assignmentProgress, - due = Date() + due = Date(), + offlineDownload = null, ), Block( id = "id1", @@ -98,7 +97,8 @@ class DiscussionTopicsViewModelTest { descendantsType = BlockType.HTML, completion = 0.0, assignmentProgress = assignmentProgress, - due = Date() + due = Date(), + offlineDownload = null, ), Block( id = "id2", @@ -116,33 +116,10 @@ class DiscussionTopicsViewModelTest { descendantsType = BlockType.HTML, completion = 0.0, assignmentProgress = assignmentProgress, - due = Date() + due = Date(), + offlineDownload = null, ) ) - private val courseStructure = CourseStructure( - root = "", - blockData = blocks, - id = "id", - name = "Course name", - number = "", - org = "Org", - start = Date(), - startDisplay = "", - startType = "", - end = Date(), - coursewareAccess = CoursewareAccess( - true, - "", - "", - "", - "", - "" - ), - media = null, - certificate = null, - isSelfPaced = false, - progress = null - ) @Before fun setUp() { diff --git a/gradle.properties b/gradle.properties index cf0008ddc..d0a098a0d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -22,3 +22,4 @@ kotlin.code.style=official # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true android.nonFinalResIds=false +android.enableR8.fullMode=true diff --git a/profile/build.gradle b/profile/build.gradle index 1c3c6f301..2ccd98e63 100644 --- a/profile/build.gradle +++ b/profile/build.gradle @@ -29,6 +29,7 @@ android { } kotlinOptions { jvmTarget = JavaVersion.VERSION_17 + freeCompilerArgs = List.of("-Xstring-concat=inline") } buildFeatures { diff --git a/profile/proguard-rules.pro b/profile/proguard-rules.pro index 481bb4348..cdb308aa0 100644 --- a/profile/proguard-rules.pro +++ b/profile/proguard-rules.pro @@ -1,21 +1,7 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +# Prevent shrinking, optimization, and obfuscation of the library when consumed by other modules. +# This ensures that all classes and methods remain available for use by the consumer of the library. +# Disabling these steps at the library level is important because the main app module will handle +# shrinking, optimization, and obfuscation for the entire application, including this library. +-dontshrink +-dontoptimize +-dontobfuscate diff --git a/settings.gradle b/settings.gradle index 8f539415d..2e2262fff 100644 --- a/settings.gradle +++ b/settings.gradle @@ -14,7 +14,7 @@ pluginManagement { maven { url "https://maven.fullstory.com" } } dependencies { - classpath("com.android.tools:r8:8.2.26") + classpath("com.android.tools:r8:8.3.37") classpath 'com.fullstory:gradle-plugin-local:1.47.0' } } diff --git a/whatsnew/build.gradle b/whatsnew/build.gradle index 4a400063e..cd6778d05 100644 --- a/whatsnew/build.gradle +++ b/whatsnew/build.gradle @@ -30,6 +30,7 @@ android { kotlinOptions { jvmTarget = JavaVersion.VERSION_17 + freeCompilerArgs = List.of("-Xstring-concat=inline") } buildFeatures { diff --git a/whatsnew/proguard-rules.pro b/whatsnew/proguard-rules.pro index 481bb4348..cdb308aa0 100644 --- a/whatsnew/proguard-rules.pro +++ b/whatsnew/proguard-rules.pro @@ -1,21 +1,7 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +# Prevent shrinking, optimization, and obfuscation of the library when consumed by other modules. +# This ensures that all classes and methods remain available for use by the consumer of the library. +# Disabling these steps at the library level is important because the main app module will handle +# shrinking, optimization, and obfuscation for the entire application, including this library. +-dontshrink +-dontoptimize +-dontobfuscate From d282fb08a95bb89b5f277246913246097813202c Mon Sep 17 00:00:00 2001 From: Kirill Izmaylov Date: Mon, 9 Sep 2024 15:37:27 +0300 Subject: [PATCH 32/56] fix: added default value to the AppConfig to prevent crashes in some cases (#372) --- .../org/openedx/app/data/storage/PreferencesManager.kt | 4 +++- .../java/org/openedx/core/domain/model/AppConfig.kt | 10 +++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt b/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt index ae36968d2..efd2d16b2 100644 --- a/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt +++ b/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt @@ -145,10 +145,12 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences saveString(APP_CONFIG, appConfigJson) } get() { - val appConfigString = getString(APP_CONFIG) + val appConfigString = getString(APP_CONFIG, getDefaultAppConfig()) return Gson().fromJson(appConfigString, AppConfig::class.java) } + private fun getDefaultAppConfig() = Gson().toJson(AppConfig()) + override var lastWhatsNewVersion: String set(value) { saveString(LAST_WHATS_NEW_VERSION, value) diff --git a/core/src/main/java/org/openedx/core/domain/model/AppConfig.kt b/core/src/main/java/org/openedx/core/domain/model/AppConfig.kt index 596fd0619..97750957f 100644 --- a/core/src/main/java/org/openedx/core/domain/model/AppConfig.kt +++ b/core/src/main/java/org/openedx/core/domain/model/AppConfig.kt @@ -3,12 +3,12 @@ package org.openedx.core.domain.model import java.io.Serializable data class AppConfig( - val courseDatesCalendarSync: CourseDatesCalendarSync, + val courseDatesCalendarSync: CourseDatesCalendarSync = CourseDatesCalendarSync(), ) : Serializable data class CourseDatesCalendarSync( - val isEnabled: Boolean, - val isSelfPacedEnabled: Boolean, - val isInstructorPacedEnabled: Boolean, - val isDeepLinkEnabled: Boolean, + val isEnabled: Boolean = false, + val isSelfPacedEnabled: Boolean = false, + val isInstructorPacedEnabled: Boolean = false, + val isDeepLinkEnabled: Boolean = false, ) : Serializable From 4c1a90930a7275aa458874bbeab9ce55f327dcd2 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk <141041606+PavloNetrebchuk@users.noreply.github.com> Date: Wed, 11 Sep 2024 12:09:22 +0300 Subject: [PATCH 33/56] feat: [FC-0047] Relative Dates (#367) * feat: relative dates * fix: Fixes according to designer feedback --- .../app/data/storage/PreferencesManager.kt | 7 + .../java/org/openedx/app/di/ScreenModule.kt | 6 +- .../core/data/storage/CorePreferences.kt | 1 + .../core/domain/model/CourseDateBlock.kt | 6 - .../org/openedx/core/extension/StringExt.kt | 2 +- .../java/org/openedx/core/utils/TimeUtils.kt | 190 ++++++------------ core/src/main/res/values/strings.xml | 17 +- .../presentation/dates/CourseDatesScreen.kt | 34 ++-- .../dates/CourseDatesViewModel.kt | 3 + .../outline/CourseOutlineScreen.kt | 7 +- .../outline/CourseOutlineUIState.kt | 1 + .../outline/CourseOutlineViewModel.kt | 3 + .../course/presentation/ui/CourseUI.kt | 9 +- .../course/presentation/ui/CourseVideosUI.kt | 6 +- .../videos/CourseVideoViewModel.kt | 9 +- .../videos/CourseVideosUIState.kt | 3 +- .../dates/CourseDatesViewModelTest.kt | 7 + .../outline/CourseOutlineViewModelTest.kt | 1 + .../videos/CourseVideoViewModelTest.kt | 1 + .../presentation/AllEnrolledCoursesView.kt | 2 +- .../presentation/DashboardGalleryUIState.kt | 2 +- .../presentation/DashboardGalleryView.kt | 15 +- .../presentation/DashboardGalleryViewModel.kt | 14 +- .../presentation/DashboardListFragment.kt | 2 +- .../discovery/presentation/ui/DiscoveryUI.kt | 7 +- .../presentation/calendar/CalendarFragment.kt | 7 + .../calendar/CalendarSetUpView.kt | 9 + .../calendar/CalendarSettingsView.kt | 8 + .../presentation/calendar/CalendarUIState.kt | 3 +- .../presentation/calendar/CalendarView.kt | 73 +++++++ .../calendar/CalendarViewModel.kt | 10 +- profile/src/main/res/values/strings.xml | 1 + 32 files changed, 265 insertions(+), 201 deletions(-) create mode 100644 profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarView.kt diff --git a/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt b/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt index efd2d16b2..1a4974a19 100644 --- a/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt +++ b/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt @@ -195,6 +195,12 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences } get() = getString(CALENDAR_USER) + override var isRelativeDatesEnabled: Boolean + set(value) { + saveBoolean(IS_RELATIVE_DATES_ENABLED, value) + } + get() = getBoolean(IS_RELATIVE_DATES_ENABLED, true) + override var isHideInactiveCourses: Boolean set(value) { saveBoolean(HIDE_INACTIVE_COURSES, value) @@ -225,6 +231,7 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences private const val CALENDAR_ID = "CALENDAR_ID" private const val RESET_APP_DIRECTORY = "reset_app_directory" private const val IS_CALENDAR_SYNC_ENABLED = "IS_CALENDAR_SYNC_ENABLED" + private const val IS_RELATIVE_DATES_ENABLED = "IS_RELATIVE_DATES_ENABLED" private const val HIDE_INACTIVE_COURSES = "HIDE_INACTIVE_COURSES" private const val CALENDAR_USER = "CALENDAR_USER" } 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 541782caf..15ef16498 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -152,6 +152,7 @@ val screenModule = module { get(), get(), get(), + get(), windowSize ) } @@ -204,7 +205,7 @@ val screenModule = module { ) } viewModel { ManageAccountViewModel(get(), get(), get(), get(), get()) } - viewModel { CalendarViewModel(get(), get(), get(), get(), get(), get(), get()) } + viewModel { CalendarViewModel(get(), get(), get(), get(), get(), get(), get(), get()) } viewModel { CoursesToSyncViewModel(get(), get(), get(), get()) } viewModel { NewCalendarDialogViewModel(get(), get(), get(), get(), get(), get()) } viewModel { DisableCalendarSyncDialogViewModel(get(), get(), get(), get()) } @@ -276,7 +277,7 @@ val screenModule = module { get(), get(), get(), - get() + get(), ) } viewModel { (courseId: String) -> @@ -358,6 +359,7 @@ val screenModule = module { get(), get(), get(), + get(), ) } viewModel { (courseId: String, handoutsType: String) -> diff --git a/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt b/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt index 7792fb4a4..5435494ba 100644 --- a/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt +++ b/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt @@ -13,6 +13,7 @@ interface CorePreferences { var videoSettings: VideoSettings var appConfig: AppConfig var canResetAppDirectory: Boolean + var isRelativeDatesEnabled: Boolean fun clearCorePreferences() } 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 97f8612bf..9249d6a23 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 @@ -3,8 +3,6 @@ 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 @@ -29,10 +27,6 @@ data class CourseDateBlock( ) && date.before(Date())) } - fun isTimeDifferenceLessThan24Hours(): Boolean { - return (date.isToday() && date.before(Date())) || date.isTimeLessThan24Hours() - } - override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false diff --git a/core/src/main/java/org/openedx/core/extension/StringExt.kt b/core/src/main/java/org/openedx/core/extension/StringExt.kt index 6d8457fed..d383cf57f 100644 --- a/core/src/main/java/org/openedx/core/extension/StringExt.kt +++ b/core/src/main/java/org/openedx/core/extension/StringExt.kt @@ -42,5 +42,5 @@ fun String.toImageLink(apiHostURL: String): String = if (this.isLinkValid()) { this } else { - apiHostURL + this.removePrefix("/") + (apiHostURL + this).replace(Regex("(? DateUtils.formatDateTime( + context, + date.time, + DateUtils.FORMAT_SHOW_WEEKDAY + ).toString() + + daysDiff == -6 -> context.getString(R.string.core_next) + " " + DateUtils.formatDateTime( + context, + date.time, + DateUtils.FORMAT_SHOW_WEEKDAY + ).toString() + + daysDiff in -1..1 -> DateUtils.getRelativeTimeSpanString( + date.time, + now.timeInMillis, + DateUtils.DAY_IN_MILLIS, + DateUtils.FORMAT_ABBREV_TIME + ).toString() + + daysDiff in 2..6 -> DateUtils.getRelativeTimeSpanString( + date.time, + now.timeInMillis, + DateUtils.DAY_IN_MILLIS + ).toString() + + inputDate.get(Calendar.YEAR) == now.get(Calendar.YEAR) -> { + DateUtils.getRelativeTimeSpanString( + date.time, + now.timeInMillis, + DateUtils.DAY_IN_MILLIS, + DateUtils.FORMAT_SHOW_DATE + ).toString() + } + + else -> { + DateUtils.getRelativeTimeSpanString( + date.time, + now.timeInMillis, + DateUtils.DAY_IN_MILLIS, + DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_YEAR + ).toString() + } + } + } + fun getCurrentTime(): Long { return Calendar.getInstance().timeInMillis } @@ -170,126 +224,6 @@ object TimeUtils { } return formattedDate } - - /** - * Method to get the formatted time string in terms of relative time with minimum resolution of minutes. - * For example, if the time difference is 1 minute, it will return "1m ago". - * - * @param date Date object to be formatted. - */ - fun getFormattedTime(date: Date): String { - return DateUtils.getRelativeTimeSpanString( - date.time, - getCurrentTime(), - DateUtils.MINUTE_IN_MILLIS, - DateUtils.FORMAT_ABBREV_TIME - ).toString() - } - - /** - * Returns a formatted date string for the given date. - */ - fun getCourseFormattedDate(context: Context, date: Date): String { - val inputDate = Calendar.getInstance().also { - it.time = date - it.clearTimeComponents() - } - val daysDifference = getDayDifference(inputDate) - - return when { - daysDifference == 0 -> { - context.getString(R.string.core_date_format_today) - } - - daysDifference == 1 -> { - context.getString(R.string.core_date_format_tomorrow) - } - - daysDifference == -1 -> { - context.getString(R.string.core_date_format_yesterday) - } - - daysDifference in -2 downTo -7 -> { - context.getString( - R.string.core_date_format_days_ago, - ceil(-daysDifference.toDouble()).toInt().toString() - ) - } - - daysDifference in 2..7 -> { - DateUtils.formatDateTime( - context, - date.time, - DateUtils.FORMAT_SHOW_WEEKDAY - ) - } - - inputDate.get(Calendar.YEAR) != Calendar.getInstance().get(Calendar.YEAR) -> { - DateUtils.formatDateTime( - context, - date.time, - DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_YEAR - ) - } - - else -> { - DateUtils.formatDateTime( - context, - date.time, - DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_NO_YEAR - ) - } - } - } - - fun getAssignmentFormattedDate(context: Context, date: Date): String { - val inputDate = Calendar.getInstance().also { - it.time = date - it.clearTimeComponents() - } - val daysDifference = getDayDifference(inputDate) - - return when { - daysDifference == 0 -> { - context.getString(R.string.core_date_format_assignment_due_today) - } - - daysDifference == 1 -> { - context.getString(R.string.core_date_format_assignment_due_tomorrow) - } - - daysDifference == -1 -> { - context.getString(R.string.core_date_format_assignment_due_yesterday) - } - - daysDifference <= -2 -> { - val numberOfDays = ceil(-daysDifference.toDouble()).toInt() - context.resources.getQuantityString( - R.plurals.core_date_format_assignment_due_days_ago, - numberOfDays, - numberOfDays - ) - } - - else -> { - val numberOfDays = ceil(daysDifference.toDouble()).toInt() - context.resources.getQuantityString( - R.plurals.core_date_format_assignment_due_in, - numberOfDays, - numberOfDays - ) - } - } - } - - /** - * Returns the number of days difference between the given date and the current date. - */ - private fun getDayDifference(inputDate: Calendar): Int { - val currentDate = Calendar.getInstance().also { it.clearTimeComponents() } - val difference = inputDate.timeInMillis - currentDate.timeInMillis - return TimeUnit.MILLISECONDS.toDays(difference).toInt() - } } /** @@ -336,16 +270,6 @@ fun Date.clearTime(): Date { return calendar.time } -/** - * Extension function to check if the time difference between the given date and the current date is less than 24 hours. - */ -fun Date.isTimeLessThan24Hours(): Boolean { - val calendar = Calendar.getInstance() - calendar.time = this - val timeInMillis = (calendar.timeInMillis - TimeUtils.getCurrentTime()).unaryPlus() - return timeInMillis < TimeUnit.DAYS.toMillis(1) -} - fun Date.toCalendar(): Calendar { val calendar = Calendar.getInstance() calendar.time = this diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index 00b02502a..0b245c7fa 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -92,21 +92,7 @@ Next Week Upcoming None - Today - Tomorrow - Yesterday - %1$s days ago - Due Today - Due Tomorrow - Due Yesterday - - Due %1$d day ago - Due %1$d days ago - - - Due in %1$d day - Due in %1$d days - + Due %1$s %d Item Hidden %d Items Hidden @@ -193,4 +179,5 @@ To Sync Not Synced Syncing to calendar… + Next 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 b148c8acb..d76eb5eab 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 @@ -74,7 +74,6 @@ import org.openedx.core.presentation.CoreAnalyticsScreen import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.presentation.dialog.alert.ActionDialogFragment import org.openedx.core.presentation.settings.calendarsync.CalendarSyncState -import org.openedx.core.presentation.settings.calendarsync.CalendarSyncUIState import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.WindowSize import org.openedx.core.ui.WindowType @@ -85,11 +84,12 @@ 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.core.utils.TimeUtils.formatToString import org.openedx.core.utils.clearTime import org.openedx.course.R import org.openedx.course.presentation.ui.CourseDatesBanner import org.openedx.course.presentation.ui.CourseDatesBannerTablet -import java.util.concurrent.atomic.AtomicReference +import java.util.Date import org.openedx.core.R as CoreR @Composable @@ -109,6 +109,7 @@ fun CourseDatesScreen( uiState = uiState, uiMessage = uiMessage, isSelfPaced = viewModel.isSelfPaced, + useRelativeDates = viewModel.useRelativeDates, onItemClick = { block -> if (block.blockId.isNotEmpty()) { viewModel.getVerticalBlock(block.blockId) @@ -178,6 +179,7 @@ private fun CourseDatesUI( uiState: CourseDatesUIState, uiMessage: UIMessage?, isSelfPaced: Boolean, + useRelativeDates: Boolean, onItemClick: (CourseDateBlock) -> Unit, onPLSBannerViewed: () -> Unit, onSyncDates: () -> Unit, @@ -311,6 +313,7 @@ private fun CourseDatesUI( sectionKey = DatesSection.COMPLETED, sectionDates = section, onItemClick = onItemClick, + useRelativeDates = useRelativeDates ) } } @@ -325,6 +328,7 @@ private fun CourseDatesUI( sectionKey = sectionKey, sectionDates = section, onItemClick = onItemClick, + useRelativeDates = useRelativeDates ) } } @@ -420,6 +424,7 @@ fun CalendarSyncCard( @Composable fun ExpandableView( sectionKey: DatesSection = DatesSection.NONE, + useRelativeDates: Boolean, sectionDates: List, onItemClick: (CourseDateBlock) -> Unit, ) { @@ -503,6 +508,7 @@ fun ExpandableView( sectionKey = sectionKey, sectionDates = sectionDates, onItemClick = onItemClick, + useRelativeDates = useRelativeDates ) } } @@ -511,6 +517,7 @@ fun ExpandableView( @Composable private fun CourseDateBlockSection( sectionKey: DatesSection = DatesSection.NONE, + useRelativeDates: Boolean, sectionDates: List, onItemClick: (CourseDateBlock) -> Unit, ) { @@ -533,7 +540,7 @@ private fun CourseDateBlockSection( if (sectionKey != DatesSection.COMPLETED) { DateBullet(section = sectionKey) } - DateBlock(dateBlocks = sectionDates, onItemClick = onItemClick) + DateBlock(dateBlocks = sectionDates, onItemClick = onItemClick, useRelativeDates = useRelativeDates) } } } @@ -565,6 +572,7 @@ private fun DateBullet( @Composable private fun DateBlock( dateBlocks: List, + useRelativeDates: Boolean, onItemClick: (CourseDateBlock) -> Unit, ) { Column( @@ -579,7 +587,7 @@ private fun DateBlock( if (index != 0) { canShowDate = (lastAssignmentDate != dateBlock.date) } - CourseDateItem(dateBlock, canShowDate, index != 0, onItemClick) + CourseDateItem(dateBlock, canShowDate, index != 0, useRelativeDates, onItemClick) lastAssignmentDate = dateBlock.date } } @@ -590,8 +598,10 @@ private fun CourseDateItem( dateBlock: CourseDateBlock, canShowDate: Boolean, isMiddleChild: Boolean, + useRelativeDates: Boolean, onItemClick: (CourseDateBlock) -> Unit, ) { + val context = LocalContext.current Column( modifier = Modifier .wrapContentHeight() @@ -601,11 +611,7 @@ private fun CourseDateItem( Spacer(modifier = Modifier.height(20.dp)) } if (canShowDate) { - val timeTitle = if (dateBlock.isTimeDifferenceLessThan24Hours()) { - TimeUtils.getFormattedTime(dateBlock.date) - } else { - TimeUtils.getCourseFormattedDate(LocalContext.current, dateBlock.date) - } + val timeTitle = formatToString(context, dateBlock.date, useRelativeDates) Text( text = timeTitle, style = MaterialTheme.appTypography.labelMedium, @@ -683,6 +689,7 @@ private fun CourseDatesScreenPreview() { ), uiMessage = null, isSelfPaced = true, + useRelativeDates = true, onItemClick = {}, onPLSBannerViewed = {}, onSyncDates = {}, @@ -704,6 +711,7 @@ private fun CourseDatesScreenTabletPreview() { ), uiMessage = null, isSelfPaced = true, + useRelativeDates = true, onItemClick = {}, onPLSBannerViewed = {}, onSyncDates = {}, @@ -743,7 +751,7 @@ private val mockedResponse: LinkedHashMap> = CourseDateBlock( title = "Homework 1: ABCD", description = "After this date, course content will be archived", - date = TimeUtils.iso8601ToDate("2023-10-20T15:08:07Z")!!, + date = Date(), dateType = DateType.ASSIGNMENT_DUE_DATE, ) ) @@ -793,9 +801,3 @@ private val mockedResponse: LinkedHashMap> = ) ) ) - -val mockCalendarSyncUIState = CalendarSyncUIState( - isCalendarSyncEnabled = true, - isSynced = true, - checkForOutOfSync = AtomicReference() -) 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 589c103fc..48fd0a524 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 @@ -14,6 +14,7 @@ import org.openedx.core.CalendarRouter import org.openedx.core.R import org.openedx.core.UIMessage import org.openedx.core.config.Config +import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.interactor.CalendarInteractor import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.CourseBannerType @@ -48,11 +49,13 @@ class CourseDatesViewModel( private val config: Config, private val calendarInteractor: CalendarInteractor, private val calendarNotifier: CalendarNotifier, + private val corePreferences: CorePreferences, val courseRouter: CourseRouter, val calendarRouter: CalendarRouter ) : BaseViewModel() { var isSelfPaced = true + var useRelativeDates = corePreferences.isRelativeDatesEnabled private val _uiState = MutableStateFlow(CourseDatesUIState.Loading) val uiState: StateFlow 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 d40ae18b6..3b2ed4988 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 @@ -315,6 +315,7 @@ private fun CourseOutlineUI( modifier = listPadding.padding(vertical = 4.dp), block = section, onItemClick = onExpandClick, + useRelativeDates = uiState.useRelativeDates, courseSectionsState = courseSectionsState, courseSubSections = courseSubSections, downloadedStateMap = uiState.downloadedState, @@ -504,7 +505,8 @@ private fun CourseOutlineScreenPreview() { verifiedUpgradeLink = "", contentTypeGatingEnabled = false, hasEnded = false - ) + ), + true ), uiMessage = null, onExpandClick = {}, @@ -537,7 +539,8 @@ private fun CourseOutlineScreenTabletPreview() { verifiedUpgradeLink = "", contentTypeGatingEnabled = false, hasEnded = false - ) + ), + true ), uiMessage = null, onExpandClick = {}, diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineUIState.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineUIState.kt index 0307b1f8e..389460442 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineUIState.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineUIState.kt @@ -14,6 +14,7 @@ sealed class CourseOutlineUIState { val courseSectionsState: Map, val subSectionsDownloadsCount: Map, val datesBannerInfo: CourseDatesBannerInfo, + val useRelativeDates: Boolean, ) : CourseOutlineUIState() data object Loading : CourseOutlineUIState() 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 193b5c7e9..0acf4f64a 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 @@ -125,6 +125,7 @@ class CourseOutlineViewModel( courseSectionsState = state.courseSectionsState, subSectionsDownloadsCount = subSectionsDownloadsCount, datesBannerInfo = state.datesBannerInfo, + useRelativeDates = preferencesManager.isRelativeDatesEnabled ) } } @@ -158,6 +159,7 @@ class CourseOutlineViewModel( courseSectionsState = courseSectionsState, subSectionsDownloadsCount = subSectionsDownloadsCount, datesBannerInfo = state.datesBannerInfo, + useRelativeDates = preferencesManager.isRelativeDatesEnabled ) courseSectionsState[blockId] ?: false @@ -215,6 +217,7 @@ class CourseOutlineViewModel( courseSectionsState = courseSectionsState, subSectionsDownloadsCount = subSectionsDownloadsCount, datesBannerInfo = datesBannerInfo, + useRelativeDates = preferencesManager.isRelativeDatesEnabled ) courseNotifier.send(CourseLoading(false)) } catch (e: Exception) { 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 f1bbe6086..780a7361d 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 @@ -591,6 +591,7 @@ fun VideoSubtitles( fun CourseSection( modifier: Modifier = Modifier, block: Block, + useRelativeDates: Boolean, onItemClick: (Block) -> Unit, courseSectionsState: Boolean?, courseSubSections: List?, @@ -634,7 +635,8 @@ fun CourseSection( ) { CourseSubSectionItem( block = subSectionBlock, - onClick = onSubSectionClick + onClick = onSubSectionClick, + useRelativeDates = useRelativeDates ) } } @@ -745,6 +747,7 @@ fun CourseExpandableChapterCard( fun CourseSubSectionItem( modifier: Modifier = Modifier, block: Block, + useRelativeDates: Boolean, onClick: (Block) -> Unit, ) { val context = LocalContext.current @@ -753,7 +756,7 @@ fun CourseSubSectionItem( val iconColor = if (block.isCompleted()) MaterialTheme.appColors.successGreen else MaterialTheme.appColors.onSurface val due by rememberSaveable { - mutableStateOf(block.due?.let { TimeUtils.getAssignmentFormattedDate(context, it) }) + mutableStateOf(block.due?.let { TimeUtils.formatToString(context, it, useRelativeDates) } ?: "") } val isAssignmentEnable = !block.isCompleted() && block.assignmentProgress != null && !due.isNullOrEmpty() Column( @@ -795,7 +798,7 @@ fun CourseSubSectionItem( stringResource( R.string.course_subsection_assignment_info, block.assignmentProgress?.assignmentType ?: "", - due ?: "", + stringResource(id = coreR.string.core_date_format_assignment_due, due), block.assignmentProgress?.numPointsEarned?.toInt() ?: 0, block.assignmentProgress?.numPointsPossible?.toInt() ?: 0 ) diff --git a/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt b/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt index 64022f498..73afb3d0b 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 @@ -300,6 +300,7 @@ private fun CourseVideosUI( courseSectionsState = courseSectionsState, courseSubSections = courseSubSections, downloadedStateMap = uiState.downloadedState, + useRelativeDates = uiState.useRelativeDates, onSubSectionClick = onSubSectionClick, onDownloadClick = onDownloadClick ) @@ -632,7 +633,8 @@ private fun CourseVideosScreenPreview() { remainingSize = 0, allCount = 1, allSize = 0 - ) + ), + useRelativeDates = true ), courseTitle = "", onExpandClick = { }, @@ -689,7 +691,7 @@ private fun CourseVideosScreenTabletPreview() { remainingSize = 0, allCount = 0, allSize = 0 - ) + ), useRelativeDates = true ), courseTitle = "", onExpandClick = { }, 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 eb2c2d155..053d5a1f4 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 @@ -168,8 +168,13 @@ class CourseVideoViewModel( _uiState.value = CourseVideosUIState.CourseData( - courseStructure, getDownloadModelsStatus(), courseSubSections, - courseSectionsState, subSectionsDownloadsCount, getDownloadModelsSize() + courseStructure = courseStructure, + downloadedState = getDownloadModelsStatus(), + courseSubSections = courseSubSections, + courseSectionsState = courseSectionsState, + subSectionsDownloadsCount = subSectionsDownloadsCount, + downloadModelsSize = getDownloadModelsSize(), + useRelativeDates = preferencesManager.isRelativeDatesEnabled ) } courseNotifier.send(CourseLoading(false)) diff --git a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideosUIState.kt b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideosUIState.kt index ce05913d6..44f485c98 100644 --- a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideosUIState.kt +++ b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideosUIState.kt @@ -12,7 +12,8 @@ sealed class CourseVideosUIState { val courseSubSections: Map>, val courseSectionsState: Map, val subSectionsDownloadsCount: Map, - val downloadModelsSize: DownloadModelsSize + val downloadModelsSize: DownloadModelsSize, + val useRelativeDates: Boolean ) : CourseVideosUIState() data class Empty(val message: String) : CourseVideosUIState() 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 2fb055011..ed4e28f58 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 @@ -28,6 +28,7 @@ import org.openedx.core.R import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.data.model.DateType +import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.interactor.CalendarInteractor import org.openedx.core.domain.model.CourseCalendarState import org.openedx.core.domain.model.CourseDateBlock @@ -65,6 +66,7 @@ class CourseDatesViewModelTest { private val calendarRouter = mockk() private val calendarNotifier = mockk() private val calendarInteractor = mockk() + private val preferencesManager = mockk() private val openEdx = "OpenEdx" private val noInternet = "Slow or no internet connection" @@ -138,6 +140,7 @@ class CourseDatesViewModelTest { coEvery { notifier.send(any()) } returns Unit every { calendarNotifier.notifier } returns flowOf(CalendarSynced) coEvery { calendarNotifier.send(any()) } returns Unit + every { preferencesManager.isRelativeDatesEnabled } returns true coEvery { calendarInteractor.getCourseCalendarStateByIdFromCache(any()) } returns CourseCalendarState( 0, "", @@ -162,6 +165,7 @@ class CourseDatesViewModelTest { config, calendarInteractor, calendarNotifier, + preferencesManager, courseRouter, calendarRouter, ) @@ -191,6 +195,7 @@ class CourseDatesViewModelTest { config, calendarInteractor, calendarNotifier, + preferencesManager, courseRouter, calendarRouter, ) @@ -220,6 +225,7 @@ class CourseDatesViewModelTest { config, calendarInteractor, calendarNotifier, + preferencesManager, courseRouter, calendarRouter, ) @@ -249,6 +255,7 @@ class CourseDatesViewModelTest { config, calendarInteractor, calendarNotifier, + preferencesManager, courseRouter, calendarRouter, ) 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 15901d1b3..255cc6379 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 @@ -234,6 +234,7 @@ class CourseOutlineViewModelTest { every { resourceManager.getString(org.openedx.course.R.string.course_can_download_only_with_wifi) } returns cantDownload every { config.getApiHostURL() } returns "http://localhost:8000" every { downloadDialogManager.showDownloadFailedPopup(any(), any()) } returns Unit + every { preferencesManager.isRelativeDatesEnabled } returns true coEvery { interactor.getCourseDates(any()) } returns mockedCourseDatesResult } 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 b8a4d543c..562bca77b 100644 --- a/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt @@ -198,6 +198,7 @@ class CourseVideoViewModelTest { Dispatchers.setMain(dispatcher) every { config.getApiHostURL() } returns "http://localhost:8000" every { courseNotifier.notifier } returns flowOf(CourseLoading(false)) + every { preferencesManager.isRelativeDatesEnabled } returns true every { downloadDialogManager.showPopup(any(), any(), any(), any(), any(), any(), any()) } returns Unit } diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt index e7e22ba1c..ef583112b 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt @@ -419,7 +419,7 @@ fun CourseItem( Column { AsyncImage( model = ImageRequest.Builder(LocalContext.current) - .data(course.course.courseImage.toImageLink(apiHostUrl) ?: "") + .data(course.course.courseImage.toImageLink(apiHostUrl)) .error(R.drawable.core_no_image_course) .placeholder(R.drawable.core_no_image_course) .build(), diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryUIState.kt b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryUIState.kt index c4049f463..fdbc5d5db 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryUIState.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryUIState.kt @@ -3,7 +3,7 @@ package org.openedx.courses.presentation import org.openedx.core.domain.model.CourseEnrollments sealed class DashboardGalleryUIState { - data class Courses(val userCourses: CourseEnrollments) : DashboardGalleryUIState() + data class Courses(val userCourses: CourseEnrollments, val useRelativeDates: Boolean) : 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 index 5de4c78c5..0fd0e2ccd 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt @@ -213,6 +213,7 @@ private fun DashboardGalleryView( UserCourses( modifier = Modifier.fillMaxSize(), userCourses = uiState.userCourses, + useRelativeDates = uiState.useRelativeDates, apiHostUrl = apiHostUrl, openCourse = { onAction(DashboardGalleryScreenAction.OpenCourse(it)) @@ -274,6 +275,7 @@ private fun UserCourses( modifier: Modifier = Modifier, userCourses: CourseEnrollments, apiHostUrl: String, + useRelativeDates: Boolean, openCourse: (EnrolledCourse) -> Unit, navigateToDates: (EnrolledCourse) -> Unit, onViewAllClick: () -> Unit, @@ -290,7 +292,8 @@ private fun UserCourses( apiHostUrl = apiHostUrl, navigateToDates = navigateToDates, resumeBlockId = resumeBlockId, - openCourse = openCourse + openCourse = openCourse, + useRelativeDates = useRelativeDates ) } if (userCourses.enrollments.courses.isNotEmpty()) { @@ -505,6 +508,7 @@ private fun AssignmentItem( private fun PrimaryCourseCard( primaryCourse: EnrolledCourse, apiHostUrl: String, + useRelativeDates: Boolean, navigateToDates: (EnrolledCourse) -> Unit, resumeBlockId: (enrolledCourse: EnrolledCourse, blockId: String) -> Unit, openCourse: (EnrolledCourse) -> Unit, @@ -527,7 +531,7 @@ private fun PrimaryCourseCard( ) { AsyncImage( model = ImageRequest.Builder(context) - .data(apiHostUrl + primaryCourse.course.courseImage) + .data(primaryCourse.course.courseImage.toImageLink(apiHostUrl)) .error(CoreR.drawable.core_no_image_course) .placeholder(CoreR.drawable.core_no_image_course) .build(), @@ -597,7 +601,10 @@ private fun PrimaryCourseCard( info = stringResource( R.string.dashboard_assignment_due, nearestAssignment.assignmentType ?: "", - TimeUtils.getAssignmentFormattedDate(context, nearestAssignment.date) + stringResource( + id = CoreR.string.core_date_format_assignment_due, + TimeUtils.formatToString(context, nearestAssignment.date, useRelativeDates) + ) ) ) } @@ -856,7 +863,7 @@ private fun ViewAllItemPreview() { private fun DashboardGalleryViewPreview() { OpenEdXTheme { DashboardGalleryView( - uiState = DashboardGalleryUIState.Courses(mockUserCourses), + uiState = DashboardGalleryUIState.Courses(mockUserCourses, true), apiHostUrl = "", uiMessage = null, updating = false, diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt index 7f1036e1d..fdef55ee7 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt @@ -14,6 +14,7 @@ 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.data.storage.CorePreferences import org.openedx.core.domain.model.EnrolledCourse import org.openedx.core.extension.isInternetError import org.openedx.core.system.ResourceManager @@ -34,7 +35,8 @@ class DashboardGalleryViewModel( private val networkConnection: NetworkConnection, private val fileUtil: FileUtil, private val dashboardRouter: DashboardRouter, - private val windowSize: WindowSize + private val corePreferences: CorePreferences, + private val windowSize: WindowSize, ) : BaseViewModel() { val apiHostUrl get() = config.getApiHostURL() @@ -76,7 +78,10 @@ class DashboardGalleryViewModel( if (response.primary == null && response.enrollments.courses.isEmpty()) { _uiState.value = DashboardGalleryUIState.Empty } else { - _uiState.value = DashboardGalleryUIState.Courses(response) + _uiState.value = DashboardGalleryUIState.Courses( + response, + corePreferences.isRelativeDatesEnabled + ) } } else { val courseEnrollments = fileUtil.getObjectFromFile() @@ -84,7 +89,10 @@ class DashboardGalleryViewModel( _uiState.value = DashboardGalleryUIState.Empty } else { _uiState.value = - DashboardGalleryUIState.Courses(courseEnrollments.mapToDomain()) + DashboardGalleryUIState.Courses( + courseEnrollments.mapToDomain(), + corePreferences.isRelativeDatesEnabled + ) } } } catch (e: Exception) { diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt index 579076b96..fefcde867 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt @@ -392,7 +392,7 @@ private fun CourseItem( ) { AsyncImage( model = ImageRequest.Builder(LocalContext.current) - .data(enrolledCourse.course.courseImage.toImageLink(apiHostUrl) ?: "") + .data(enrolledCourse.course.courseImage.toImageLink(apiHostUrl)) .error(CoreR.drawable.core_no_image_course) .placeholder(CoreR.drawable.core_no_image_course) .build(), diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/ui/DiscoveryUI.kt b/discovery/src/main/java/org/openedx/discovery/presentation/ui/DiscoveryUI.kt index 5d0f527bb..4ce446e31 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/ui/DiscoveryUI.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/ui/DiscoveryUI.kt @@ -68,15 +68,10 @@ fun ImageHeader( } else { ContentScale.Crop } - val imageUrl = if (courseImage?.isLinkValid() == true) { - courseImage - } else { - apiHostUrl + courseImage - } Box(modifier = modifier, contentAlignment = Alignment.Center) { AsyncImage( model = ImageRequest.Builder(LocalContext.current) - .data(imageUrl) + .data(courseImage?.toImageLink(apiHostUrl)) .error(CoreR.drawable.core_no_image_course) .placeholder(CoreR.drawable.core_no_image_course) .build(), diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarFragment.kt index 112a4e774..fcc6db153 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarFragment.kt @@ -59,6 +59,9 @@ class CalendarFragment : Fragment() { onCalendarSyncSwitchClick = { viewModel.setCalendarSyncEnabled(it, requireActivity().supportFragmentManager) }, + onRelativeDateSwitchClick = { + viewModel.setRelativeDateEnabled(it) + }, onChangeSyncOptionClick = { val dialog = NewCalendarDialogFragment.newInstance(NewCalendarDialogType.UPDATE) dialog.show( @@ -84,11 +87,14 @@ private fun CalendarView( onChangeSyncOptionClick: () -> Unit, onCourseToSyncClick: () -> Unit, onCalendarSyncSwitchClick: (Boolean) -> Unit, + onRelativeDateSwitchClick: (Boolean) -> Unit ) { if (!uiState.isCalendarExist) { CalendarSetUpView( windowSize = windowSize, + useRelativeDates = uiState.isRelativeDateEnabled, setUpCalendarSync = setUpCalendarSync, + onRelativeDateSwitchClick = onRelativeDateSwitchClick, onBackClick = onBackClick ) } else { @@ -97,6 +103,7 @@ private fun CalendarView( uiState = uiState, onBackClick = onBackClick, onCalendarSyncSwitchClick = onCalendarSyncSwitchClick, + onRelativeDateSwitchClick = onRelativeDateSwitchClick, onChangeSyncOptionClick = onChangeSyncOptionClick, onCourseToSyncClick = onCourseToSyncClick ) diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarSetUpView.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarSetUpView.kt index 06a842630..7309a42f9 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarSetUpView.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarSetUpView.kt @@ -55,7 +55,9 @@ import org.openedx.profile.R @Composable fun CalendarSetUpView( windowSize: WindowSize, + useRelativeDates: Boolean, setUpCalendarSync: () -> Unit, + onRelativeDateSwitchClick: (Boolean) -> Unit, onBackClick: () -> Unit ) { val scaffoldState = rememberScaffoldState() @@ -192,6 +194,11 @@ fun CalendarSetUpView( Spacer(modifier = Modifier.height(24.dp)) } } + Spacer(modifier = Modifier.height(28.dp)) + OptionsSection( + isRelativeDatesEnabled = useRelativeDates, + onRelativeDateSwitchClick = onRelativeDateSwitchClick + ) } } } @@ -206,7 +213,9 @@ private fun CalendarScreenPreview() { OpenEdXTheme { CalendarSetUpView( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + useRelativeDates = true, setUpCalendarSync = {}, + onRelativeDateSwitchClick = { _ -> }, onBackClick = {} ) } diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarSettingsView.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarSettingsView.kt index bce3ede77..d8c2e9a55 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarSettingsView.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarSettingsView.kt @@ -67,6 +67,7 @@ fun CalendarSettingsView( windowSize: WindowSize, uiState: CalendarUIState, onCalendarSyncSwitchClick: (Boolean) -> Unit, + onRelativeDateSwitchClick: (Boolean) -> Unit, onChangeSyncOptionClick: () -> Unit, onCourseToSyncClick: () -> Unit, onBackClick: () -> Unit @@ -155,6 +156,11 @@ fun CalendarSettingsView( onCourseToSyncClick = onCourseToSyncClick ) } + Spacer(modifier = Modifier.height(32.dp)) + OptionsSection( + isRelativeDatesEnabled = uiState.isRelativeDateEnabled, + onRelativeDateSwitchClick = onRelativeDateSwitchClick + ) } } } @@ -312,10 +318,12 @@ private fun CalendarSettingsViewPreview() { calendarData = CalendarData("calendar", Color.Red.toArgb()), calendarSyncState = CalendarSyncState.SYNCED, isCalendarSyncEnabled = false, + isRelativeDateEnabled = true, coursesSynced = 5 ), onBackClick = {}, onCalendarSyncSwitchClick = {}, + onRelativeDateSwitchClick = {}, onChangeSyncOptionClick = {}, onCourseToSyncClick = {} ) diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarUIState.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarUIState.kt index cf99e0fa2..513a5c5e5 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarUIState.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarUIState.kt @@ -8,5 +8,6 @@ data class CalendarUIState( val calendarData: CalendarData? = null, val calendarSyncState: CalendarSyncState, val isCalendarSyncEnabled: Boolean, - val coursesSynced: Int? + val coursesSynced: Int?, + val isRelativeDateEnabled: Boolean, ) diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarView.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarView.kt new file mode 100644 index 000000000..4cc682dc7 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarView.kt @@ -0,0 +1,73 @@ +package org.openedx.profile.presentation.calendar + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.LocalMinimumInteractiveComponentEnforcement +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Switch +import androidx.compose.material.SwitchDefaults +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appTypography +import org.openedx.core.utils.TimeUtils +import org.openedx.profile.R +import java.util.Date + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun OptionsSection( + isRelativeDatesEnabled: Boolean, + onRelativeDateSwitchClick: (Boolean) -> Unit +) { + val context = LocalContext.current + val textDescription = if (isRelativeDatesEnabled) { + stringResource(R.string.profile_show_relative_dates) + } else { + stringResource( + R.string.profile_show_full_dates, + TimeUtils.formatToString(context, Date(), false) + ) + } + Column { + SectionTitle(stringResource(R.string.profile_options)) + Spacer(modifier = Modifier.height(8.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.weight(1f), + text = stringResource(R.string.profile_use_relative_dates), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textDark + ) + CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) { + Switch( + modifier = Modifier + .padding(0.dp), + checked = isRelativeDatesEnabled, + onCheckedChange = onRelativeDateSwitchClick, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.appColors.textAccent + ) + ) + } + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = textDescription, + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textPrimaryVariant + ) + } +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarViewModel.kt index 658d7ca8e..c50bf587c 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarViewModel.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.openedx.core.BaseViewModel import org.openedx.core.data.storage.CalendarPreferences +import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.interactor.CalendarInteractor import org.openedx.core.presentation.settings.calendarsync.CalendarSyncState import org.openedx.core.system.CalendarManager @@ -30,6 +31,7 @@ class CalendarViewModel( private val calendarPreferences: CalendarPreferences, private val calendarNotifier: CalendarNotifier, private val calendarInteractor: CalendarInteractor, + private val corePreferences: CorePreferences, private val profileRouter: ProfileRouter, private val networkConnection: NetworkConnection, ) : BaseViewModel() { @@ -40,7 +42,8 @@ class CalendarViewModel( calendarData = null, calendarSyncState = if (networkConnection.isOnline()) CalendarSyncState.SYNCED else CalendarSyncState.OFFLINE, isCalendarSyncEnabled = calendarPreferences.isCalendarSyncEnabled, - coursesSynced = null + coursesSynced = null, + isRelativeDateEnabled = corePreferences.isRelativeDatesEnabled, ) private val _uiState = MutableStateFlow(calendarInitState) @@ -107,6 +110,11 @@ class CalendarViewModel( } } + fun setRelativeDateEnabled(isEnabled: Boolean) { + corePreferences.isRelativeDatesEnabled = isEnabled + _uiState.update { it.copy(isRelativeDateEnabled = isEnabled) } + } + fun navigateToCoursesToSync(fragmentManager: FragmentManager) { profileRouter.navigateToCoursesToSync(fragmentManager) } diff --git a/profile/src/main/res/values/strings.xml b/profile/src/main/res/values/strings.xml index 41535240c..1adf22c97 100644 --- a/profile/src/main/res/values/strings.xml +++ b/profile/src/main/res/values/strings.xml @@ -78,5 +78,6 @@ No %1$s Courses No courses are currently being synced to your calendar. No courses match the current filter. + Show full dates like “%1$s” From 1dc72d215ff26224ec23d980d7055ba733a61964 Mon Sep 17 00:00:00 2001 From: Igor Date: Mon, 7 Oct 2024 11:03:32 +0200 Subject: [PATCH 34/56] fix: Handle missing email in Facebook login (#377) --- .../org/openedx/auth/presentation/sso/FacebookAuthHelper.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/auth/src/main/java/org/openedx/auth/presentation/sso/FacebookAuthHelper.kt b/auth/src/main/java/org/openedx/auth/presentation/sso/FacebookAuthHelper.kt index 70f2209ab..f6e734629 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/sso/FacebookAuthHelper.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/sso/FacebookAuthHelper.kt @@ -46,8 +46,8 @@ class FacebookAuthHelper { continuation.safeResume( SocialAuthResponse( accessToken = result.accessToken.token, - name = obj?.getString(ApiConstants.NAME) ?: "", - email = obj?.getString(ApiConstants.EMAIL) ?: "", + name = obj?.optString(ApiConstants.NAME).orEmpty(), + email = obj?.optString(ApiConstants.EMAIL).orEmpty(), authType = AuthType.FACEBOOK, ) ) { From f66b3f2df1b16ed3ffd85488124761e3bc7e9e6c Mon Sep 17 00:00:00 2001 From: Igor Date: Mon, 7 Oct 2024 11:09:57 +0200 Subject: [PATCH 35/56] fix: `IllegalStateException` on `supportFragmentManager.popBackStack()` (#379) - Ensure `MutableStateFlow` is lifecycle-aware by utilizing `collectAsStateWithLifecycle`. Co-authored-by: Farhan Arshad --- core/build.gradle | 1 + .../openedx/course/settings/download/DownloadQueueFragment.kt | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/core/build.gradle b/core/build.gradle index dce265ef3..f135f62fd 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -114,6 +114,7 @@ dependencies { api "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version" api "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version" api "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version" + api "androidx.lifecycle:lifecycle-runtime-compose:$lifecycle_version" // Fullstory api 'com.fullstory:instrumentation-full:1.47.0@aar' diff --git a/course/src/main/java/org/openedx/course/settings/download/DownloadQueueFragment.kt b/course/src/main/java/org/openedx/course/settings/download/DownloadQueueFragment.kt index ea2b40e4b..3db1ee158 100644 --- a/course/src/main/java/org/openedx/course/settings/download/DownloadQueueFragment.kt +++ b/course/src/main/java/org/openedx/course/settings/download/DownloadQueueFragment.kt @@ -23,7 +23,6 @@ import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -41,6 +40,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import androidx.core.os.bundleOf import androidx.fragment.app.Fragment +import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.core.module.db.DownloadModel @@ -80,7 +80,7 @@ class DownloadQueueFragment : Fragment() { setContent { OpenEdXTheme { val windowSize = rememberWindowSize() - val uiState by viewModel.uiState.collectAsState(DownloadQueueUIState.Loading) + val uiState by viewModel.uiState.collectAsStateWithLifecycle(DownloadQueueUIState.Loading) DownloadQueueScreen( windowSize = windowSize, From 77cdf417f0b8e4d2a0bc27d89be2e15ddcf49371 Mon Sep 17 00:00:00 2001 From: Igor Date: Mon, 7 Oct 2024 11:13:57 +0200 Subject: [PATCH 36/56] fix: disable full story plugin when disabled (#381) Co-authored-by: Farhan Arshad --- app/build.gradle | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/build.gradle b/app/build.gradle index cc09177bc..3c017ee0b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -11,6 +11,10 @@ apply plugin: 'org.jetbrains.kotlin.android' apply plugin: 'kotlin-parcelize' apply plugin: 'kotlin-kapt' +if (fullstoryEnabled) { + apply plugin: 'fullstory' +} + if (firebaseEnabled) { apply plugin: 'com.google.gms.google-services' apply plugin: 'com.google.firebase.crashlytics' @@ -29,7 +33,6 @@ if (firebaseEnabled) { } if (fullstoryEnabled) { - apply plugin: 'fullstory' def fullstoryOrgId = fullstoryConfig?.get("ORG_ID") fullstory { From eddd54b357e6d0da4ccd1dc2d3df5832212600f7 Mon Sep 17 00:00:00 2001 From: Igor Date: Mon, 7 Oct 2024 11:14:39 +0200 Subject: [PATCH 37/56] fix: bug when unable to see new loaded items on the All Courses screen (#382) --- .../openedx/courses/presentation/AllEnrolledCoursesView.kt | 3 ++- .../courses/presentation/AllEnrolledCoursesViewModel.kt | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt index ef583112b..de5d80b0c 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt @@ -20,6 +20,7 @@ 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.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.rememberLazyGridState @@ -329,7 +330,7 @@ private fun AllEnrolledCoursesView( } ) } - item { + item(span = { GridItemSpan(columns) }) { if (state.canLoadMore) { Box( modifier = Modifier diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesViewModel.kt b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesViewModel.kt index 6f3f96ebf..d52475eca 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesViewModel.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesViewModel.kt @@ -83,7 +83,7 @@ class AllEnrolledCoursesViewModel( } coursesList.clear() coursesList.addAll(response.courses) - _uiState.update { it.copy(courses = coursesList) } + _uiState.update { it.copy(courses = coursesList.toList()) } } catch (e: Exception) { if (e.isInternetError()) { _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection))) @@ -125,7 +125,7 @@ class AllEnrolledCoursesViewModel( page = -1 coursesList.addAll(cachedList) } - _uiState.update { it.copy(courses = coursesList) } + _uiState.update { it.copy(courses = coursesList.toList()) } } catch (e: Exception) { if (e.isInternetError()) { _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection))) From 660770ce01f87509567324d0d8979912834cf653 Mon Sep 17 00:00:00 2001 From: Igor Date: Mon, 7 Oct 2024 11:15:35 +0200 Subject: [PATCH 38/56] fix: Auto Playing Videos and Discovery's External Browser Pop-Up (#383) * fix: Open in External Browser Pop-Up for Bachelor's Degrees * fix: Multiple videos playing simultaneously * fix: Retain Video Seek Time when Exiting Fullscreen in Native Videos --------- Co-authored-by: Hamza Israr --- .../course/presentation/unit/video/VideoUnitFragment.kt | 3 ++- .../presentation/unit/video/YoutubeVideoUnitFragment.kt | 9 ++++++++- .../discovery/presentation/info/CourseInfoFragment.kt | 3 --- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitFragment.kt index d92b3b067..276f48574 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitFragment.kt @@ -201,9 +201,10 @@ class VideoUnitFragment : Fragment(R.layout.fragment_video_unit) { if (!viewModel.isPlayerSetUp) { setPlayerMedia(mediaItem) viewModel.getActivePlayer()?.prepare() - viewModel.getActivePlayer()?.playWhenReady = viewModel.isPlaying + viewModel.getActivePlayer()?.playWhenReady = viewModel.isPlaying && isResumed viewModel.isPlayerSetUp = true } + viewModel.getActivePlayer()?.seekTo(viewModel.getCurrentVideoTime()) viewModel.castPlayer?.setSessionAvailabilityListener( object : SessionAvailabilityListener { diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoUnitFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoUnitFragment.kt index 8ee99b970..e6b04687d 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoUnitFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoUnitFragment.kt @@ -84,6 +84,13 @@ class YoutubeVideoUnitFragment : Fragment(R.layout.fragment_youtube_video_unit) return binding.root } + override fun onResume() { + super.onResume() + if (viewModel.isPlaying) { + _youTubePlayer?.play() + } + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -202,7 +209,7 @@ class YoutubeVideoUnitFragment : Fragment(R.layout.fragment_youtube_video_unit) } viewModel.videoUrl.split("watch?v=").getOrNull(1)?.let { videoId -> - if (viewModel.isPlaying) { + if (viewModel.isPlaying && isResumed) { youTubePlayer.loadVideo( videoId, viewModel.getCurrentVideoTime().toFloat() / 1000 ) diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoFragment.kt index b3b3275eb..d34233f1b 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoFragment.kt @@ -347,9 +347,6 @@ private fun CourseInfoWebView( factory = { webView }, - update = { - webView.loadUrl(contentUrl) - } ) } From a86d95d32b65ce97545c79999ba3990db9692751 Mon Sep 17 00:00:00 2001 From: Igor Date: Mon, 7 Oct 2024 11:16:13 +0200 Subject: [PATCH 39/56] fix: wrong sending custom or track events as page events (#384) --- .../main/java/org/openedx/app/analytics/FullstoryAnalytics.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/openedx/app/analytics/FullstoryAnalytics.kt b/app/src/main/java/org/openedx/app/analytics/FullstoryAnalytics.kt index 11aa26bc7..bb3473844 100644 --- a/app/src/main/java/org/openedx/app/analytics/FullstoryAnalytics.kt +++ b/app/src/main/java/org/openedx/app/analytics/FullstoryAnalytics.kt @@ -22,7 +22,7 @@ class FullstoryAnalytics : Analytics { override fun logEvent(eventName: String, params: Map) { logger.d { "Event: $eventName $params" } - FS.page(eventName, params).start() + FS.event(eventName, params) } override fun logUserId(userId: Long) { From 42f58355254fa2db7a93c1599c5c7115ad3204a0 Mon Sep 17 00:00:00 2001 From: Igor Date: Mon, 7 Oct 2024 11:17:10 +0200 Subject: [PATCH 40/56] fix: ignore IllegalStateException when fragment transaction fails (#373) --- .../main/java/org/openedx/app/AppRouter.kt | 70 +++++++++++++------ 1 file changed, 47 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/org/openedx/app/AppRouter.kt b/app/src/main/java/org/openedx/app/AppRouter.kt index 0b64fb94f..09903f99e 100644 --- a/app/src/main/java/org/openedx/app/AppRouter.kt +++ b/app/src/main/java/org/openedx/app/AppRouter.kt @@ -67,10 +67,14 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di infoType: String?, openTab: String ) { - fm.popBackStack() - fm.beginTransaction() - .replace(R.id.container, MainFragment.newInstance(courseId, infoType, openTab)) - .commit() + try { + fm.popBackStack() + fm.beginTransaction() + .replace(R.id.container, MainFragment.newInstance(courseId, infoType, openTab)) + .commit() + } catch (e: Exception) { + e.printStackTrace() + } } override fun navigateToSignIn(fm: FragmentManager, courseId: String?, infoType: String?) { @@ -102,18 +106,26 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di } override fun navigateToWhatsNew(fm: FragmentManager, courseId: String?, infoType: String?) { - fm.popBackStack() - fm.beginTransaction() - .replace(R.id.container, WhatsNewFragment.newInstance(courseId, infoType)) - .commit() + try { + fm.popBackStack() + fm.beginTransaction() + .replace(R.id.container, WhatsNewFragment.newInstance(courseId, infoType)) + .commit() + } catch (e: Exception) { + e.printStackTrace() + } } override fun clearBackStack(fm: FragmentManager) { fm.apply { - for (fragment in fragments) { - beginTransaction().remove(fragment).commit() + try { + for (fragment in fragments) { + beginTransaction().remove(fragment).commit() + } + popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE) + } catch (e: Exception) { + e.printStackTrace() } - popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE) } } //endregion @@ -424,10 +436,14 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di } private fun replaceFragmentWithBackStack(fm: FragmentManager, fragment: Fragment) { - fm.beginTransaction() - .replace(R.id.container, fragment, fragment.javaClass.simpleName) - .addToBackStack(fragment.javaClass.simpleName) - .commit() + try { + fm.beginTransaction() + .replace(R.id.container, fragment, fragment.javaClass.simpleName) + .addToBackStack(fragment.javaClass.simpleName) + .commit() + } catch (e: Exception) { + e.printStackTrace() + } } private fun replaceFragment( @@ -435,18 +451,26 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di fragment: Fragment, transaction: Int = FragmentTransaction.TRANSIT_NONE, ) { - fm.beginTransaction() - .setTransition(transaction) - .replace(R.id.container, fragment, fragment.javaClass.simpleName) - .commit() + try { + fm.beginTransaction() + .setTransition(transaction) + .replace(R.id.container, fragment, fragment.javaClass.simpleName) + .commit() + } catch (e: Exception) { + e.printStackTrace() + } } //App upgrade override fun navigateToUserProfile(fm: FragmentManager) { - fm.popBackStack() - fm.beginTransaction() - .replace(R.id.container, ProfileFragment()) - .commit() + try { + fm.popBackStack() + fm.beginTransaction() + .replace(R.id.container, ProfileFragment()) + .commit() + } catch (e: Exception) { + e.printStackTrace() + } } //endregion } From 16a3b17776c85002f955f5b541a0dece9c41247d Mon Sep 17 00:00:00 2001 From: Igor Date: Tue, 8 Oct 2024 10:07:01 +0200 Subject: [PATCH 41/56] fix: crash when internet disconnected right after opening a course (#376) --- .../outline/CourseOutlineScreen.kt | 2 + .../outline/CourseOutlineUIState.kt | 1 + .../outline/CourseOutlineViewModel.kt | 1 + .../videos/CourseVideoViewModel.kt | 54 ++++++++++--------- .../outline/CourseOutlineViewModelTest.kt | 4 +- 5 files changed, 36 insertions(+), 26 deletions(-) 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 3b2ed4988..f0516a744 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 @@ -327,6 +327,8 @@ private fun CourseOutlineUI( } } + CourseOutlineUIState.Error -> {} + CourseOutlineUIState.Loading -> {} } } diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineUIState.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineUIState.kt index 389460442..381cb8401 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineUIState.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineUIState.kt @@ -17,5 +17,6 @@ sealed class CourseOutlineUIState { val useRelativeDates: Boolean, ) : CourseOutlineUIState() + data object Error : CourseOutlineUIState() data object Loading : CourseOutlineUIState() } 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 0acf4f64a..f59f6ec6e 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 @@ -221,6 +221,7 @@ class CourseOutlineViewModel( ) courseNotifier.send(CourseLoading(false)) } catch (e: Exception) { + _uiState.value = CourseOutlineUIState.Error if (e.isInternetError()) { _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection))) } else { diff --git a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt index 053d5a1f4..e5bbffe05 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 @@ -150,34 +150,40 @@ class CourseVideoViewModel( fun getVideos() { viewModelScope.launch { - var courseStructure = interactor.getCourseStructureForVideos(courseId) - val blocks = courseStructure.blockData - if (blocks.isEmpty()) { + try { + var courseStructure = interactor.getCourseStructureForVideos(courseId) + val blocks = courseStructure.blockData + if (blocks.isEmpty()) { + _uiState.value = CourseVideosUIState.Empty( + message = resourceManager.getString(R.string.course_does_not_include_videos) + ) + } else { + setBlocks(courseStructure.blockData) + courseSubSections.clear() + courseSubSectionUnit.clear() + courseStructure = courseStructure.copy(blockData = sortBlocks(blocks)) + initDownloadModelsStatus() + + val courseSectionsState = + (_uiState.value as? CourseVideosUIState.CourseData)?.courseSectionsState.orEmpty() + + _uiState.value = + CourseVideosUIState.CourseData( + courseStructure = courseStructure, + downloadedState = getDownloadModelsStatus(), + courseSubSections = courseSubSections, + courseSectionsState = courseSectionsState, + subSectionsDownloadsCount = subSectionsDownloadsCount, + downloadModelsSize = getDownloadModelsSize(), + useRelativeDates = preferencesManager.isRelativeDatesEnabled + ) + } + courseNotifier.send(CourseLoading(false)) + } catch (e: Exception) { _uiState.value = CourseVideosUIState.Empty( message = resourceManager.getString(R.string.course_does_not_include_videos) ) - } else { - setBlocks(courseStructure.blockData) - courseSubSections.clear() - courseSubSectionUnit.clear() - courseStructure = courseStructure.copy(blockData = sortBlocks(blocks)) - initDownloadModelsStatus() - - val courseSectionsState = - (_uiState.value as? CourseVideosUIState.CourseData)?.courseSectionsState.orEmpty() - - _uiState.value = - CourseVideosUIState.CourseData( - courseStructure = courseStructure, - downloadedState = getDownloadModelsStatus(), - courseSubSections = courseSubSections, - courseSectionsState = courseSectionsState, - subSectionsDownloadsCount = subSectionsDownloadsCount, - downloadModelsSize = getDownloadModelsSize(), - useRelativeDates = preferencesManager.isRelativeDatesEnabled - ) } - courseNotifier.send(CourseLoading(false)) } } 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 255cc6379..679dfedc9 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 @@ -281,7 +281,7 @@ class CourseOutlineViewModelTest { coVerify(exactly = 2) { interactor.getCourseStatus(any()) } assertEquals(noInternet, message.await()?.message) - assert(viewModel.uiState.value is CourseOutlineUIState.Loading) + assert(viewModel.uiState.value is CourseOutlineUIState.Error) } @Test @@ -319,7 +319,7 @@ class CourseOutlineViewModelTest { coVerify(exactly = 2) { interactor.getCourseStatus(any()) } assertEquals(somethingWrong, message.await()?.message) - assert(viewModel.uiState.value is CourseOutlineUIState.Loading) + assert(viewModel.uiState.value is CourseOutlineUIState.Error) } @Test From a8b73952d77ffa7b49ddf83f4accd522a46719fc Mon Sep 17 00:00:00 2001 From: Igor Date: Tue, 8 Oct 2024 10:07:37 +0200 Subject: [PATCH 42/56] fix: Update course dates prefix on course cards (#380) Co-authored-by: omer.habib --- .../java/org/openedx/core/utils/TimeUtils.kt | 4 ++-- core/src/main/res/values/strings.xml | 11 +++++------ .../presentation/AllEnrolledCoursesView.kt | 19 ++++++++----------- .../presentation/DashboardGalleryView.kt | 19 ++++++++----------- 4 files changed, 23 insertions(+), 30 deletions(-) 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 bb7c320e5..02e0bde2f 100644 --- a/core/src/main/java/org/openedx/core/utils/TimeUtils.kt +++ b/core/src/main/java/org/openedx/core/utils/TimeUtils.kt @@ -170,12 +170,12 @@ object TimeUtils { DateUtils.SECOND_IN_MILLIS, DateUtils.FORMAT_ABBREV_RELATIVE ).toString() - resourceManager.getString(R.string.core_label_expired, timeSpan) + resourceManager.getString(R.string.core_label_access_expired, timeSpan) } } else { formattedDate = if (dayDifferenceInMillis > SEVEN_DAYS_IN_MILLIS) { resourceManager.getString( - R.string.core_label_expires_on, + R.string.core_label_expires, dateToCourseDate(resourceManager, expiry) ) } else { diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index 0b245c7fa..78263a819 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -16,13 +16,12 @@ Cancel Search Select value - Starting %1$s - Ended %1$s + Starts %1$s + Ended on %1$s Ends %1$s - Course access expires %1$s - Course access expires on %1$s - Course access expired %1$s - Course access expired on %1$s + Access expires %1$s + Access expired %1$s + Expired on %1$s Password Soon Offline diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt index de5d80b0c..34409198c 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt @@ -454,17 +454,14 @@ fun CourseItem( 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 = TimeUtils.getCourseFormattedDate( + LocalContext.current, + Date(), + course.auditAccessExpires, + course.course.start, + course.course.end, + course.course.startType, + course.course.startDisplay ) ) Text( diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt index 0fd0e2ccd..a3832e8b5 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt @@ -708,17 +708,14 @@ private fun PrimaryCourseTitle( 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 - ) + text = TimeUtils.getCourseFormattedDate( + LocalContext.current, + Date(), + primaryCourse.auditAccessExpires, + primaryCourse.course.start, + primaryCourse.course.end, + primaryCourse.course.startType, + primaryCourse.course.startDisplay ) ) } From bbf71e39ced4e34321c4a368bd05fc02712eb5a3 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk <141041606+PavloNetrebchuk@users.noreply.github.com> Date: Thu, 17 Oct 2024 17:16:02 +0300 Subject: [PATCH 43/56] refactor: Rename EnrollmentStatus field (#385) --- .../java/org/openedx/core/data/model/EnrollmentStatus.kt | 6 +++--- .../java/org/openedx/core/domain/model/EnrollmentStatus.kt | 2 +- .../main/java/org/openedx/core/worker/CalendarSyncWorker.kt | 4 ++-- .../profile/presentation/calendar/CoursesToSyncFragment.kt | 6 +++--- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/core/src/main/java/org/openedx/core/data/model/EnrollmentStatus.kt b/core/src/main/java/org/openedx/core/data/model/EnrollmentStatus.kt index f5535879e..dc73134ec 100644 --- a/core/src/main/java/org/openedx/core/data/model/EnrollmentStatus.kt +++ b/core/src/main/java/org/openedx/core/data/model/EnrollmentStatus.kt @@ -8,12 +8,12 @@ data class EnrollmentStatus( val courseId: String?, @SerializedName("course_name") val courseName: String?, - @SerializedName("is_active") - val isActive: Boolean? + @SerializedName("recently_active") + val recentlyActive: Boolean? ) { fun mapToDomain() = EnrollmentStatus( courseId = courseId ?: "", courseName = courseName ?: "", - isActive = isActive ?: false + recentlyActive = recentlyActive ?: false ) } diff --git a/core/src/main/java/org/openedx/core/domain/model/EnrollmentStatus.kt b/core/src/main/java/org/openedx/core/domain/model/EnrollmentStatus.kt index 8d40ea71d..4039975e3 100644 --- a/core/src/main/java/org/openedx/core/domain/model/EnrollmentStatus.kt +++ b/core/src/main/java/org/openedx/core/domain/model/EnrollmentStatus.kt @@ -3,5 +3,5 @@ package org.openedx.core.domain.model data class EnrollmentStatus( val courseId: String, val courseName: String, - val isActive: Boolean + val recentlyActive: Boolean ) diff --git a/core/src/main/java/org/openedx/core/worker/CalendarSyncWorker.kt b/core/src/main/java/org/openedx/core/worker/CalendarSyncWorker.kt index 2c36f075b..39a6c5507 100644 --- a/core/src/main/java/org/openedx/core/worker/CalendarSyncWorker.kt +++ b/core/src/main/java/org/openedx/core/worker/CalendarSyncWorker.kt @@ -136,7 +136,7 @@ class CalendarSyncWorker( val courseId = enrollmentStatus.courseId try { createCalendarState(enrollmentStatus) - if (enrollmentStatus.isActive && isCourseSyncEnabled(courseId)) { + if (enrollmentStatus.recentlyActive && isCourseSyncEnabled(courseId)) { val courseDates = calendarInteractor.getCourseDates(courseId) val isCourseCalendarUpToDate = isCourseCalendarUpToDate(courseId, courseDates) if (!isCourseCalendarUpToDate) { @@ -191,7 +191,7 @@ class CalendarSyncWorker( if (courseCalendarStateChecksum == null) { val courseCalendarStateEntity = CourseCalendarStateEntity( courseId = enrollmentStatus.courseId, - isCourseSyncEnabled = enrollmentStatus.isActive + isCourseSyncEnabled = enrollmentStatus.recentlyActive ) calendarInteractor.insertCourseCalendarStateEntityToCache(courseCalendarStateEntity) } diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CoursesToSyncFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CoursesToSyncFragment.kt index 7b4d1d9d0..39e767e1b 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/calendar/CoursesToSyncFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CoursesToSyncFragment.kt @@ -281,7 +281,7 @@ private fun CourseCheckboxList( .filter { it.courseId in courseIds } .let { enrollments -> if (uiState.isHideInactiveCourses) { - enrollments.filter { it.isActive } + enrollments.filter { it.recentlyActive } } else { enrollments } @@ -299,7 +299,7 @@ private fun CourseCheckboxList( ?: false val annotatedString = buildAnnotatedString { append(course.courseName) - if (!course.isActive) { + if (!course.recentlyActive) { append(" ") withStyle( style = SpanStyle( @@ -327,7 +327,7 @@ private fun CourseCheckboxList( uncheckedColor = MaterialTheme.appColors.textFieldText ), checked = isCourseSyncEnabled, - enabled = course.isActive, + enabled = course.recentlyActive, onCheckedChange = { isEnabled -> onCourseSyncCheckChange(isEnabled, course.courseId) } From 1daedbec1b5738be689c21eb0f9c34a57d88b32e Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk <141041606+PavloNetrebchuk@users.noreply.github.com> Date: Thu, 17 Oct 2024 17:28:10 +0300 Subject: [PATCH 44/56] refactor: Reorganize offline logic for better readability (#386) --- .../core/module/DownloadWorkerController.kt | 27 +-- .../download/DownloadDialogManager.kt | 171 +++++++----------- .../offline/CourseOfflineViewModel.kt | 115 ++++++------ 3 files changed, 132 insertions(+), 181 deletions(-) diff --git a/core/src/main/java/org/openedx/core/module/DownloadWorkerController.kt b/core/src/main/java/org/openedx/core/module/DownloadWorkerController.kt index e440cfcc5..39612ae10 100644 --- a/core/src/main/java/org/openedx/core/module/DownloadWorkerController.kt +++ b/core/src/main/java/org/openedx/core/module/DownloadWorkerController.kt @@ -4,7 +4,6 @@ import android.content.Context import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkInfo import androidx.work.WorkManager -import com.google.common.util.concurrent.ListenableFuture import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch @@ -14,7 +13,6 @@ import org.openedx.core.module.db.DownloadModelEntity import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.download.FileDownloader import java.io.File -import java.util.concurrent.ExecutionException class DownloadWorkerController( context: Context, @@ -23,7 +21,6 @@ class DownloadWorkerController( ) { private val workManager = WorkManager.getInstance(context) - private var downloadTaskList = listOf() init { @@ -46,16 +43,15 @@ class DownloadWorkerController( } private suspend fun updateList() { - downloadTaskList = - downloadDao.getAllDataFlow().first().map { it.mapToDomain() }.filter { + downloadTaskList = downloadDao.getAllDataFlow().first() + .map { it.mapToDomain() } + .filter { it.downloadedState == DownloadedState.WAITING || it.downloadedState == DownloadedState.DOWNLOADING } } suspend fun saveModels(downloadModels: List) { - downloadDao.insertDownloadModel( - downloadModels.map { DownloadModelEntity.createFrom(it) } - ) + downloadDao.insertDownloadModel(downloadModels.map { DownloadModelEntity.createFrom(it) }) } suspend fun removeModel(id: String) { @@ -69,11 +65,9 @@ class DownloadWorkerController( downloadModels.forEach { downloadModel -> removeIds.add(downloadModel.id) - if (downloadModel.downloadedState == DownloadedState.DOWNLOADING) { hasDownloading = true } - try { File(downloadModel.path).delete() } catch (e: Exception) { @@ -97,19 +91,14 @@ class DownloadWorkerController( workManager.cancelAllWorkByTag(DownloadWorker.WORKER_TAG) } - private fun isWorkScheduled(tag: String): Boolean { - val statuses: ListenableFuture> = workManager.getWorkInfosByTag(tag) + val statuses = workManager.getWorkInfosByTag(tag) return try { - val workInfoList: List = statuses.get() - val workInfo = workInfoList.find { - (it.state == WorkInfo.State.RUNNING) or (it.state == WorkInfo.State.ENQUEUED) + val workInfo = statuses.get().find { + it.state == WorkInfo.State.RUNNING || it.state == WorkInfo.State.ENQUEUED } workInfo != null - } catch (e: ExecutionException) { - e.printStackTrace() - false - } catch (e: InterruptedException) { + } catch (e: Exception) { e.printStackTrace() false } diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt index 64a95d2d8..5a85ba191 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt @@ -27,91 +27,44 @@ class DownloadDialogManager( } private val uiState = MutableSharedFlow() + private val coroutineScope = CoroutineScope(Dispatchers.IO) init { - CoroutineScope(Dispatchers.IO).launch { - uiState.collect { uiState -> - when { - uiState.isDownloadFailed -> { - val dialog = DownloadErrorDialogFragment.newInstance( - dialogType = DownloadErrorDialogType.DOWNLOAD_FAILED, - uiState = uiState - ) - dialog.show( - uiState.fragmentManager, - DownloadErrorDialogFragment.DIALOG_TAG - ) - } - - uiState.isAllBlocksDownloaded -> { - val dialog = DownloadConfirmDialogFragment.newInstance( - dialogType = DownloadConfirmDialogType.REMOVE, - uiState = uiState - ) - dialog.show( - uiState.fragmentManager, - DownloadConfirmDialogFragment.DIALOG_TAG - ) - } - - !networkConnection.isOnline() -> { - val dialog = DownloadErrorDialogFragment.newInstance( - dialogType = DownloadErrorDialogType.NO_CONNECTION, - uiState = uiState - ) - dialog.show( - uiState.fragmentManager, - DownloadErrorDialogFragment.DIALOG_TAG - ) - } - - StorageManager.getFreeStorage() < uiState.sizeSum * DOWNLOAD_SIZE_FACTOR -> { - val dialog = DownloadStorageErrorDialogFragment.newInstance( - uiState = uiState - ) - dialog.show( - uiState.fragmentManager, - DownloadStorageErrorDialogFragment.DIALOG_TAG - ) - } - - corePreferences.videoSettings.wifiDownloadOnly && !networkConnection.isWifiConnected() -> { - val dialog = DownloadErrorDialogFragment.newInstance( - dialogType = DownloadErrorDialogType.WIFI_REQUIRED, - uiState = uiState - ) - dialog.show( - uiState.fragmentManager, - DownloadErrorDialogFragment.DIALOG_TAG - ) - } - - !corePreferences.videoSettings.wifiDownloadOnly && !networkConnection.isWifiConnected() -> { - val dialog = DownloadConfirmDialogFragment.newInstance( - dialogType = DownloadConfirmDialogType.DOWNLOAD_ON_CELLULAR, - uiState = uiState - ) - dialog.show( - uiState.fragmentManager, - DownloadConfirmDialogFragment.DIALOG_TAG - ) - } - - uiState.sizeSum >= MAX_CELLULAR_SIZE -> { - val dialog = DownloadConfirmDialogFragment.newInstance( - dialogType = DownloadConfirmDialogType.CONFIRM, - uiState = uiState - ) - dialog.show( - uiState.fragmentManager, - DownloadConfirmDialogFragment.DIALOG_TAG - ) - } - - else -> { - uiState.saveDownloadModels() - } + coroutineScope.launch { + uiState.collect { state -> + val dialog = when { + state.isDownloadFailed -> DownloadErrorDialogFragment.newInstance( + dialogType = DownloadErrorDialogType.DOWNLOAD_FAILED, uiState = state + ) + + state.isAllBlocksDownloaded -> DownloadConfirmDialogFragment.newInstance( + dialogType = DownloadConfirmDialogType.REMOVE, uiState = state + ) + + !networkConnection.isOnline() -> DownloadErrorDialogFragment.newInstance( + dialogType = DownloadErrorDialogType.NO_CONNECTION, uiState = state + ) + + StorageManager.getFreeStorage() < state.sizeSum * DOWNLOAD_SIZE_FACTOR -> DownloadStorageErrorDialogFragment.newInstance( + uiState = state + ) + + corePreferences.videoSettings.wifiDownloadOnly && !networkConnection.isWifiConnected() -> DownloadErrorDialogFragment.newInstance( + dialogType = DownloadErrorDialogType.WIFI_REQUIRED, uiState = state + ) + + !corePreferences.videoSettings.wifiDownloadOnly && !networkConnection.isWifiConnected() -> DownloadConfirmDialogFragment.newInstance( + dialogType = DownloadConfirmDialogType.DOWNLOAD_ON_CELLULAR, uiState = state + ) + + state.sizeSum >= MAX_CELLULAR_SIZE -> DownloadConfirmDialogFragment.newInstance( + dialogType = DownloadConfirmDialogType.CONFIRM, uiState = state + ) + + else -> null } + + dialog?.show(state.fragmentManager, dialog::class.java.simpleName) ?: state.saveDownloadModels() } } } @@ -141,7 +94,7 @@ class DownloadDialogManager( fragmentManager: FragmentManager, removeDownloadModels: () -> Unit, ) { - CoroutineScope(Dispatchers.IO).launch { + coroutineScope.launch { uiState.emit( DownloadDialogUIState( downloadDialogItems = listOf(downloadDialogItem), @@ -161,39 +114,44 @@ class DownloadDialogManager( fragmentManager: FragmentManager, ) { createDownloadItems( - downloadModel = downloadModel, + downloadModels = downloadModel, fragmentManager = fragmentManager, ) } private fun createDownloadItems( - downloadModel: List, + downloadModels: List, fragmentManager: FragmentManager, ) { - CoroutineScope(Dispatchers.IO).launch { - val courseIds = downloadModel.map { it.courseId }.distinct() - val blockIds = downloadModel.map { it.id } + coroutineScope.launch { + val courseIds = downloadModels.map { it.courseId }.distinct() + val blockIds = downloadModels.map { it.id } val notDownloadedSubSections = mutableListOf() val allDownloadDialogItems = mutableListOf() + courseIds.forEach { courseId -> val courseStructure = interactor.getCourseStructureFromCache(courseId) val allSubSectionBlocks = courseStructure.blockData.filter { it.type == BlockType.SEQUENTIAL } - allSubSectionBlocks.forEach { subSectionsBlock -> - val verticalBlocks = courseStructure.blockData.filter { it.id in subSectionsBlock.descendants } + + allSubSectionBlocks.forEach { subSectionBlock -> + val verticalBlocks = courseStructure.blockData.filter { it.id in subSectionBlock.descendants } val blocks = courseStructure.blockData.filter { it.id in verticalBlocks.flatMap { it.descendants } && it.id in blockIds } - val size = blocks.sumOf { getFileSize(it) } - if (blocks.isNotEmpty()) notDownloadedSubSections.add(subSectionsBlock) - if (size > 0) { - val downloadDialogItem = DownloadDialogItem( - title = subSectionsBlock.displayName, - size = size + val totalSize = blocks.sumOf { getFileSize(it) } + + if (blocks.isNotEmpty()) notDownloadedSubSections.add(subSectionBlock) + if (totalSize > 0) { + allDownloadDialogItems.add( + DownloadDialogItem( + title = subSectionBlock.displayName, + size = totalSize + ) ) - allDownloadDialogItems.add(downloadDialogItem) } } } + uiState.emit( DownloadDialogUIState( downloadDialogItems = allDownloadDialogItems, @@ -203,8 +161,8 @@ class DownloadDialogManager( fragmentManager = fragmentManager, removeDownloadModels = {}, saveDownloadModels = { - CoroutineScope(Dispatchers.IO).launch { - workerController.saveModels(downloadModel) + coroutineScope.launch { + workerController.saveModels(downloadModels) } } ) @@ -221,12 +179,12 @@ class DownloadDialogManager( removeDownloadModels: (blockId: String) -> Unit, saveDownloadModels: (blockId: String) -> Unit, ) { - CoroutineScope(Dispatchers.IO).launch { + coroutineScope.launch { val courseStructure = interactor.getCourseStructure(courseId, false) val downloadModelIds = interactor.getAllDownloadModels().map { it.id } - val downloadDialogItems = subSectionsBlocks.mapNotNull { subSectionsBlock -> - val verticalBlocks = courseStructure.blockData.filter { it.id in subSectionsBlock.descendants } + val downloadDialogItems = subSectionsBlocks.mapNotNull { subSectionBlock -> + val verticalBlocks = courseStructure.blockData.filter { it.id in subSectionBlock.descendants } val blocks = verticalBlocks.flatMap { verticalBlock -> courseStructure.blockData.filter { it.id in verticalBlock.descendants && @@ -235,7 +193,7 @@ class DownloadDialogManager( } } val size = blocks.sumOf { getFileSize(it) } - if (size > 0) DownloadDialogItem(title = subSectionsBlock.displayName, size = size) else null + if (size > 0) DownloadDialogItem(title = subSectionBlock.displayName, size = size) else null } uiState.emit( @@ -252,12 +210,11 @@ class DownloadDialogManager( } } - private fun getFileSize(block: Block): Long { return when { - block.type == BlockType.VIDEO -> block.downloadModel?.size ?: 0 - block.isxBlock -> block.offlineDownload?.fileSize ?: 0 - else -> 0 + block.type == BlockType.VIDEO -> block.downloadModel?.size ?: 0L + block.isxBlock -> block.offlineDownload?.fileSize ?: 0L + else -> 0L } } } diff --git a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt index 230b30deb..6fc72607f 100644 --- a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt @@ -83,12 +83,14 @@ class CourseOfflineViewModel( val courseStructure = courseInteractor.getCourseStructureFromCache(courseId) val downloadModels = courseInteractor.getAllDownloadModels() val subSectionsBlocks = allBlocks.values.filter { it.type == BlockType.SEQUENTIAL } - val notDownloadedSubSectionBlocks = subSectionsBlocks.mapNotNull { subSectionsBlock -> - val verticalBlocks = allBlocks.values.filter { it.id in subSectionsBlock.descendants } + val notDownloadedSubSectionBlocks = subSectionsBlocks.mapNotNull { subSection -> + val verticalBlocks = allBlocks.values.filter { it.id in subSection.descendants } val notDownloadedBlocks = courseStructure.blockData.filter { block -> - block.id in verticalBlocks.flatMap { it.descendants } && block.isDownloadable && !downloadModels.any { it.id == block.id } + block.id in verticalBlocks.flatMap { it.descendants } && + block.isDownloadable && + downloadModels.none { it.id == block.id } } - if (notDownloadedBlocks.isNotEmpty()) subSectionsBlock else null + if (notDownloadedBlocks.isNotEmpty()) subSection else null } downloadDialogManager.showPopup( @@ -105,10 +107,9 @@ class CourseOfflineViewModel( } fun removeDownloadModel(downloadModel: DownloadModel, fragmentManager: FragmentManager) { - val icon = if (downloadModel.type == FileType.VIDEO) { - Icons.Outlined.SmartDisplay - } else { - Icons.AutoMirrored.Outlined.InsertDriveFile + val icon = when (downloadModel.type) { + FileType.VIDEO -> Icons.Outlined.SmartDisplay + else -> Icons.AutoMirrored.Outlined.InsertDriveFile } val downloadDialogItem = DownloadDialogItem( title = downloadModel.title, @@ -120,26 +121,25 @@ class CourseOfflineViewModel( fragmentManager = fragmentManager, removeDownloadModels = { super.removeBlockDownloadModel(downloadModel.id) - }, + } ) } fun deleteAll(fragmentManager: FragmentManager) { viewModelScope.launch { val downloadModels = courseInteractor.getAllDownloadModels().filter { it.courseId == courseId } + val totalSize = downloadModels.sumOf { it.size } val downloadDialogItem = DownloadDialogItem( title = courseTitle, - size = downloadModels.sumOf { it.size }, + size = totalSize, icon = Icons.AutoMirrored.Outlined.InsertDriveFile ) downloadDialogManager.showRemoveDownloadModelPopup( downloadDialogItem = downloadDialogItem, fragmentManager = fragmentManager, removeDownloadModels = { - downloadModels.forEach { - super.removeBlockDownloadModel(it.id) - } - }, + downloadModels.forEach { super.removeBlockDownloadModel(it.id) } + } ) } } @@ -148,9 +148,7 @@ class CourseOfflineViewModel( viewModelScope.launch { courseInteractor.getAllDownloadModels() .filter { it.courseId == courseId && it.downloadedState.isWaitingOrDownloading } - .forEach { - removeBlockDownloadModel(it.id) - } + .forEach { removeBlockDownloadModel(it.id) } } } @@ -159,57 +157,64 @@ class CourseOfflineViewModel( setBlocks(courseStructure.blockData) allBlocks.values .filter { it.type == BlockType.SEQUENTIAL } - .forEach { - addDownloadableChildrenForSequentialBlock(it) - } - + .forEach { addDownloadableChildrenForSequentialBlock(it) } } private fun getOfflineData() { viewModelScope.launch { val courseStructure = courseInteractor.getCourseStructureFromCache(courseId) - val downloadableFilesSize = getFilesSize(courseStructure.blockData) - if (downloadableFilesSize == 0L) return@launch - - courseInteractor.getDownloadModels().collect { - val downloadModels = it.filter { it.downloadedState.isDownloaded && it.courseId == courseId } - val downloadedModelsIds = downloadModels.map { it.id } - val downloadedBlocks = courseStructure.blockData.filter { it.id in downloadedModelsIds } - val downloadedFilesSize = getFilesSize(downloadedBlocks) - val realDownloadedFilesSize = downloadModels.sumOf { it.size } - val largestDownloads = downloadModels - .sortedByDescending { it.size } - .take(5) - - _uiState.update { - it.copy( - isHaveDownloadableBlocks = true, - largestDownloads = largestDownloads, - readyToDownloadSize = (downloadableFilesSize - downloadedFilesSize).toFileSize(1, false), - downloadedSize = realDownloadedFilesSize.toFileSize(1, false), - progressBarValue = downloadedFilesSize.toFloat() / downloadableFilesSize.toFloat() - ) - } + val totalDownloadableSize = getFilesSize(courseStructure.blockData) + + if (totalDownloadableSize == 0L) return@launch + + courseInteractor.getDownloadModels().collect { downloadModels -> + val completedDownloads = + downloadModels.filter { it.downloadedState.isDownloaded && it.courseId == courseId } + val completedDownloadIds = completedDownloads.map { it.id } + val downloadedBlocks = courseStructure.blockData.filter { it.id in completedDownloadIds } + + updateUIState( + totalDownloadableSize, + completedDownloads, + downloadedBlocks + ) } } } - private fun getFilesSize(block: List): Long { - return block.filter { it.isDownloadable }.sumOf { + private fun updateUIState( + totalDownloadableSize: Long, + completedDownloads: List, + downloadedBlocks: List + ) { + val downloadedSize = getFilesSize(downloadedBlocks) + val realDownloadedSize = completedDownloads.sumOf { it.size } + val largestDownloads = completedDownloads + .sortedByDescending { it.size } + .take(5) + + _uiState.update { + it.copy( + isHaveDownloadableBlocks = true, + largestDownloads = largestDownloads, + readyToDownloadSize = (totalDownloadableSize - downloadedSize).toFileSize(1, false), + downloadedSize = realDownloadedSize.toFileSize(1, false), + progressBarValue = downloadedSize.toFloat() / totalDownloadableSize.toFloat() + ) + } + } + + private fun getFilesSize(blocks: List): Long { + return blocks.filter { it.isDownloadable }.sumOf { when (it.downloadableType) { FileType.VIDEO -> { - val videoInfo = - it.studentViewData?.encodedVideos?.getPreferredVideoInfoForDownloading( - preferencesManager.videoSettings.videoDownloadQuality - ) - videoInfo?.fileSize ?: 0 - } - - FileType.X_BLOCK -> { - it.offlineDownload?.fileSize ?: 0 + it.studentViewData?.encodedVideos + ?.getPreferredVideoInfoForDownloading(preferencesManager.videoSettings.videoDownloadQuality) + ?.fileSize ?: 0 } - null -> 0 + FileType.X_BLOCK -> it.offlineDownload?.fileSize ?: 0 + else -> 0 } } } From e2ee565dd71ebd891562d663443b3d06a57af323 Mon Sep 17 00:00:00 2001 From: Kshitij Sobti Date: Tue, 29 Oct 2024 14:29:21 +0530 Subject: [PATCH 45/56] fix: Support pull to refresh for empty dashboard (#375) This change adds support for pull to refresh when the course list is empty. --- .../presentation/DashboardListFragment.kt | 50 ++++++++++++++++--- dashboard/src/main/res/values/strings.xml | 1 + 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt index fefcde867..eea1b9db1 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt @@ -24,7 +24,9 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Divider import androidx.compose.material.ExperimentalMaterialApi @@ -35,6 +37,7 @@ import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.filled.AccessTime import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState @@ -305,7 +308,7 @@ internal fun DashboardListView( is DashboardUIState.Empty -> { Box( modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center + contentAlignment = Alignment.Center, ) { Column( Modifier @@ -475,13 +478,18 @@ private fun CourseItem( @Composable private fun EmptyState() { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, ) { Column( - Modifier.width(185.dp), - horizontalAlignment = Alignment.CenterHorizontally + Modifier + .fillMaxSize() + .weight(1f), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, ) { Icon( painter = painterResource(id = R.drawable.dashboard_ic_empty), @@ -499,6 +507,13 @@ private fun EmptyState() { textAlign = TextAlign.Center ) } + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.dashboard_pull_to_refresh), + color = MaterialTheme.appColors.textSecondary, + style = MaterialTheme.appTypography.labelSmall, + textAlign = TextAlign.Center + ) } } @@ -577,6 +592,29 @@ private fun DashboardListViewTabletPreview() { } } + +@Preview(uiMode = UI_MODE_NIGHT_NO) +@Preview(uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun EmptyStatePreview() { + OpenEdXTheme { + DashboardListView( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + apiHostUrl = "http://localhost:8000", + state = DashboardUIState.Empty, + uiMessage = null, + onSwipeRefresh = {}, + onItemClick = {}, + onReloadClick = {}, + hasInternetConnection = true, + refreshing = false, + canLoadMore = false, + paginationCallback = {}, + appUpgradeParameters = AppUpdateState.AppUpgradeParameters() + ) + } +} + private val mockCourseAssignments = CourseAssignments(null, emptyList()) private val mockCourseEnrolled = EnrolledCourse( auditAccessExpires = Date(), diff --git a/dashboard/src/main/res/values/strings.xml b/dashboard/src/main/res/values/strings.xml index 80c05bc42..01979f21d 100644 --- a/dashboard/src/main/res/values/strings.xml +++ b/dashboard/src/main/res/values/strings.xml @@ -19,6 +19,7 @@ You are not currently enrolled in any courses, would you like to explore the course catalog? Find a Course No %1$s Courses + Swipe down to refresh %1$d Past Due Assignment From aaefaa4c10eb1b46eb078b741b9a8514602970df Mon Sep 17 00:00:00 2001 From: Igor Date: Tue, 29 Oct 2024 10:05:59 +0100 Subject: [PATCH 46/56] fix: Remove student_view_mutli_device dependency for HTML XBlocks (#388) --- .../presentation/unit/container/CourseUnitContainerAdapter.kt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt index a8953baf1..0610983e8 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt @@ -45,10 +45,6 @@ class CourseUnitContainerAdapter( createDiscussionFragment(block) } - !block.studentViewMultiDevice -> { - createNotAvailableUnitFragment(block, NotAvailableUnitType.MOBILE_UNSUPPORTED) - } - block.isHTMLBlock || block.isProblemBlock || block.isOpenAssessmentBlock || block.isDragAndDropBlock || block.isWordCloudBlock || block.isLTIConsumerBlock || block.isSurveyBlock -> { val lastModified = if (downloadedModel != null && noNetwork) { From 0de8db166b4d41265d763ec5b957de7c8577b4fc Mon Sep 17 00:00:00 2001 From: Kaustav Banerjee Date: Tue, 29 Oct 2024 19:09:24 +0530 Subject: [PATCH 47/56] feat: add flag to remove registration from app (#387) --- Documentation/ConfigurationManagement.md | 3 +- .../logistration/LogistrationFragment.kt | 29 ++++++++++++++-- .../logistration/LogistrationViewModel.kt | 1 + .../auth/presentation/signin/SignInUIState.kt | 1 + .../presentation/signin/SignInViewModel.kt | 1 + .../presentation/signin/compose/SignInView.kt | 2 +- .../signin/SignInViewModelTest.kt | 1 + .../java/org/openedx/core/config/Config.kt | 5 +++ .../java/org/openedx/core/ui/ComposeCommon.kt | 34 ++++++++++++------- default_config/dev/config.yaml | 2 ++ default_config/prod/config.yaml | 2 ++ default_config/stage/config.yaml | 2 ++ .../presentation/NativeDiscoveryFragment.kt | 7 +++- .../presentation/NativeDiscoveryViewModel.kt | 1 + .../presentation/WebViewDiscoveryFragment.kt | 6 +++- .../presentation/WebViewDiscoveryViewModel.kt | 1 + .../detail/CourseDetailsFragment.kt | 7 +++- .../detail/CourseDetailsViewModel.kt | 1 + .../presentation/info/CourseInfoFragment.kt | 6 +++- .../presentation/info/CourseInfoViewModel.kt | 2 ++ .../search/CourseSearchFragment.kt | 7 +++- .../search/CourseSearchViewModel.kt | 1 + 22 files changed, 99 insertions(+), 23 deletions(-) diff --git a/Documentation/ConfigurationManagement.md b/Documentation/ConfigurationManagement.md index c3786b1d6..0e8456ed0 100644 --- a/Documentation/ConfigurationManagement.md +++ b/Documentation/ConfigurationManagement.md @@ -89,7 +89,8 @@ android: - **WHATS_NEW_ENABLED:** Enables the "What's New" feature to present the latest changes to the user. - **SOCIAL_AUTH_ENABLED:** Enables SSO buttons on the SignIn and SignUp screens. - **COURSE_DROPDOWN_NAVIGATION_ENABLED:** Enables an alternative navigation through units. -- **COURSE_UNIT_PROGRESS_ENABLED:** Enables the display of the unit progress within the courseware. +- **COURSE_UNIT_PROGRESS_ENABLED:** Enables the display of the unit progress within the courseware. +- **REGISTRATION_ENABLED:** Enables user registration from the app. ## Future Support - To add config related to some other service, create a class, e.g. `ServiceNameConfig.kt`, to be able to populate related fields. diff --git a/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationFragment.kt b/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationFragment.kt index ae3d2365e..6faca63ce 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationFragment.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationFragment.kt @@ -74,7 +74,8 @@ class LogistrationFragment : Fragment() { }, onSearchClick = { querySearch -> viewModel.navigateToDiscovery(parentFragmentManager, querySearch) - } + }, + isRegistrationEnabled = viewModel.isRegistrationEnabled ) } } @@ -98,6 +99,7 @@ private fun LogistrationScreen( onSearchClick: (String) -> Unit, onRegisterClick: () -> Unit, onSignInClick: () -> Unit, + isRegistrationEnabled: Boolean, ) { var textFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { @@ -183,7 +185,11 @@ private fun LogistrationScreen( Spacer(modifier = Modifier.weight(1f)) - AuthButtonsPanel(onRegisterClick = onRegisterClick, onSignInClick = onSignInClick) + AuthButtonsPanel( + onRegisterClick = onRegisterClick, + onSignInClick = onSignInClick, + showRegisterButton = isRegistrationEnabled + ) } } } @@ -199,7 +205,24 @@ private fun LogistrationPreview() { LogistrationScreen( onSearchClick = {}, onSignInClick = {}, - onRegisterClick = {} + onRegisterClick = {}, + isRegistrationEnabled = true, + ) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "NEXUS_9_Light", device = Devices.NEXUS_9, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "NEXUS_9_Night", device = Devices.NEXUS_9, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun LogistrationRegistrationDisabledPreview() { + OpenEdXTheme { + LogistrationScreen( + onSearchClick = {}, + onSignInClick = {}, + onRegisterClick = {}, + isRegistrationEnabled = false, ) } } diff --git a/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationViewModel.kt b/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationViewModel.kt index 3306ccfa3..090c03251 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationViewModel.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationViewModel.kt @@ -17,6 +17,7 @@ class LogistrationViewModel( ) : BaseViewModel() { private val discoveryTypeWebView get() = config.getDiscoveryConfig().isViewTypeWebView() + val isRegistrationEnabled get() = config.isRegistrationEnabled() init { logLogistrationScreenEvent() diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInUIState.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInUIState.kt index 9ce5cfc98..7d472882f 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInUIState.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInUIState.kt @@ -18,6 +18,7 @@ internal data class SignInUIState( val isMicrosoftAuthEnabled: Boolean = false, val isSocialAuthEnabled: Boolean = false, val isLogistrationEnabled: Boolean = false, + val isRegistrationEnabled: Boolean = true, val showProgress: Boolean = false, val loginSuccess: Boolean = false, val agreement: RegistrationField? = null, diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt index 53b42f46d..00b91d71f 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt @@ -67,6 +67,7 @@ class SignInViewModel( isMicrosoftAuthEnabled = config.getMicrosoftConfig().isEnabled(), isSocialAuthEnabled = config.isSocialAuthEnabled(), isLogistrationEnabled = config.isPreLoginExperienceEnabled(), + isRegistrationEnabled = config.isRegistrationEnabled(), agreement = agreementProvider.getAgreement(isSignIn = true)?.createHonorCodeField(), ) ) diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt index cb77faa37..52fa9b4c4 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt @@ -264,7 +264,7 @@ private fun AuthForm( .fillMaxWidth() .padding(top = 20.dp, bottom = 36.dp) ) { - if (state.isLogistrationEnabled.not()) { + if (state.isLogistrationEnabled.not() && state.isRegistrationEnabled) { Text( modifier = Modifier .testTag("txt_register") diff --git a/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt b/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt index f991db3ad..bef45ad82 100644 --- a/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt +++ b/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt @@ -95,6 +95,7 @@ class SignInViewModelTest { every { calendarPreferences.clearCalendarPreferences() } returns Unit coEvery { calendarInteractor.clearCalendarCachedData() } returns Unit every { analytics.logScreenEvent(any(), any()) } returns Unit + every { config.isRegistrationEnabled() } returns true } @After 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 528ff4cc8..13b37b62e 100644 --- a/core/src/main/java/org/openedx/core/config/Config.kt +++ b/core/src/main/java/org/openedx/core/config/Config.kt @@ -115,6 +115,10 @@ class Config(context: Context) { return getObjectOrNewInstance(UI_COMPONENTS, UIConfig::class.java) } + fun isRegistrationEnabled(): Boolean { + return getBoolean(REGISTRATION_ENABLED, true) + } + private fun getString(key: String, defaultValue: String = ""): String { val element = getObject(key) return if (element != null) { @@ -168,6 +172,7 @@ class Config(context: Context) { private const val GOOGLE = "GOOGLE" private const val MICROSOFT = "MICROSOFT" private const val PRE_LOGIN_EXPERIENCE_ENABLED = "PRE_LOGIN_EXPERIENCE_ENABLED" + private const val REGISTRATION_ENABLED = "REGISTRATION_ENABLED" private const val DISCOVERY = "DISCOVERY" private const val PROGRAM = "PROGRAM" private const val DASHBOARD = "DASHBOARD" 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 eb9f92800..1f3f1850f 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt @@ -1183,24 +1183,32 @@ fun ConnectionErrorView( fun AuthButtonsPanel( onRegisterClick: () -> Unit, onSignInClick: () -> Unit, + showRegisterButton: Boolean, ) { Row { - OpenEdXButton( - modifier = Modifier - .testTag("btn_register") - .width(0.dp) - .weight(1f), - text = stringResource(id = R.string.core_register), - textColor = MaterialTheme.appColors.primaryButtonText, - backgroundColor = MaterialTheme.appColors.secondaryButtonBackground, - onClick = { onRegisterClick() } - ) + if (showRegisterButton) { + OpenEdXButton( + modifier = Modifier + .testTag("btn_register") + .width(0.dp) + .weight(1f), + text = stringResource(id = R.string.core_register), + textColor = MaterialTheme.appColors.primaryButtonText, + backgroundColor = MaterialTheme.appColors.secondaryButtonBackground, + onClick = { onRegisterClick() } + ) + } OpenEdXOutlinedButton( modifier = Modifier .testTag("btn_sign_in") - .width(100.dp) - .padding(start = 16.dp), + .then( + if (showRegisterButton) { + Modifier.width(100.dp).padding(start = 16.dp) + } else { + Modifier.weight(1f) + } + ), text = stringResource(id = R.string.core_sign_in), onClick = { onSignInClick() }, textColor = MaterialTheme.appColors.secondaryButtonBorderedText, @@ -1333,7 +1341,7 @@ private fun ToolbarPreview() { @Preview @Composable private fun AuthButtonsPanelPreview() { - AuthButtonsPanel(onRegisterClick = {}, onSignInClick = {}) + AuthButtonsPanel(onRegisterClick = {}, onSignInClick = {}, showRegisterButton = true) } @Preview diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index 99f2f0f3d..19c1e600a 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -84,6 +84,8 @@ TOKEN_TYPE: "JWT" WHATS_NEW_ENABLED: false #feature flag enable Social Login buttons SOCIAL_AUTH_ENABLED: false +#feature flag to enable registration from app +REGISTRATION_ENABLED: true #Course navigation feature flags UI_COMPONENTS: COURSE_DROPDOWN_NAVIGATION_ENABLED: false diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml index 99f2f0f3d..19c1e600a 100644 --- a/default_config/prod/config.yaml +++ b/default_config/prod/config.yaml @@ -84,6 +84,8 @@ TOKEN_TYPE: "JWT" WHATS_NEW_ENABLED: false #feature flag enable Social Login buttons SOCIAL_AUTH_ENABLED: false +#feature flag to enable registration from app +REGISTRATION_ENABLED: true #Course navigation feature flags UI_COMPONENTS: COURSE_DROPDOWN_NAVIGATION_ENABLED: false diff --git a/default_config/stage/config.yaml b/default_config/stage/config.yaml index 99f2f0f3d..19c1e600a 100644 --- a/default_config/stage/config.yaml +++ b/default_config/stage/config.yaml @@ -84,6 +84,8 @@ TOKEN_TYPE: "JWT" WHATS_NEW_ENABLED: false #feature flag enable Social Login buttons SOCIAL_AUTH_ENABLED: false +#feature flag to enable registration from app +REGISTRATION_ENABLED: true #Course navigation feature flags UI_COMPONENTS: COURSE_DROPDOWN_NAVIGATION_ENABLED: false diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt index 3a50ab707..1e9c6663f 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt @@ -117,6 +117,7 @@ class NativeDiscoveryFragment : Fragment() { hasInternetConnection = viewModel.hasInternetConnection, canShowBackButton = viewModel.canShowBackButton, isUserLoggedIn = viewModel.isUserLoggedIn, + isRegistrationEnabled = viewModel.isRegistrationEnabled, appUpgradeParameters = AppUpdateState.AppUpgradeParameters( appUpgradeEvent = appUpgradeEvent, wasUpdateDialogClosed = wasUpdateDialogClosed, @@ -209,6 +210,7 @@ internal fun DiscoveryScreen( hasInternetConnection: Boolean, canShowBackButton: Boolean, isUserLoggedIn: Boolean, + isRegistrationEnabled: Boolean, appUpgradeParameters: AppUpdateState.AppUpgradeParameters, onSearchClick: () -> Unit, onSwipeRefresh: () -> Unit, @@ -252,7 +254,8 @@ internal fun DiscoveryScreen( ) { AuthButtonsPanel( onRegisterClick = onRegisterClick, - onSignInClick = onSignInClick + onSignInClick = onSignInClick, + showRegisterButton = isRegistrationEnabled ) } } @@ -517,6 +520,7 @@ private fun DiscoveryScreenPreview() { refreshing = false, hasInternetConnection = true, isUserLoggedIn = false, + isRegistrationEnabled = true, appUpgradeParameters = AppUpdateState.AppUpgradeParameters(), onSignInClick = {}, onRegisterClick = {}, @@ -558,6 +562,7 @@ private fun DiscoveryScreenTabletPreview() { refreshing = false, hasInternetConnection = true, isUserLoggedIn = true, + isRegistrationEnabled = true, appUpgradeParameters = AppUpdateState.AppUpgradeParameters(), onSignInClick = {}, onRegisterClick = {}, diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryViewModel.kt b/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryViewModel.kt index 3f1098433..19bfb21f9 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryViewModel.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryViewModel.kt @@ -33,6 +33,7 @@ class NativeDiscoveryViewModel( val apiHostUrl get() = config.getApiHostURL() val isUserLoggedIn get() = corePreferences.user != null val canShowBackButton get() = config.isPreLoginExperienceEnabled() && !isUserLoggedIn + val isRegistrationEnabled: Boolean get() = config.isRegistrationEnabled() private val _uiState = MutableLiveData(DiscoveryUIState.Loading) val uiState: LiveData diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryFragment.kt index 6696e765b..fcd185efe 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryFragment.kt @@ -91,6 +91,7 @@ class WebViewDiscoveryFragment : Fragment() { isPreLogin = viewModel.isPreLogin, contentUrl = viewModel.discoveryUrl, uriScheme = viewModel.uriScheme, + isRegistrationEnabled = viewModel.isRegistrationEnabled, hasInternetConnection = hasInternetConnection, checkInternetConnection = { hasInternetConnection = viewModel.hasInternetConnection @@ -173,6 +174,7 @@ private fun WebViewDiscoveryScreen( isPreLogin: Boolean, contentUrl: String, uriScheme: String, + isRegistrationEnabled: Boolean, hasInternetConnection: Boolean, checkInternetConnection: () -> Unit, onWebPageUpdated: (String) -> Unit, @@ -206,7 +208,8 @@ private fun WebViewDiscoveryScreen( ) { AuthButtonsPanel( onRegisterClick = onRegisterClick, - onSignInClick = onSignInClick + onSignInClick = onSignInClick, + showRegisterButton = isRegistrationEnabled ) } } @@ -363,6 +366,7 @@ private fun WebViewDiscoveryScreenPreview() { contentUrl = "https://www.example.com/", isPreLogin = false, uriScheme = "", + isRegistrationEnabled = true, hasInternetConnection = false, checkInternetConnection = {}, onWebPageUpdated = {}, diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryViewModel.kt b/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryViewModel.kt index e786a3970..94f62574d 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryViewModel.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryViewModel.kt @@ -21,6 +21,7 @@ class WebViewDiscoveryViewModel( private val webViewConfig get() = config.getDiscoveryConfig().webViewConfig val isPreLogin get() = config.isPreLoginExperienceEnabled() && corePreferences.user == null + val isRegistrationEnabled: Boolean get() = config.isRegistrationEnabled() private var _discoveryUrl = webViewConfig.baseUrl val discoveryUrl: String diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt index 4c7eb1da6..32c795c56 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt @@ -142,6 +142,7 @@ class CourseDetailsFragment : Fragment() { ), hasInternetConnection = viewModel.hasInternetConnection, isUserLoggedIn = viewModel.isUserLoggedIn, + isRegistrationEnabled = viewModel.isRegistrationEnabled, onReloadClick = { viewModel.getCourseDetail() }, @@ -211,6 +212,7 @@ internal fun CourseDetailsScreen( htmlBody: String, hasInternetConnection: Boolean, isUserLoggedIn: Boolean, + isRegistrationEnabled: Boolean, onReloadClick: () -> Unit, onBackClick: () -> Unit, onButtonClick: () -> Unit, @@ -238,7 +240,8 @@ internal fun CourseDetailsScreen( Box(modifier = Modifier.padding(horizontal = 16.dp, vertical = 32.dp)) { AuthButtonsPanel( onRegisterClick = onRegisterClick, - onSignInClick = onSignInClick + onSignInClick = onSignInClick, + showRegisterButton = isRegistrationEnabled ) } } @@ -694,6 +697,7 @@ private fun CourseDetailNativeContentPreview() { apiHostUrl = "http://localhost:8000", hasInternetConnection = false, isUserLoggedIn = true, + isRegistrationEnabled = true, htmlBody = "Preview text", onReloadClick = {}, onBackClick = {}, @@ -716,6 +720,7 @@ private fun CourseDetailNativeContentTabletPreview() { apiHostUrl = "http://localhost:8000", hasInternetConnection = false, isUserLoggedIn = true, + isRegistrationEnabled = true, htmlBody = "Preview text", onReloadClick = {}, onBackClick = {}, diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModel.kt b/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModel.kt index 817363fa3..81b36e651 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModel.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModel.kt @@ -35,6 +35,7 @@ class CourseDetailsViewModel( ) : BaseViewModel() { val apiHostUrl get() = config.getApiHostURL() val isUserLoggedIn get() = corePreferences.user != null + val isRegistrationEnabled: Boolean get() = config.isRegistrationEnabled() private val _uiState = MutableLiveData(CourseDetailsUIState.Loading) val uiState: LiveData diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoFragment.kt index d34233f1b..1345ae5c6 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoFragment.kt @@ -122,6 +122,7 @@ class CourseInfoFragment : Fragment() { uiMessage = uiMessage, uriScheme = viewModel.uriScheme, hasInternetConnection = hasInternetConnection, + isRegistrationEnabled = viewModel.isRegistrationEnabled, checkInternetConnection = { hasInternetConnection = viewModel.hasInternetConnection }, @@ -222,6 +223,7 @@ private fun CourseInfoScreen( uiState: CourseInfoUIState, uiMessage: UIMessage?, uriScheme: String, + isRegistrationEnabled: Boolean, hasInternetConnection: Boolean, checkInternetConnection: () -> Unit, onRegisterClick: () -> Unit, @@ -250,7 +252,8 @@ private fun CourseInfoScreen( ) { AuthButtonsPanel( onRegisterClick = onRegisterClick, - onSignInClick = onSignInClick + onSignInClick = onSignInClick, + showRegisterButton = isRegistrationEnabled ) } } @@ -364,6 +367,7 @@ fun CourseInfoScreenPreview() { ), uiMessage = null, uriScheme = "", + isRegistrationEnabled = true, hasInternetConnection = false, checkInternetConnection = {}, onRegisterClick = {}, diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoViewModel.kt b/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoViewModel.kt index 487027e8f..87c64c770 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoViewModel.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoViewModel.kt @@ -64,6 +64,8 @@ class CourseInfoViewModel( val hasInternetConnection: Boolean get() = networkConnection.isOnline() + val isRegistrationEnabled: Boolean get() = config.isRegistrationEnabled() + val uriScheme: String get() = config.getUriScheme() private val webViewConfig get() = config.getDiscoveryConfig().webViewConfig diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/search/CourseSearchFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/search/CourseSearchFragment.kt index e13e6f0bb..72a5aa909 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/search/CourseSearchFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/search/CourseSearchFragment.kt @@ -118,6 +118,7 @@ class CourseSearchFragment : Fragment() { refreshing = refreshing, querySearch = querySearch, isUserLoggedIn = viewModel.isUserLoggedIn, + isRegistrationEnabled = viewModel.isRegistrationEnabled, onBackClick = { requireActivity().supportFragmentManager.popBackStack() }, @@ -171,6 +172,7 @@ private fun CourseSearchScreen( refreshing: Boolean, querySearch: String, isUserLoggedIn: Boolean, + isRegistrationEnabled: Boolean, onBackClick: () -> Unit, onSearchTextChanged: (String) -> Unit, onSwipeRefresh: () -> Unit, @@ -222,7 +224,8 @@ private fun CourseSearchScreen( ) { AuthButtonsPanel( onRegisterClick = onRegisterClick, - onSignInClick = onSignInClick + onSignInClick = onSignInClick, + showRegisterButton = isRegistrationEnabled ) } } @@ -433,6 +436,7 @@ fun CourseSearchScreenPreview() { refreshing = false, querySearch = "", isUserLoggedIn = true, + isRegistrationEnabled = true, onBackClick = {}, onSearchTextChanged = {}, onSwipeRefresh = {}, @@ -458,6 +462,7 @@ fun CourseSearchScreenTabletPreview() { refreshing = false, querySearch = "", isUserLoggedIn = false, + isRegistrationEnabled = true, onBackClick = {}, onSearchTextChanged = {}, onSwipeRefresh = {}, diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/search/CourseSearchViewModel.kt b/discovery/src/main/java/org/openedx/discovery/presentation/search/CourseSearchViewModel.kt index ea6c5ba35..8acbe0e1c 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/search/CourseSearchViewModel.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/search/CourseSearchViewModel.kt @@ -30,6 +30,7 @@ class CourseSearchViewModel( val apiHostUrl get() = config.getApiHostURL() val isUserLoggedIn get() = corePreferences.user != null + val isRegistrationEnabled: Boolean get() = config.isRegistrationEnabled() private val _uiState = MutableLiveData(CourseSearchUIState.Courses(emptyList(), 0)) From 44f106d4da16fe95cd265c0dd876e3c434ecf48d Mon Sep 17 00:00:00 2001 From: Igor Date: Tue, 29 Oct 2024 14:43:26 +0100 Subject: [PATCH 48/56] feat: added ability update the primary course on the learn tab (#389) --- .../courses/presentation/DashboardGalleryView.kt | 10 ++++++++++ .../courses/presentation/DashboardGalleryViewModel.kt | 4 ++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt index a3832e8b5..1e35ff387 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt @@ -42,6 +42,7 @@ import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -53,6 +54,7 @@ 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.LocalLifecycleOwner import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource @@ -63,6 +65,7 @@ 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 androidx.lifecycle.Lifecycle import coil.compose.AsyncImage import coil.request.ImageRequest import org.koin.androidx.compose.koinViewModel @@ -108,6 +111,13 @@ fun DashboardGalleryView( val uiMessage by viewModel.uiMessage.collectAsState(null) val uiState by viewModel.uiState.collectAsState(DashboardGalleryUIState.Loading) + val lifecycleState by LocalLifecycleOwner.current.lifecycle.currentStateFlow.collectAsState() + LaunchedEffect(lifecycleState) { + if (lifecycleState == Lifecycle.State.RESUMED) { + viewModel.updateCourses(isUpdating = false) + } + } + DashboardGalleryView( uiMessage = uiMessage, uiState = uiState, diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt index fdef55ee7..2c6ba0ccb 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt @@ -108,11 +108,11 @@ class DashboardGalleryViewModel( } } - fun updateCourses() { + fun updateCourses(isUpdating: Boolean = true) { if (isLoading) { return } - _updating.value = true + _updating.value = isUpdating getCourses() } From 0ff62f09144fbd17f88e868c833e454fd5c4c1ad Mon Sep 17 00:00:00 2001 From: Igor Date: Tue, 29 Oct 2024 14:46:30 +0100 Subject: [PATCH 49/56] fix: Minor bugbash fixes (#390) --- .../org/openedx/core/extension/ViewExt.kt | 22 +++-------- .../outline/CourseOutlineScreen.kt | 16 +++++--- .../outline/CourseOutlineUIState.kt | 1 + .../outline/CourseOutlineViewModel.kt | 3 ++ .../presentation/AllEnrolledCoursesView.kt | 2 +- .../presentation/whatsnew/WhatsNewFragment.kt | 38 ++++++++++--------- 6 files changed, 41 insertions(+), 41 deletions(-) diff --git a/core/src/main/java/org/openedx/core/extension/ViewExt.kt b/core/src/main/java/org/openedx/core/extension/ViewExt.kt index ebd007d3d..498619480 100644 --- a/core/src/main/java/org/openedx/core/extension/ViewExt.kt +++ b/core/src/main/java/org/openedx/core/extension/ViewExt.kt @@ -66,23 +66,11 @@ fun WebView.loadUrl(url: String, scope: CoroutineScope, cookieManager: AppCookie } fun WebView.applyDarkModeIfEnabled(isDarkTheme: Boolean) { - if (isDarkTheme && WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - settings.setAlgorithmicDarkeningAllowed(true) - } else { - // Switch WebView to dark mode; uses default dark theme - if (WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) { - WebSettingsCompat.setForceDark( - settings, - WebSettingsCompat.FORCE_DARK_ON - ) - } - if (WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK_STRATEGY)) { - WebSettingsCompat.setForceDarkStrategy( - settings, - WebSettingsCompat.DARK_STRATEGY_PREFER_WEB_THEME_OVER_USER_AGENT_DARKENING - ) - } + if (isDarkTheme && WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) { + try { + WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, true) + } catch (e: Exception) { + e.printStackTrace() } } } 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 f0516a744..10ad4f932 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 @@ -79,7 +79,7 @@ fun CourseOutlineScreen( windowSize: WindowSize, viewModel: CourseOutlineViewModel, fragmentManager: FragmentManager, - onResetDatesClick: () -> Unit + onResetDatesClick: () -> Unit, ) { val uiState by viewModel.uiState.collectAsState() val uiMessage by viewModel.uiMessage.collectAsState(null) @@ -288,12 +288,14 @@ private fun CourseOutlineUI( ResumeCourseTablet( modifier = Modifier.padding(vertical = 16.dp), block = uiState.resumeComponent, + displayName = uiState.resumeUnitTitle, onResumeClick = onResumeClick ) } else { ResumeCourse( modifier = Modifier.padding(vertical = 16.dp), block = uiState.resumeComponent, + displayName = uiState.resumeUnitTitle, onResumeClick = onResumeClick ) } @@ -341,6 +343,7 @@ private fun CourseOutlineUI( private fun ResumeCourse( modifier: Modifier = Modifier, block: Block, + displayName: String, onResumeClick: (String) -> Unit, ) { Column( @@ -363,7 +366,7 @@ private fun ResumeCourse( tint = MaterialTheme.appColors.textPrimary ) Text( - text = block.displayName, + text = displayName, color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.titleMedium, maxLines = 1, @@ -393,6 +396,7 @@ private fun ResumeCourse( private fun ResumeCourseTablet( modifier: Modifier = Modifier, block: Block, + displayName: String, onResumeClick: (String) -> Unit, ) { Row( @@ -421,7 +425,7 @@ private fun ResumeCourseTablet( tint = MaterialTheme.appColors.textPrimary ) Text( - text = block.displayName, + text = displayName, color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.titleMedium, overflow = TextOverflow.Ellipsis, @@ -450,7 +454,7 @@ private fun ResumeCourseTablet( @Composable private fun CourseProgress( modifier: Modifier = Modifier, - progress: Progress + progress: Progress, ) { Column( modifier = modifier, @@ -498,6 +502,7 @@ private fun CourseOutlineScreenPreview() { mockCourseStructure, mapOf(), mockChapterBlock, + "Resumed Unit", mapOf(), mapOf(), mapOf(), @@ -532,6 +537,7 @@ private fun CourseOutlineScreenTabletPreview() { mockCourseStructure, mapOf(), mockChapterBlock, + "Resumed Unit", mapOf(), mapOf(), mapOf(), @@ -560,7 +566,7 @@ private fun CourseOutlineScreenTabletPreview() { @Composable private fun ResumeCoursePreview() { OpenEdXTheme { - ResumeCourse(block = mockChapterBlock) {} + ResumeCourse(block = mockChapterBlock, displayName = "Resumed Unit") {} } } diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineUIState.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineUIState.kt index 381cb8401..55cf52137 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineUIState.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineUIState.kt @@ -10,6 +10,7 @@ sealed class CourseOutlineUIState { val courseStructure: CourseStructure, val downloadedState: Map, val resumeComponent: Block?, + val resumeUnitTitle: String, val courseSubSections: Map>, val courseSectionsState: Map, val subSectionsDownloadsCount: Map, 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 f59f6ec6e..006176b56 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 @@ -121,6 +121,7 @@ class CourseOutlineViewModel( courseStructure = state.courseStructure, downloadedState = it.toMap(), resumeComponent = state.resumeComponent, + resumeUnitTitle = resumeVerticalBlock?.displayName ?: "", courseSubSections = courseSubSections, courseSectionsState = state.courseSectionsState, subSectionsDownloadsCount = subSectionsDownloadsCount, @@ -155,6 +156,7 @@ class CourseOutlineViewModel( courseStructure = state.courseStructure, downloadedState = state.downloadedState, resumeComponent = state.resumeComponent, + resumeUnitTitle = resumeVerticalBlock?.displayName ?: "", courseSubSections = courseSubSections, courseSectionsState = courseSectionsState, subSectionsDownloadsCount = subSectionsDownloadsCount, @@ -213,6 +215,7 @@ class CourseOutlineViewModel( courseStructure = courseStructure, downloadedState = getDownloadModelsStatus(), resumeComponent = getResumeBlock(blocks, courseStatus.lastVisitedBlockId), + resumeUnitTitle = resumeVerticalBlock?.displayName ?: "", courseSubSections = courseSubSections, courseSectionsState = courseSectionsState, subSectionsDownloadsCount = subSectionsDownloadsCount, diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt index 34409198c..7bd060bb5 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt @@ -186,6 +186,7 @@ private fun AllEnrolledCoursesView( scaffoldState = scaffoldState, modifier = Modifier .fillMaxSize() + .navigationBarsPadding() .semantics { testTagsAsResourceId = true }, @@ -262,7 +263,6 @@ private fun AllEnrolledCoursesView( Box( modifier = Modifier .fillMaxWidth() - .navigationBarsPadding() .pullRefresh(pullRefreshState), ) { Column( diff --git a/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewFragment.kt b/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewFragment.kt index da0458054..541877ee2 100644 --- a/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewFragment.kt +++ b/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewFragment.kt @@ -15,6 +15,7 @@ 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.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn @@ -140,6 +141,7 @@ fun WhatsNewScreen( .semantics { testTagsAsResourceId = true } + .navigationBarsPadding() .fillMaxSize(), scaffoldState = scaffoldState, topBar = { @@ -247,26 +249,26 @@ private fun WhatsNewScreenPortrait( .background(MaterialTheme.appColors.background), contentAlignment = Alignment.TopCenter ) { - HorizontalPager( - modifier = Modifier.fillMaxSize(), - verticalAlignment = Alignment.Top, - state = pagerState - ) { page -> - val image = whatsNewItem.messages[page].image - Image( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 36.dp, vertical = 48.dp), - painter = painterResource(id = image), - contentDescription = null - ) - } - Box( + Column( modifier = Modifier - .fillMaxSize() - .padding(horizontal = 24.dp, vertical = 120.dp), - contentAlignment = Alignment.BottomCenter + .padding(horizontal = 24.dp, vertical = 36.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), ) { + HorizontalPager( + modifier = Modifier + .fillMaxWidth() + .weight(1.0f), + verticalAlignment = Alignment.Top, + state = pagerState + ) { page -> + val image = whatsNewItem.messages[page].image + Image( + modifier = Modifier + .fillMaxWidth(), + painter = painterResource(id = image), + contentDescription = null + ) + } Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(20.dp), From 190dd87396194a06208ba8ed7bba073928d27750 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk <141041606+PavloNetrebchuk@users.noreply.github.com> Date: Wed, 30 Oct 2024 13:38:57 +0200 Subject: [PATCH 50/56] Code refactoring and junit tests (#391) * refactor: minor code style changes * feat: CalendarViewModelTest * feat: LearnViewModelTest --- .../core/adapter/NavigationFragmentAdapter.kt | 2 +- .../java/org/openedx/core/config/Config.kt | 3 +- .../java/org/openedx/core/ui/ComposeCommon.kt | 3 - .../presentation/DashboardGalleryView.kt | 2 +- ...lTest.kt => DashboardListViewModelTest.kt} | 2 +- .../presentation/LearnViewModelTest.kt | 80 +++++++++ .../data/model/response/CommentsResponse.kt | 6 +- .../presentation/edit/EditProfileFragment.kt | 23 +-- .../profile/CalendarViewModelTest.kt | 158 ++++++++++++++++++ 9 files changed, 258 insertions(+), 21 deletions(-) rename dashboard/src/test/java/org/openedx/dashboard/presentation/{DashboardViewModelTest.kt => DashboardListViewModelTest.kt} (99%) create mode 100644 dashboard/src/test/java/org/openedx/dashboard/presentation/LearnViewModelTest.kt create mode 100644 profile/src/test/java/org/openedx/profile/presentation/profile/CalendarViewModelTest.kt diff --git a/core/src/main/java/org/openedx/core/adapter/NavigationFragmentAdapter.kt b/core/src/main/java/org/openedx/core/adapter/NavigationFragmentAdapter.kt index 273c53427..708b43829 100644 --- a/core/src/main/java/org/openedx/core/adapter/NavigationFragmentAdapter.kt +++ b/core/src/main/java/org/openedx/core/adapter/NavigationFragmentAdapter.kt @@ -14,4 +14,4 @@ class NavigationFragmentAdapter(fragment: Fragment) : FragmentStateAdapter(fragm fun addFragment(fragment: Fragment) { fragments.add(fragment) } -} \ No newline at end of file +} 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 13b37b62e..c97bf7b47 100644 --- a/core/src/main/java/org/openedx/core/config/Config.kt +++ b/core/src/main/java/org/openedx/core/config/Config.kt @@ -12,8 +12,7 @@ class Config(context: Context) { private var configProperties: JsonObject = try { val inputStream = context.assets.open("config/config.json") - val parser = JsonParser() - val config = parser.parse(InputStreamReader(inputStream)) + val config = JsonParser.parseReader(InputStreamReader(inputStream)) config.asJsonObject } catch (e: Exception) { JsonObject() 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 1f3f1850f..3eb16bd9b 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt @@ -61,7 +61,6 @@ import androidx.compose.runtime.rememberCoroutineScope 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.draw.clip import androidx.compose.ui.draw.drawWithContent @@ -210,7 +209,6 @@ fun Toolbar( } } -@OptIn(ExperimentalComposeUiApi::class) @Composable fun SearchBar( modifier: Modifier, @@ -310,7 +308,6 @@ fun SearchBar( ) } -@OptIn(ExperimentalComposeUiApi::class) @Composable fun SearchBarStateless( modifier: Modifier, diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt index 1e35ff387..493f81a00 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt @@ -434,7 +434,7 @@ private fun CourseListItem( Column { AsyncImage( model = ImageRequest.Builder(LocalContext.current) - .data(course.course.courseImage.toImageLink(apiHostUrl) ?: "") + .data(course.course.courseImage.toImageLink(apiHostUrl)) .error(CoreR.drawable.core_no_image_course) .placeholder(CoreR.drawable.core_no_image_course) .build(), diff --git a/dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardViewModelTest.kt b/dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardListViewModelTest.kt similarity index 99% rename from dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardViewModelTest.kt rename to dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardListViewModelTest.kt index 2a1131392..216d8ecbf 100644 --- a/dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardViewModelTest.kt +++ b/dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardListViewModelTest.kt @@ -38,7 +38,7 @@ import org.openedx.dashboard.domain.interactor.DashboardInteractor import java.net.UnknownHostException @OptIn(ExperimentalCoroutinesApi::class) -class DashboardViewModelTest { +class DashboardListViewModelTest { @get:Rule val testInstantTaskExecutorRule: TestRule = InstantTaskExecutorRule() diff --git a/dashboard/src/test/java/org/openedx/dashboard/presentation/LearnViewModelTest.kt b/dashboard/src/test/java/org/openedx/dashboard/presentation/LearnViewModelTest.kt new file mode 100644 index 000000000..090dc7987 --- /dev/null +++ b/dashboard/src/test/java/org/openedx/dashboard/presentation/LearnViewModelTest.kt @@ -0,0 +1,80 @@ +package org.openedx.dashboard.presentation + +import androidx.fragment.app.FragmentManager +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.openedx.DashboardNavigator +import org.openedx.core.config.Config +import org.openedx.core.config.DashboardConfig +import org.openedx.learn.presentation.LearnViewModel + +class LearnViewModelTest { + + private val config = mockk() + private val dashboardRouter = mockk(relaxed = true) + private val analytics = mockk(relaxed = true) + private val fragmentManager = mockk() + + private val viewModel = LearnViewModel(config, dashboardRouter, analytics) + + @Test + fun `onSettingsClick calls navigateToSettings`() = runTest { + viewModel.onSettingsClick(fragmentManager) + verify { dashboardRouter.navigateToSettings(fragmentManager) } + } + + @Test + fun `getDashboardFragment returns correct fragment based on dashboardType`() = runTest { + DashboardConfig.DashboardType.entries.forEach { type -> + every { config.getDashboardConfig().getType() } returns type + val dashboardFragment = viewModel.getDashboardFragment + assertEquals(DashboardNavigator(type).getDashboardFragment()::class, dashboardFragment::class) + } + } + + + @Test + fun `getProgramFragment returns correct program fragment`() = runTest { + viewModel.getProgramFragment + verify { dashboardRouter.getProgramFragment() } + } + + @Test + fun `isProgramTypeWebView returns correct view type`() = runTest { + every { config.getProgramConfig().isViewTypeWebView() } returns true + assertTrue(viewModel.isProgramTypeWebView) + } + + @Test + fun `logMyCoursesTabClickedEvent logs correct analytics event`() = runTest { + viewModel.logMyCoursesTabClickedEvent() + + verify { + analytics.logScreenEvent( + screenName = DashboardAnalyticsEvent.MY_COURSES.eventName, + params = match { + it[DashboardAnalyticsKey.NAME.key] == DashboardAnalyticsEvent.MY_COURSES.biValue + } + ) + } + } + + @Test + fun `logMyProgramsTabClickedEvent logs correct analytics event`() = runTest { + viewModel.logMyProgramsTabClickedEvent() + + verify { + analytics.logScreenEvent( + screenName = DashboardAnalyticsEvent.MY_PROGRAMS.eventName, + params = match { + it[DashboardAnalyticsKey.NAME.key] == DashboardAnalyticsEvent.MY_PROGRAMS.biValue + } + ) + } + } +} diff --git a/discussion/src/main/java/org/openedx/discussion/data/model/response/CommentsResponse.kt b/discussion/src/main/java/org/openedx/discussion/data/model/response/CommentsResponse.kt index 711bab32c..a2248b036 100644 --- a/discussion/src/main/java/org/openedx/discussion/data/model/response/CommentsResponse.kt +++ b/discussion/src/main/java/org/openedx/discussion/data/model/response/CommentsResponse.kt @@ -76,9 +76,9 @@ data class CommentResult( authorLabel ?: "", createdAt, updatedAt, - rawBody ?: "", - renderedBody ?: "", - TextConverter.textToLinkedImageText(renderedBody ?: ""), + rawBody, + renderedBody, + TextConverter.textToLinkedImageText(renderedBody), abuseFlagged, voted, voteCount, diff --git a/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileFragment.kt index 3800d23ac..21317c3fe 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileFragment.kt @@ -675,25 +675,29 @@ private fun EditProfileScreen( openWarningMessageDialog = true } }, - text = stringResource(if (uiState.isLimited) R.string.profile_switch_to_full else R.string.profile_switch_to_limited), + text = stringResource( + if (uiState.isLimited) { + R.string.profile_switch_to_full + } else { + R.string.profile_switch_to_limited + } + ), color = MaterialTheme.appColors.textAccent, style = MaterialTheme.appTypography.labelLarge ) Spacer(modifier = Modifier.height(20.dp)) ProfileFields( disabled = uiState.isLimited, - onFieldClick = { it, title -> - when (it) { + onFieldClick = { field, title -> + when (field) { YEAR_OF_BIRTH -> { serverFieldName.value = YEAR_OF_BIRTH - expandedList = - LocaleUtils.getBirthYearsRange() + expandedList = LocaleUtils.getBirthYearsRange() } COUNTRY -> { serverFieldName.value = COUNTRY - expandedList = - LocaleUtils.getCountries() + expandedList = LocaleUtils.getCountries() } LANGUAGE -> { @@ -706,9 +710,8 @@ private fun EditProfileScreen( coroutine.launch { val index = expandedList.indexOfFirst { option -> if (serverFieldName.value == LANGUAGE) { - option.value == (mapFields[serverFieldName.value] as List).getOrNull( - 0 - )?.code + option.value == (mapFields[serverFieldName.value] as List) + .getOrNull(0)?.code } else { option.value == mapFields[serverFieldName.value] } diff --git a/profile/src/test/java/org/openedx/profile/presentation/profile/CalendarViewModelTest.kt b/profile/src/test/java/org/openedx/profile/presentation/profile/CalendarViewModelTest.kt new file mode 100644 index 000000000..7fd8977a1 --- /dev/null +++ b/profile/src/test/java/org/openedx/profile/presentation/profile/CalendarViewModelTest.kt @@ -0,0 +1,158 @@ +package org.openedx.profile.presentation.profile + +import androidx.activity.result.ActivityResultLauncher +import androidx.fragment.app.FragmentManager +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.openedx.core.data.storage.CalendarPreferences +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.interactor.CalendarInteractor +import org.openedx.core.presentation.settings.calendarsync.CalendarSyncState +import org.openedx.core.system.CalendarManager +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.system.notifier.calendar.CalendarCreated +import org.openedx.core.system.notifier.calendar.CalendarNotifier +import org.openedx.core.system.notifier.calendar.CalendarSynced +import org.openedx.core.worker.CalendarSyncScheduler +import org.openedx.profile.presentation.ProfileRouter +import org.openedx.profile.presentation.calendar.CalendarViewModel + +@OptIn(ExperimentalCoroutinesApi::class) +class CalendarViewModelTest { + + private val dispatcher = StandardTestDispatcher() + private lateinit var viewModel: CalendarViewModel + + private val calendarSyncScheduler = mockk(relaxed = true) + private val calendarManager = mockk(relaxed = true) + private val calendarPreferences = mockk(relaxed = true) + private val calendarNotifier = mockk(relaxed = true) + private val calendarInteractor = mockk(relaxed = true) + private val corePreferences = mockk(relaxed = true) + private val profileRouter = mockk() + private val networkConnection = mockk() + private val permissionLauncher = mockk>>() + private val fragmentManager = mockk() + + @Before + fun setup() { + Dispatchers.setMain(dispatcher) + every { networkConnection.isOnline() } returns true + viewModel = CalendarViewModel( + calendarSyncScheduler = calendarSyncScheduler, + calendarManager = calendarManager, + calendarPreferences = calendarPreferences, + calendarNotifier = calendarNotifier, + calendarInteractor = calendarInteractor, + corePreferences = corePreferences, + profileRouter = profileRouter, + networkConnection = networkConnection + ) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `init triggers immediate sync and loads calendar data`() = runTest(dispatcher) { + coVerify { calendarSyncScheduler.requestImmediateSync() } + coVerify { calendarInteractor.getAllCourseCalendarStateFromCache() } + } + + @Test + fun `setUpCalendarSync launches permission request`() = runTest(dispatcher) { + every { permissionLauncher.launch(calendarManager.permissions) } returns Unit + viewModel.setUpCalendarSync(permissionLauncher) + coVerify { permissionLauncher.launch(calendarManager.permissions) } + } + + @Test + fun `setCalendarSyncEnabled enables sync and triggers sync when isEnabled is true`() = runTest(dispatcher) { + viewModel.setCalendarSyncEnabled(isEnabled = true, fragmentManager = fragmentManager) + + coVerify { + calendarPreferences.isCalendarSyncEnabled = true + calendarSyncScheduler.requestImmediateSync() + } + assertTrue(viewModel.uiState.value.isCalendarSyncEnabled) + } + + @Test + fun `setRelativeDateEnabled updates preference and UI state`() = runTest(dispatcher) { + viewModel.setRelativeDateEnabled(true) + + coVerify { corePreferences.isRelativeDatesEnabled = true } + assertTrue(viewModel.uiState.value.isRelativeDateEnabled) + } + + @Test + fun `network disconnection changes sync state to offline`() = runTest(dispatcher) { + every { networkConnection.isOnline() } returns false + viewModel = CalendarViewModel( + calendarSyncScheduler, + calendarManager, + calendarPreferences, + calendarNotifier, + calendarInteractor, + corePreferences, + profileRouter, + networkConnection + ) + + assertEquals(CalendarSyncState.OFFLINE, viewModel.uiState.value.calendarSyncState) + } + + @Test + fun `successful calendar sync updates sync state to SYNCED`() = runTest(dispatcher) { + viewModel = CalendarViewModel( + calendarSyncScheduler, + calendarManager, + calendarPreferences, + calendarNotifier.apply { + every { notifier } returns flowOf(CalendarSynced) + }, + calendarInteractor, + corePreferences, + profileRouter, + networkConnection + ) + + assertEquals(CalendarSyncState.SYNCED, viewModel.uiState.value.calendarSyncState) + } + + @Test + fun `calendar creation updates calendar existence state`() = runTest(dispatcher) { + every { calendarPreferences.calendarId } returns 1 + every { calendarManager.isCalendarExist(1) } returns true + + viewModel = CalendarViewModel( + calendarSyncScheduler, + calendarManager, + calendarPreferences, + calendarNotifier.apply { + every { notifier } returns flowOf(CalendarCreated) + }, + calendarInteractor, + corePreferences, + profileRouter, + networkConnection + ) + + assertTrue(viewModel.uiState.value.isCalendarExist) + } +} From 283953515d4f76d6ccaa9a26a809c8582c9d3460 Mon Sep 17 00:00:00 2001 From: Igor Date: Thu, 31 Oct 2024 12:18:27 +0100 Subject: [PATCH 51/56] feat: App Level WebView/No Internet Error Handling (#392) Co-authored-by: Farhan Arshad <43750646+farhan-arshad-dev@users.noreply.github.com> --- .../org/openedx/core/extension/StringExt.kt | 9 ++ .../core/presentation/global/ErrorType.kt | 23 +++++ .../global/webview/WebViewUIState.kt | 15 +++ .../java/org/openedx/core/ui/ComposeCommon.kt | 31 +++--- .../res/drawable/core_ic_unknown_error.xml | 23 +++++ core/src/main/res/values/strings.xml | 2 + .../unit/html/HtmlUnitFragment.kt | 72 ++++++++------ .../presentation/unit/html/HtmlUnitUIState.kt | 12 ++- .../unit/html/HtmlUnitViewModel.kt | 23 ++++- .../unit/video/VideoUnitFragment.kt | 11 +-- .../unit/video/YoutubeVideoUnitFragment.kt | 11 +-- .../presentation/WebViewDiscoveryFragment.kt | 79 ++++++++++----- .../presentation/WebViewDiscoveryViewModel.kt | 20 ++++ .../presentation/catalog/CatalogWebView.kt | 14 +++ .../presentation/info/CourseInfoFragment.kt | 83 ++++++++++------ .../presentation/info/CourseInfoUIState.kt | 12 ++- .../presentation/info/CourseInfoViewModel.kt | 22 ++++- .../presentation/program/ProgramFragment.kt | 95 +++++++++++-------- .../presentation/program/ProgramUIState.kt | 2 + .../presentation/program/ProgramViewModel.kt | 25 +++-- 20 files changed, 402 insertions(+), 182 deletions(-) create mode 100644 core/src/main/java/org/openedx/core/presentation/global/ErrorType.kt create mode 100644 core/src/main/java/org/openedx/core/presentation/global/webview/WebViewUIState.kt create mode 100644 core/src/main/res/drawable/core_ic_unknown_error.xml diff --git a/core/src/main/java/org/openedx/core/extension/StringExt.kt b/core/src/main/java/org/openedx/core/extension/StringExt.kt index d383cf57f..0ecc86e1f 100644 --- a/core/src/main/java/org/openedx/core/extension/StringExt.kt +++ b/core/src/main/java/org/openedx/core/extension/StringExt.kt @@ -1,6 +1,7 @@ package org.openedx.core.extension import android.util.Patterns +import java.net.URL import java.util.Locale import java.util.regex.Pattern @@ -38,6 +39,14 @@ fun String.takeIfNotEmpty(): String? { return if (this.isEmpty().not()) this else null } +fun String?.equalsHost(host: String?): Boolean { + return try { + host?.startsWith(URL(this).host, ignoreCase = true) == true + } catch (e: Exception) { + false + } +} + fun String.toImageLink(apiHostURL: String): String = if (this.isLinkValid()) { this diff --git a/core/src/main/java/org/openedx/core/presentation/global/ErrorType.kt b/core/src/main/java/org/openedx/core/presentation/global/ErrorType.kt new file mode 100644 index 000000000..481758ecb --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/global/ErrorType.kt @@ -0,0 +1,23 @@ +package org.openedx.core.presentation.global + +import org.openedx.core.R + +enum class ErrorType( + val iconResId: Int = 0, + val titleResId: Int = 0, + val descriptionResId: Int = 0, + val actionResId: Int = 0, +) { + CONNECTION_ERROR( + iconResId = R.drawable.core_no_internet_connection, + titleResId = R.string.core_no_internet_connection, + descriptionResId = R.string.core_no_internet_connection_description, + actionResId = R.string.core_reload, + ), + UNKNOWN_ERROR( + iconResId = R.drawable.core_ic_unknown_error, + titleResId = R.string.core_try_again, + descriptionResId = R.string.core_something_went_wrong_description, + actionResId = R.string.core_reload, + ), +} diff --git a/core/src/main/java/org/openedx/core/presentation/global/webview/WebViewUIState.kt b/core/src/main/java/org/openedx/core/presentation/global/webview/WebViewUIState.kt new file mode 100644 index 000000000..3a99afaaf --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/global/webview/WebViewUIState.kt @@ -0,0 +1,15 @@ +package org.openedx.core.presentation.global.webview + +import org.openedx.core.presentation.global.ErrorType + +sealed class WebViewUIState { + data object Loading : WebViewUIState() + data object Loaded : WebViewUIState() + data class Error(val errorType: ErrorType) : WebViewUIState() +} + +enum class WebViewUIAction { + WEB_PAGE_LOADED, + WEB_PAGE_ERROR, + RELOAD_WEB_PAGE +} 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 3eb16bd9b..d50b05cbe 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt @@ -108,6 +108,7 @@ import org.openedx.core.domain.model.RegistrationField import org.openedx.core.extension.LinkedImageText import org.openedx.core.extension.tagId import org.openedx.core.extension.toastMessage +import org.openedx.core.presentation.global.ErrorType import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes @@ -1133,25 +1134,33 @@ fun BackBtn( } @Composable -fun ConnectionErrorView( - modifier: Modifier, - onReloadClick: () -> Unit, +fun ConnectionErrorView(onReloadClick: () -> Unit) { + FullScreenErrorView(errorType = ErrorType.CONNECTION_ERROR, onReloadClick = onReloadClick) +} + +@Composable +fun FullScreenErrorView( + modifier: Modifier = Modifier, + errorType: ErrorType, + onReloadClick: () -> Unit ) { Column( - modifier = modifier, + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.appColors.background), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { Icon( modifier = Modifier.size(100.dp), - painter = painterResource(id = R.drawable.core_no_internet_connection), + painter = painterResource(id = errorType.iconResId), contentDescription = null, tint = MaterialTheme.appColors.onSurface ) Spacer(Modifier.height(28.dp)) Text( modifier = Modifier.fillMaxWidth(0.8f), - text = stringResource(id = R.string.core_no_internet_connection), + text = stringResource(id = errorType.titleResId), color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.titleLarge, textAlign = TextAlign.Center @@ -1159,7 +1168,7 @@ fun ConnectionErrorView( Spacer(Modifier.height(16.dp)) Text( modifier = Modifier.fillMaxWidth(0.8f), - text = stringResource(id = R.string.core_no_internet_connection_description), + text = stringResource(id = errorType.descriptionResId), color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.bodyLarge, textAlign = TextAlign.Center @@ -1168,7 +1177,7 @@ fun ConnectionErrorView( OpenEdXButton( modifier = Modifier .widthIn(Dp.Unspecified, 162.dp), - text = stringResource(id = R.string.core_reload), + text = stringResource(id = errorType.actionResId), textColor = MaterialTheme.appColors.primaryButtonText, backgroundColor = MaterialTheme.appColors.secondaryButtonBackground, onClick = onReloadClick, @@ -1369,11 +1378,7 @@ private fun IconTextPreview() { @Composable private fun ConnectionErrorViewPreview() { OpenEdXTheme(darkTheme = true) { - ConnectionErrorView( - modifier = Modifier - .fillMaxSize(), - onReloadClick = {} - ) + ConnectionErrorView(onReloadClick = {}) } } diff --git a/core/src/main/res/drawable/core_ic_unknown_error.xml b/core/src/main/res/drawable/core_ic_unknown_error.xml new file mode 100644 index 000000000..d7d2c0c02 --- /dev/null +++ b/core/src/main/res/drawable/core_ic_unknown_error.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index 78263a819..b023e8845 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -75,6 +75,8 @@ We received your feedback and will use it to help improve your learning experience going forward. Thank you for sharing! No internet connection Please connect to the internet to view this content. + Try Again + Something went wrong OK Continue Leaving the app diff --git a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt index db88ae6c8..b5747d4d0 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt @@ -10,6 +10,7 @@ import android.util.Log import android.view.LayoutInflater import android.view.ViewGroup import android.webkit.JavascriptInterface +import android.webkit.WebResourceError import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse import android.webkit.WebSettings @@ -18,7 +19,6 @@ import android.webkit.WebViewClient import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -53,10 +53,11 @@ import kotlinx.coroutines.launch import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.core.extension.applyDarkModeIfEnabled +import org.openedx.core.extension.equalsHost import org.openedx.core.extension.isEmailValid import org.openedx.core.extension.loadUrl import org.openedx.core.system.AppCookieManager -import org.openedx.core.ui.ConnectionErrorView +import org.openedx.core.ui.FullScreenErrorView import org.openedx.core.ui.WindowSize import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.roundBorderWithoutBottom @@ -96,10 +97,6 @@ class HtmlUnitFragment : Fragment() { OpenEdXTheme { val windowSize = rememberWindowSize() - var isLoading by remember { - mutableStateOf(true) - } - var hasInternetConnection by remember { mutableStateOf(viewModel.isOnline) } @@ -148,7 +145,8 @@ class HtmlUnitFragment : Fragment() { .then(border), contentAlignment = Alignment.TopCenter ) { - if (uiState.isLoadingEnabled) { + if (uiState is HtmlUnitUIState.Initialization) return@Box + if ((uiState is HtmlUnitUIState.Error).not()) { if (hasInternetConnection || fromDownloadedContent) { HTMLContentView( uiState = uiState, @@ -156,41 +154,45 @@ class HtmlUnitFragment : Fragment() { url = url, cookieManager = viewModel.cookieManager, apiHostURL = viewModel.apiHostURL, - isLoading = isLoading, + isLoading = uiState is HtmlUnitUIState.Loading, injectJSList = injectJSList, onCompletionSet = { viewModel.notifyCompletionSet() }, onWebPageLoading = { - isLoading = true + viewModel.onWebPageLoading() }, onWebPageLoaded = { - isLoading = false + if ((uiState is HtmlUnitUIState.Error).not()) { + viewModel.onWebPageLoaded() + } if (isAdded) viewModel.setWebPageLoaded(requireContext().assets) }, + onWebPageLoadError = { + viewModel.onWebPageLoadError() + }, saveXBlockProgress = { jsonProgress -> viewModel.saveXBlockProgress(jsonProgress) }, ) } else { - ConnectionErrorView( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight() - .background(MaterialTheme.appColors.background) - ) { - hasInternetConnection = viewModel.isOnline - } + viewModel.onWebPageLoadError() } - if (isLoading && hasInternetConnection) { - Box( - modifier = Modifier - .fillMaxSize() - .zIndex(1f), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator(color = MaterialTheme.appColors.primary) - } + } else { + val errorType = (uiState as HtmlUnitUIState.Error).errorType + FullScreenErrorView(errorType = errorType) { + hasInternetConnection = viewModel.isOnline + viewModel.onWebPageLoading() + } + } + if (uiState is HtmlUnitUIState.Loading && hasInternetConnection) { + Box( + modifier = Modifier + .fillMaxSize() + .zIndex(1f), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) } } } @@ -239,7 +241,8 @@ private fun HTMLContentView( onCompletionSet: () -> Unit, onWebPageLoading: () -> Unit, onWebPageLoaded: () -> Unit, - saveXBlockProgress: (String) -> Unit + onWebPageLoadError: () -> Unit, + saveXBlockProgress: (String) -> Unit, ) { val coroutineScope = rememberCoroutineScope() val context = LocalContext.current @@ -333,6 +336,17 @@ private fun HTMLContentView( } super.onReceivedHttpError(view, request, errorResponse) } + + override fun onReceivedError( + view: WebView, + request: WebResourceRequest, + error: WebResourceError + ) { + if (view.url.equalsHost(request.url.host)) { + onWebPageLoadError() + } + super.onReceivedError(view, request, error) + } } with(settings) { javaScriptEnabled = true @@ -356,7 +370,7 @@ private fun HTMLContentView( update = { webView -> if (!isLoading && injectJSList.isNotEmpty()) { injectJSList.forEach { webView.evaluateJavascript(it, null) } - val jsonProgress = uiState.jsonProgress + val jsonProgress = (uiState as? HtmlUnitUIState.Loaded)?.jsonProgress if (!jsonProgress.isNullOrEmpty()) { webView.setupOfflineProgress(jsonProgress) } diff --git a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitUIState.kt b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitUIState.kt index 2dc14424c..855a7a1e9 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitUIState.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitUIState.kt @@ -1,6 +1,10 @@ package org.openedx.course.presentation.unit.html -data class HtmlUnitUIState( - val jsonProgress: String?, - val isLoadingEnabled: Boolean -) +import org.openedx.core.presentation.global.ErrorType + +sealed class HtmlUnitUIState { + data object Initialization : HtmlUnitUIState() + data object Loading : HtmlUnitUIState() + data class Loaded(val jsonProgress: String? = null) : HtmlUnitUIState() + data class Error(val errorType: ErrorType) : HtmlUnitUIState() +} diff --git a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitViewModel.kt index f852c1f2d..bccdcd0fd 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitViewModel.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.launch import org.openedx.core.BaseViewModel import org.openedx.core.config.Config import org.openedx.core.extension.readAsText +import org.openedx.core.presentation.global.ErrorType import org.openedx.core.system.AppCookieManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseCompletionSet @@ -29,9 +30,8 @@ class HtmlUnitViewModel( private val offlineProgressSyncScheduler: OfflineProgressSyncScheduler ) : BaseViewModel() { - private val _uiState = MutableStateFlow(HtmlUnitUIState(null, false)) - val uiState: StateFlow - get() = _uiState.asStateFlow() + private val _uiState = MutableStateFlow(HtmlUnitUIState.Initialization) + val uiState = _uiState.asStateFlow() private val _injectJSList = MutableStateFlow>(listOf()) val injectJSList = _injectJSList.asStateFlow() @@ -45,6 +45,19 @@ class HtmlUnitViewModel( tryToSyncProgress() } + fun onWebPageLoading() { + _uiState.value = HtmlUnitUIState.Loading + } + + fun onWebPageLoaded() { + _uiState.value = HtmlUnitUIState.Loaded() + } + + fun onWebPageLoadError() { + _uiState.value = + HtmlUnitUIState.Error(if (networkConnection.isOnline()) ErrorType.UNKNOWN_ERROR else ErrorType.CONNECTION_ERROR) + } + fun setWebPageLoaded(assets: AssetManager) { if (_injectJSList.value.isNotEmpty()) return @@ -80,7 +93,7 @@ class HtmlUnitViewModel( } } catch (e: Exception) { } finally { - _uiState.update { it.copy(isLoadingEnabled = true) } + _uiState.value = HtmlUnitUIState.Loading } } } @@ -90,7 +103,7 @@ class HtmlUnitViewModel( if (!isOnline) { val xBlockProgress = courseInteractor.getXBlockProgress(blockId) delay(500) - _uiState.update { it.copy(jsonProgress = xBlockProgress?.jsonProgress?.toJson()) } + _uiState.value = HtmlUnitUIState.Loaded(jsonProgress = xBlockProgress?.jsonProgress?.toJson()) } } } diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitFragment.kt index 276f48574..49431ba46 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitFragment.kt @@ -6,14 +6,10 @@ import android.os.Handler import android.os.Looper import android.view.View import android.widget.FrameLayout -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.MaterialTheme import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.ui.Modifier import androidx.core.os.bundleOf import androidx.core.view.isVisible import androidx.fragment.app.Fragment @@ -37,7 +33,6 @@ import org.openedx.core.presentation.global.viewBinding import org.openedx.core.ui.ConnectionErrorView import org.openedx.core.ui.WindowSize import org.openedx.core.ui.theme.OpenEdXTheme -import org.openedx.core.ui.theme.appColors import org.openedx.core.utils.LocaleUtils import org.openedx.course.R import org.openedx.course.databinding.FragmentVideoUnitBinding @@ -104,11 +99,7 @@ class VideoUnitFragment : Fragment(R.layout.fragment_video_unit) { binding.connectionError.setContent { OpenEdXTheme { - ConnectionErrorView( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.appColors.background) - ) { + ConnectionErrorView { binding.connectionError.isVisible = !viewModel.hasInternetConnection && !viewModel.isDownloaded } diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoUnitFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoUnitFragment.kt index e6b04687d..b163a7cde 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoUnitFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoUnitFragment.kt @@ -4,14 +4,10 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.MaterialTheme import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.ui.Modifier import androidx.core.os.bundleOf import androidx.core.view.isVisible import androidx.fragment.app.Fragment @@ -32,7 +28,6 @@ import org.openedx.core.presentation.dialog.selectorbottomsheet.SelectBottomDial import org.openedx.core.ui.ConnectionErrorView import org.openedx.core.ui.WindowSize import org.openedx.core.ui.theme.OpenEdXTheme -import org.openedx.core.ui.theme.appColors import org.openedx.core.utils.LocaleUtils import org.openedx.course.R import org.openedx.course.databinding.FragmentYoutubeVideoUnitBinding @@ -102,11 +97,7 @@ class YoutubeVideoUnitFragment : Fragment(R.layout.fragment_youtube_video_unit) binding.connectionError.setContent { OpenEdXTheme { - ConnectionErrorView( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.appColors.background) - ) { + ConnectionErrorView { binding.connectionError.isVisible = !viewModel.hasInternetConnection } } diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryFragment.kt index fcd185efe..cf67d1a2c 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryFragment.kt @@ -24,6 +24,7 @@ import androidx.compose.material.Surface import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -52,8 +53,11 @@ import androidx.lifecycle.LifecycleOwner import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.core.presentation.dialog.alert.ActionDialogFragment +import org.openedx.core.presentation.global.ErrorType +import org.openedx.core.presentation.global.webview.WebViewUIAction +import org.openedx.core.presentation.global.webview.WebViewUIState import org.openedx.core.ui.AuthButtonsPanel -import org.openedx.core.ui.ConnectionErrorView +import org.openedx.core.ui.FullScreenErrorView import org.openedx.core.ui.Toolbar import org.openedx.core.ui.WindowSize import org.openedx.core.ui.WindowType @@ -83,18 +87,33 @@ class WebViewDiscoveryFragment : Fragment() { setContent { OpenEdXTheme { val windowSize = rememberWindowSize() + val uiState by viewModel.uiState.collectAsState() var hasInternetConnection by remember { mutableStateOf(viewModel.hasInternetConnection) } WebViewDiscoveryScreen( windowSize = windowSize, + uiState = uiState, isPreLogin = viewModel.isPreLogin, contentUrl = viewModel.discoveryUrl, uriScheme = viewModel.uriScheme, isRegistrationEnabled = viewModel.isRegistrationEnabled, hasInternetConnection = hasInternetConnection, - checkInternetConnection = { - hasInternetConnection = viewModel.hasInternetConnection + onWebViewUIAction = { action -> + when (action) { + WebViewUIAction.WEB_PAGE_LOADED -> { + viewModel.onWebPageLoaded() + } + + WebViewUIAction.WEB_PAGE_ERROR -> { + viewModel.onWebPageLoadError() + } + + WebViewUIAction.RELOAD_WEB_PAGE -> { + hasInternetConnection = viewModel.hasInternetConnection + viewModel.onWebPageLoading() + } + } }, onWebPageUpdated = { url -> viewModel.updateDiscoveryUrl(url) @@ -171,12 +190,13 @@ class WebViewDiscoveryFragment : Fragment() { @SuppressLint("SetJavaScriptEnabled") private fun WebViewDiscoveryScreen( windowSize: WindowSize, + uiState: WebViewUIState, isPreLogin: Boolean, contentUrl: String, uriScheme: String, isRegistrationEnabled: Boolean, hasInternetConnection: Boolean, - checkInternetConnection: () -> Unit, + onWebViewUIAction: (WebViewUIAction) -> Unit, onWebPageUpdated: (String) -> Unit, onUriClick: (String, WebViewLink.Authority) -> Unit, onRegisterClick: () -> Unit, @@ -186,7 +206,6 @@ private fun WebViewDiscoveryScreen( ) { val scaffoldState = rememberScaffoldState() val configuration = LocalConfiguration.current - var isLoading by remember { mutableStateOf(true) } Scaffold( scaffoldState = scaffoldState, @@ -251,25 +270,32 @@ private fun WebViewDiscoveryScreen( .background(Color.White), contentAlignment = Alignment.TopCenter ) { - if (hasInternetConnection) { - DiscoveryWebView( - contentUrl = contentUrl, - uriScheme = uriScheme, - onWebPageLoaded = { isLoading = false }, - onWebPageUpdated = onWebPageUpdated, - onUriClick = onUriClick, - ) - } else { - ConnectionErrorView( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight() - .background(MaterialTheme.appColors.background) - ) { - checkInternetConnection() + if ((uiState is WebViewUIState.Error).not()) { + if (hasInternetConnection) { + DiscoveryWebView( + contentUrl = contentUrl, + uriScheme = uriScheme, + onWebPageLoaded = { + if ((uiState is WebViewUIState.Error).not()) { + onWebViewUIAction(WebViewUIAction.WEB_PAGE_LOADED) + } + }, + onWebPageUpdated = onWebPageUpdated, + onUriClick = onUriClick, + onWebPageLoadError = { + onWebViewUIAction(WebViewUIAction.WEB_PAGE_ERROR) + } + ) + } else { + onWebViewUIAction(WebViewUIAction.WEB_PAGE_ERROR) } } - if (isLoading && hasInternetConnection) { + if (uiState is WebViewUIState.Error) { + FullScreenErrorView(errorType = uiState.errorType) { + onWebViewUIAction(WebViewUIAction.RELOAD_WEB_PAGE) + } + } + if (uiState is WebViewUIState.Loading && hasInternetConnection) { Box( modifier = Modifier .fillMaxSize() @@ -293,6 +319,7 @@ private fun DiscoveryWebView( onWebPageLoaded: () -> Unit, onWebPageUpdated: (String) -> Unit, onUriClick: (String, WebViewLink.Authority) -> Unit, + onWebPageLoadError: () -> Unit ) { val webView = CatalogWebViewScreen( url = contentUrl, @@ -300,6 +327,7 @@ private fun DiscoveryWebView( onWebPageLoaded = onWebPageLoaded, onWebPageUpdated = onWebPageUpdated, onUriClick = onUriClick, + onWebPageLoadError = onWebPageLoadError ) AndroidView( @@ -363,18 +391,19 @@ private fun WebViewDiscoveryScreenPreview() { OpenEdXTheme { WebViewDiscoveryScreen( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - contentUrl = "https://www.example.com/", + uiState = WebViewUIState.Error(ErrorType.CONNECTION_ERROR), isPreLogin = false, + contentUrl = "https://www.example.com/", uriScheme = "", isRegistrationEnabled = true, hasInternetConnection = false, - checkInternetConnection = {}, + onWebViewUIAction = {}, onWebPageUpdated = {}, onUriClick = { _, _ -> }, onRegisterClick = {}, onSignInClick = {}, onSettingsClick = {}, - onBackClick = {} + onBackClick = {}, ) } } diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryViewModel.kt b/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryViewModel.kt index 94f62574d..a8f8cfc45 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryViewModel.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryViewModel.kt @@ -1,9 +1,14 @@ package org.openedx.discovery.presentation import androidx.fragment.app.FragmentManager +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import org.openedx.core.BaseViewModel import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.presentation.global.ErrorType +import org.openedx.core.presentation.global.webview.WebViewUIState import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.utils.UrlUtils @@ -16,6 +21,8 @@ class WebViewDiscoveryViewModel( private val analytics: DiscoveryAnalytics, ) : BaseViewModel() { + private val _uiState = MutableStateFlow(WebViewUIState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() val uriScheme: String get() = config.getUriScheme() private val webViewConfig get() = config.getDiscoveryConfig().webViewConfig @@ -38,6 +45,19 @@ class WebViewDiscoveryViewModel( val hasInternetConnection: Boolean get() = networkConnection.isOnline() + fun onWebPageLoading() { + _uiState.value = WebViewUIState.Loading + } + + fun onWebPageLoaded() { + _uiState.value = WebViewUIState.Loaded + } + + fun onWebPageLoadError() { + _uiState.value = + WebViewUIState.Error(if (networkConnection.isOnline()) ErrorType.UNKNOWN_ERROR else ErrorType.CONNECTION_ERROR) + } + fun updateDiscoveryUrl(url: String) { if (url.isNotEmpty()) { _discoveryUrl = url diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/catalog/CatalogWebView.kt b/discovery/src/main/java/org/openedx/discovery/presentation/catalog/CatalogWebView.kt index 373516b0a..785e77767 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/catalog/CatalogWebView.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/catalog/CatalogWebView.kt @@ -1,6 +1,7 @@ package org.openedx.discovery.presentation.catalog import android.annotation.SuppressLint +import android.webkit.WebResourceError import android.webkit.WebResourceRequest import android.webkit.WebView import androidx.compose.foundation.isSystemInDarkTheme @@ -8,6 +9,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import org.openedx.core.extension.applyDarkModeIfEnabled +import org.openedx.core.extension.equalsHost import org.openedx.discovery.presentation.catalog.WebViewLink.Authority as linkAuthority @SuppressLint("SetJavaScriptEnabled", "ComposableNaming") @@ -20,6 +22,7 @@ fun CatalogWebViewScreen( refreshSessionCookie: () -> Unit = {}, onWebPageUpdated: (String) -> Unit = {}, onUriClick: (String, linkAuthority) -> Unit, + onWebPageLoadError: () -> Unit ): WebView { val context = LocalContext.current val isDarkTheme = isSystemInDarkTheme() @@ -81,6 +84,17 @@ fun CatalogWebViewScreen( else -> false } } + + override fun onReceivedError( + view: WebView, + request: WebResourceRequest, + error: WebResourceError + ) { + if (view.url.equalsHost(request.url.host)) { + onWebPageLoadError() + } + super.onReceivedError(view, request, error) + } } with(settings) { diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoFragment.kt index 1345ae5c6..4906e91f8 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoFragment.kt @@ -45,8 +45,10 @@ import org.koin.core.parameter.parametersOf import org.openedx.core.UIMessage import org.openedx.core.presentation.dialog.alert.ActionDialogFragment import org.openedx.core.presentation.dialog.alert.InfoDialogFragment +import org.openedx.core.presentation.global.webview.WebViewUIAction +import org.openedx.core.presentation.global.webview.WebViewUIState import org.openedx.core.ui.AuthButtonsPanel -import org.openedx.core.ui.ConnectionErrorView +import org.openedx.core.ui.FullScreenErrorView import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.Toolbar import org.openedx.core.ui.WindowSize @@ -85,6 +87,7 @@ class CourseInfoFragment : Fragment() { val uiMessage by viewModel.uiMessage.collectAsState(initial = null) val showAlert by viewModel.showAlert.collectAsState(initial = false) val uiState by viewModel.uiState.collectAsState() + val webViewState by viewModel.webViewState.collectAsState() val windowSize = rememberWindowSize() var hasInternetConnection by remember { mutableStateOf(viewModel.hasInternetConnection) @@ -105,26 +108,42 @@ class CourseInfoFragment : Fragment() { } } - LaunchedEffect(uiState.enrollmentSuccess.get()) { - if (uiState.enrollmentSuccess.get().isNotEmpty()) { + LaunchedEffect((uiState as CourseInfoUIState.CourseInfo).enrollmentSuccess.get()) { + if ((uiState as CourseInfoUIState.CourseInfo).enrollmentSuccess.get() + .isNotEmpty() + ) { viewModel.onSuccessfulCourseEnrollment( fragmentManager = requireActivity().supportFragmentManager, - courseId = uiState.enrollmentSuccess.get(), + courseId = (uiState as CourseInfoUIState.CourseInfo).enrollmentSuccess.get(), ) // Clear after navigation - uiState.enrollmentSuccess.set("") + (uiState as CourseInfoUIState.CourseInfo).enrollmentSuccess.set("") } } CourseInfoScreen( windowSize = windowSize, uiState = uiState, + webViewUIState = webViewState, uiMessage = uiMessage, uriScheme = viewModel.uriScheme, hasInternetConnection = hasInternetConnection, isRegistrationEnabled = viewModel.isRegistrationEnabled, - checkInternetConnection = { - hasInternetConnection = viewModel.hasInternetConnection + onWebViewUIAction = { action -> + when (action) { + WebViewUIAction.WEB_PAGE_LOADED -> { + viewModel.onWebPageLoaded() + } + + WebViewUIAction.WEB_PAGE_ERROR -> { + viewModel.onWebPageError() + } + + WebViewUIAction.RELOAD_WEB_PAGE -> { + hasInternetConnection = viewModel.hasInternetConnection + viewModel.onWebPageLoading() + } + } }, onRegisterClick = { viewModel.navigateToSignUp( @@ -180,7 +199,7 @@ class CourseInfoFragment : Fragment() { linkAuthority.ENROLL -> { viewModel.courseEnrollClickedEvent(param) - if (uiState.isPreLogin) { + if ((uiState as CourseInfoUIState.CourseInfo).isPreLogin) { viewModel.navigateToSignUp( fragmentManager = requireActivity().supportFragmentManager, courseId = viewModel.pathId, @@ -221,11 +240,12 @@ class CourseInfoFragment : Fragment() { private fun CourseInfoScreen( windowSize: WindowSize, uiState: CourseInfoUIState, + webViewUIState: WebViewUIState, uiMessage: UIMessage?, uriScheme: String, isRegistrationEnabled: Boolean, hasInternetConnection: Boolean, - checkInternetConnection: () -> Unit, + onWebViewUIAction: (WebViewUIAction) -> Unit, onRegisterClick: () -> Unit, onSignInClick: () -> Unit, onBackClick: () -> Unit, @@ -233,7 +253,6 @@ private fun CourseInfoScreen( ) { val scaffoldState = rememberScaffoldState() val configuration = LocalConfiguration.current - var isLoading by remember { mutableStateOf(true) } HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) @@ -242,7 +261,7 @@ private fun CourseInfoScreen( modifier = Modifier.fillMaxSize(), backgroundColor = MaterialTheme.appColors.background, bottomBar = { - if (uiState.isPreLogin) { + if ((uiState as CourseInfoUIState.CourseInfo).isPreLogin) { Box( modifier = Modifier .padding( @@ -294,24 +313,27 @@ private fun CourseInfoScreen( .navigationBarsPadding(), contentAlignment = Alignment.TopCenter ) { - if (hasInternetConnection) { - CourseInfoWebView( - contentUrl = uiState.initialUrl, - uriScheme = uriScheme, - onWebPageLoaded = { isLoading = false }, - onUriClick = onUriClick, - ) - } else { - ConnectionErrorView( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight() - .background(MaterialTheme.appColors.background) - ) { - checkInternetConnection() + if ((webViewUIState is WebViewUIState.Error).not()) { + if (hasInternetConnection) { + CourseInfoWebView( + contentUrl = (uiState as CourseInfoUIState.CourseInfo).initialUrl, + uriScheme = uriScheme, + onWebPageLoaded = { onWebViewUIAction(WebViewUIAction.WEB_PAGE_LOADED) }, + onUriClick = onUriClick, + onWebPageLoadError = { + onWebViewUIAction(WebViewUIAction.WEB_PAGE_ERROR) + } + ) + } else { + onWebViewUIAction(WebViewUIAction.WEB_PAGE_ERROR) + } + } + if (webViewUIState is WebViewUIState.Error) { + FullScreenErrorView(errorType = webViewUIState.errorType) { + onWebViewUIAction(WebViewUIAction.RELOAD_WEB_PAGE) } } - if (isLoading && hasInternetConnection) { + if (webViewUIState is WebViewUIState.Loading && hasInternetConnection) { Box( modifier = Modifier .fillMaxSize() @@ -334,6 +356,7 @@ private fun CourseInfoWebView( uriScheme: String, onWebPageLoaded: () -> Unit, onUriClick: (String, linkAuthority) -> Unit, + onWebPageLoadError: () -> Unit ) { val webView = CatalogWebViewScreen( @@ -342,6 +365,7 @@ private fun CourseInfoWebView( isAllLinksExternal = true, onWebPageLoaded = onWebPageLoaded, onUriClick = onUriClick, + onWebPageLoadError = onWebPageLoadError ) AndroidView( @@ -360,7 +384,7 @@ fun CourseInfoScreenPreview() { OpenEdXTheme { CourseInfoScreen( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - uiState = CourseInfoUIState( + uiState = CourseInfoUIState.CourseInfo( initialUrl = "https://www.example.com/", isPreLogin = false, enrollmentSuccess = AtomicReference("") @@ -369,11 +393,12 @@ fun CourseInfoScreenPreview() { uriScheme = "", isRegistrationEnabled = true, hasInternetConnection = false, - checkInternetConnection = {}, + onWebViewUIAction = {}, onRegisterClick = {}, onSignInClick = {}, onBackClick = {}, onUriClick = { _, _ -> }, + webViewUIState = WebViewUIState.Loading, ) } } diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoUIState.kt b/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoUIState.kt index ffabf1daf..cd28abd2b 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoUIState.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoUIState.kt @@ -2,8 +2,10 @@ package org.openedx.discovery.presentation.info import java.util.concurrent.atomic.AtomicReference -internal data class CourseInfoUIState( - val initialUrl: String = "", - val isPreLogin: Boolean = false, - val enrollmentSuccess: AtomicReference = AtomicReference("") -) +sealed class CourseInfoUIState { + data class CourseInfo( + val initialUrl: String = "", + val isPreLogin: Boolean = false, + val enrollmentSuccess: AtomicReference = AtomicReference("") + ) : CourseInfoUIState() +} diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoViewModel.kt b/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoViewModel.kt index 87c64c770..65907f5cf 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoViewModel.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoViewModel.kt @@ -8,6 +8,7 @@ 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 kotlinx.coroutines.withContext @@ -17,6 +18,8 @@ import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.extension.isInternetError import org.openedx.core.presentation.CoreAnalyticsKey +import org.openedx.core.presentation.global.ErrorType +import org.openedx.core.presentation.global.webview.WebViewUIState import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseDashboardUpdate @@ -46,13 +49,17 @@ class CourseInfoViewModel( private val _uiState = MutableStateFlow( - CourseInfoUIState( + CourseInfoUIState.CourseInfo( initialUrl = getInitialUrl(), isPreLogin = config.isPreLoginExperienceEnabled() && corePreferences.user == null ) ) internal val uiState: StateFlow = _uiState + private val _webViewUIState = MutableStateFlow(WebViewUIState.Loading) + val webViewState + get() = _webViewUIState.asStateFlow() + private val _uiMessage = MutableSharedFlow() val uiMessage: SharedFlow get() = _uiMessage.asSharedFlow() @@ -189,6 +196,19 @@ class CourseInfoViewModel( } } + fun onWebPageLoaded() { + _webViewUIState.value = WebViewUIState.Loaded + } + + fun onWebPageError() { + _webViewUIState.value = + WebViewUIState.Error(if (networkConnection.isOnline()) ErrorType.UNKNOWN_ERROR else ErrorType.CONNECTION_ERROR) + } + + fun onWebPageLoading() { + _webViewUIState.value = WebViewUIState.Loading + } + companion object { private const val ARG_PATH_ID = "path_id" } 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 ef79e1f32..85809a9fb 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 @@ -50,8 +50,9 @@ import org.openedx.core.extension.takeIfNotEmpty import org.openedx.core.extension.toastMessage import org.openedx.core.presentation.dialog.alert.ActionDialogFragment import org.openedx.core.presentation.dialog.alert.InfoDialogFragment +import org.openedx.core.presentation.global.webview.WebViewUIAction import org.openedx.core.system.AppCookieManager -import org.openedx.core.ui.ConnectionErrorView +import org.openedx.core.ui.FullScreenErrorView import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.Toolbar import org.openedx.core.ui.WindowSize @@ -133,10 +134,22 @@ class ProgramFragment : Fragment() { isNestedFragment = isNestedFragment, uriScheme = viewModel.uriScheme, hasInternetConnection = hasInternetConnection, - checkInternetConnection = { - hasInternetConnection = viewModel.hasInternetConnection + onWebViewUIAction = { action -> + when (action) { + WebViewUIAction.WEB_PAGE_LOADED -> { + viewModel.showLoading(false) + } + + WebViewUIAction.WEB_PAGE_ERROR -> { + viewModel.onPageLoadError() + } + + WebViewUIAction.RELOAD_WEB_PAGE -> { + hasInternetConnection = viewModel.hasInternetConnection + viewModel.showLoading(true) + } + } }, - onWebPageLoaded = { viewModel.showLoading(false) }, onBackClick = { requireActivity().supportFragmentManager.popBackStackImmediate() }, @@ -192,7 +205,7 @@ class ProgramFragment : Fragment() { }, onSettingsClick = { viewModel.navigateToSettings(requireActivity().supportFragmentManager) - }, + } ) } } @@ -234,8 +247,7 @@ private fun ProgramInfoScreen( canShowBackBtn: Boolean, isNestedFragment: Boolean, hasInternetConnection: Boolean, - checkInternetConnection: () -> Unit, - onWebPageLoaded: () -> Unit, + onWebViewUIAction: (WebViewUIAction) -> Unit, onSettingsClick: () -> Unit, onBackClick: () -> Unit, onUriClick: (String, WebViewLink.Authority) -> Unit, @@ -243,7 +255,6 @@ private fun ProgramInfoScreen( val scaffoldState = rememberScaffoldState() val configuration = LocalConfiguration.current val coroutineScope = rememberCoroutineScope() - val isLoading = uiState is ProgramUIState.Loading when (uiState) { is ProgramUIState.UiMessage -> { @@ -304,41 +315,44 @@ private fun ProgramInfoScreen( .background(Color.White), contentAlignment = Alignment.TopCenter ) { - if (hasInternetConnection) { - val webView = CatalogWebViewScreen( - url = contentUrl, - uriScheme = uriScheme, - isAllLinksExternal = true, - onWebPageLoaded = onWebPageLoaded, - refreshSessionCookie = { - coroutineScope.launch { - cookieManager.tryToRefreshSessionCookie() + if ((uiState is ProgramUIState.Error).not()) { + if (hasInternetConnection) { + val webView = CatalogWebViewScreen( + url = contentUrl, + uriScheme = uriScheme, + isAllLinksExternal = true, + onWebPageLoaded = { onWebViewUIAction(WebViewUIAction.WEB_PAGE_LOADED) }, + refreshSessionCookie = { + coroutineScope.launch { + cookieManager.tryToRefreshSessionCookie() + } + }, + onUriClick = onUriClick, + onWebPageLoadError = { onWebViewUIAction(WebViewUIAction.WEB_PAGE_ERROR) } + ) + + AndroidView( + modifier = Modifier + .background(MaterialTheme.appColors.background), + factory = { + webView + }, + update = { + webView.loadUrl(contentUrl, coroutineScope, cookieManager) } - }, - onUriClick = onUriClick, - ) + ) + } else { + onWebViewUIAction(WebViewUIAction.WEB_PAGE_ERROR) + } + } - AndroidView( - modifier = Modifier - .background(MaterialTheme.appColors.background), - factory = { - webView - }, - update = { - webView.loadUrl(contentUrl, coroutineScope, cookieManager) - } - ) - } else { - ConnectionErrorView( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight() - .background(MaterialTheme.appColors.background) - ) { - checkInternetConnection() + if (uiState is ProgramUIState.Error) { + FullScreenErrorView(errorType = uiState.errorType) { + onWebViewUIAction(WebViewUIAction.RELOAD_WEB_PAGE) } } - if (isLoading && hasInternetConnection) { + + if (uiState == ProgramUIState.Loading && hasInternetConnection) { Box( modifier = Modifier .fillMaxSize() @@ -368,9 +382,8 @@ fun MyProgramsPreview() { canShowBackBtn = false, isNestedFragment = false, hasInternetConnection = false, - checkInternetConnection = {}, + onWebViewUIAction = {}, onBackClick = {}, - onWebPageLoaded = {}, onSettingsClick = {}, onUriClick = { _, _ -> }, ) diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramUIState.kt b/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramUIState.kt index fa7f395d7..bed418100 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramUIState.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramUIState.kt @@ -1,10 +1,12 @@ package org.openedx.discovery.presentation.program import org.openedx.core.UIMessage +import org.openedx.core.presentation.global.ErrorType sealed class ProgramUIState { data object Loading : ProgramUIState() data object Loaded : ProgramUIState() + data class Error(val errorType: ErrorType) : ProgramUIState() class CourseEnrolled(val courseId: String, val isEnrolled: Boolean) : ProgramUIState() diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramViewModel.kt b/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramViewModel.kt index 1bed6d2cd..59a26cba5 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramViewModel.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramViewModel.kt @@ -2,16 +2,16 @@ package org.openedx.discovery.presentation.program import androidx.fragment.app.FragmentManager import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +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.extension.isInternetError +import org.openedx.core.presentation.global.ErrorType import org.openedx.core.system.AppCookieManager import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection @@ -38,12 +38,8 @@ class ProgramViewModel( val hasInternetConnection: Boolean get() = networkConnection.isOnline() - private val _uiState = MutableSharedFlow( - replay = 0, - extraBufferCapacity = 1, - onBufferOverflow = BufferOverflow.DROP_OLDEST - ) - val uiState: SharedFlow get() = _uiState.asSharedFlow() + private val _uiState = MutableStateFlow(ProgramUIState.Loading) + val uiState: StateFlow get() = _uiState.asStateFlow() fun showLoading(isLoading: Boolean) { viewModelScope.launch { @@ -97,6 +93,9 @@ class ProgramViewModel( enrollmentMode = "" ) } + viewModelScope.launch { + _uiState.emit(ProgramUIState.Loaded) + } } fun navigateToDiscovery() { @@ -106,4 +105,10 @@ class ProgramViewModel( fun navigateToSettings(fragmentManager: FragmentManager) { router.navigateToSettings(fragmentManager) } + + fun onPageLoadError() { + viewModelScope.launch { + _uiState.emit(ProgramUIState.Error(if (networkConnection.isOnline()) ErrorType.UNKNOWN_ERROR else ErrorType.CONNECTION_ERROR)) + } + } } From a4eac8da3cc3e56fb6d770dcd93204ee000f94d8 Mon Sep 17 00:00:00 2001 From: Igor Date: Thu, 31 Oct 2024 12:19:58 +0100 Subject: [PATCH 52/56] feat: Course Level Error Handling for Empty States (#393) Co-authored-by: Farhan Arshad <43750646+farhan-arshad-dev@users.noreply.github.com> --- .../org/openedx/core/NoContentScreenType.kt | 31 +++ .../java/org/openedx/core/ui/ComposeCommon.kt | 65 ++++++ .../org/openedx/core/ui/WebContentScreen.kt | 12 +- .../res/drawable/core_ic_no_announcements.xml | 11 + .../main/res/drawable/core_ic_no_content.xml | 11 + .../main/res/drawable/core_ic_no_handouts.xml | 11 + .../main/res/drawable/core_ic_no_videos.xml | 11 + core/src/main/res/values/strings.xml | 6 + .../presentation/dates/CourseDatesScreen.kt | 44 ++-- .../presentation/dates/CourseDatesUIState.kt | 2 +- .../dates/CourseDatesViewModel.kt | 5 +- .../presentation/dates/DashboardUIState.kt | 11 +- .../presentation/handouts/HandoutsUIState.kt | 7 + .../handouts/HandoutsViewModel.kt | 33 ++- .../handouts/HandoutsWebViewFragment.kt | 215 ++++++++++++++++-- .../outline/CourseOutlineScreen.kt | 197 ++++++++-------- .../course/presentation/ui/CourseVideosUI.kt | 29 +-- .../videos/CourseVideoViewModel.kt | 8 +- .../videos/CourseVideosUIState.kt | 4 +- course/src/main/res/values/strings.xml | 1 - .../dates/CourseDatesViewModelTest.kt | 8 +- .../handouts/HandoutsViewModelTest.kt | 24 +- .../videos/CourseVideoViewModelTest.kt | 1 - .../topics/DiscussionTopicsScreen.kt | 59 +++-- .../topics/DiscussionTopicsUIState.kt | 5 +- .../topics/DiscussionTopicsViewModel.kt | 9 +- .../topics/DiscussionTopicsViewModelTest.kt | 87 +------ 27 files changed, 603 insertions(+), 304 deletions(-) create mode 100644 core/src/main/java/org/openedx/core/NoContentScreenType.kt create mode 100644 core/src/main/res/drawable/core_ic_no_announcements.xml create mode 100644 core/src/main/res/drawable/core_ic_no_content.xml create mode 100644 core/src/main/res/drawable/core_ic_no_handouts.xml create mode 100644 core/src/main/res/drawable/core_ic_no_videos.xml create mode 100644 course/src/main/java/org/openedx/course/presentation/handouts/HandoutsUIState.kt diff --git a/core/src/main/java/org/openedx/core/NoContentScreenType.kt b/core/src/main/java/org/openedx/core/NoContentScreenType.kt new file mode 100644 index 000000000..88e8ad94b --- /dev/null +++ b/core/src/main/java/org/openedx/core/NoContentScreenType.kt @@ -0,0 +1,31 @@ +package org.openedx.core + +enum class NoContentScreenType( + val iconResId: Int, + val messageResId: Int, +) { + COURSE_OUTLINE( + iconResId = R.drawable.core_ic_no_content, + messageResId = R.string.core_no_course_content + ), + COURSE_VIDEOS( + iconResId = R.drawable.core_ic_no_videos, + messageResId = R.string.core_no_videos + ), + COURSE_DATES( + iconResId = R.drawable.core_ic_no_content, + messageResId = R.string.core_no_dates + ), + COURSE_DISCUSSIONS( + iconResId = R.drawable.core_ic_no_content, + messageResId = R.string.core_no_discussion + ), + COURSE_HANDOUTS( + iconResId = R.drawable.core_ic_no_handouts, + messageResId = R.string.core_no_handouts + ), + COURSE_ANNOUNCEMENTS( + iconResId = R.drawable.core_ic_no_announcements, + messageResId = R.string.core_no_announcements + ) +} 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 d50b05cbe..3c4578d58 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt @@ -31,12 +31,15 @@ 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.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults +import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Divider import androidx.compose.material.Icon import androidx.compose.material.IconButton @@ -49,6 +52,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.Info import androidx.compose.material.icons.filled.ManageAccounts import androidx.compose.material.icons.filled.Search import androidx.compose.runtime.Composable @@ -97,11 +101,13 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex import coil.ImageLoader import coil.compose.AsyncImage import coil.decode.GifDecoder import coil.decode.ImageDecoderDecoder import kotlinx.coroutines.launch +import org.openedx.core.NoContentScreenType import org.openedx.core.R import org.openedx.core.UIMessage import org.openedx.core.domain.model.RegistrationField @@ -1185,6 +1191,41 @@ fun FullScreenErrorView( } } +@Composable +fun NoContentScreen(noContentScreenType: NoContentScreenType) { + NoContentScreen( + message = stringResource(id = noContentScreenType.messageResId), + icon = painterResource(id = noContentScreenType.iconResId) + ) +} + +@Composable +fun NoContentScreen(message: String, icon: Painter) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + modifier = Modifier.size(80.dp), + painter = icon, + contentDescription = null, + tint = MaterialTheme.appColors.progressBarBackgroundColor, + ) + Spacer(Modifier.height(24.dp)) + Text( + modifier = Modifier.fillMaxWidth(0.8f), + text = message, + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.bodyMedium, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center + ) + } +} + @Composable fun AuthButtonsPanel( onRegisterClick: () -> Unit, @@ -1280,6 +1321,19 @@ fun RoundTabsBar( } } +@Composable +fun CircularProgress() { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.appColors.background) + .zIndex(1f), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } +} + @Composable private fun RoundTab( modifier: Modifier = Modifier, @@ -1400,3 +1454,14 @@ private fun RoundTabsBarPreview() { ) } } + +@Preview +@Composable +private fun PreviewNoContentScreen() { + OpenEdXTheme(darkTheme = true) { + NoContentScreen( + "No Content available", + rememberVectorPainter(image = Icons.Filled.Info) + ) + } +} diff --git a/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt b/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt index 06aa70ea2..2fe762b26 100644 --- a/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt +++ b/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt @@ -6,7 +6,6 @@ import android.net.Uri import android.webkit.WebResourceRequest import android.webkit.WebView import android.webkit.WebViewClient -import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -14,7 +13,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn -import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold import androidx.compose.material.Surface @@ -101,15 +99,7 @@ fun WebContentScreen( color = MaterialTheme.appColors.background ) { if (htmlBody.isNullOrEmpty() && contentUrl.isNullOrEmpty()) { - Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.appColors.background) - .zIndex(1f), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator(color = MaterialTheme.appColors.primary) - } + CircularProgress() } else { var webViewAlpha by rememberSaveable { mutableFloatStateOf(0f) } Surface( diff --git a/core/src/main/res/drawable/core_ic_no_announcements.xml b/core/src/main/res/drawable/core_ic_no_announcements.xml new file mode 100644 index 000000000..fc85b3fe1 --- /dev/null +++ b/core/src/main/res/drawable/core_ic_no_announcements.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/core/src/main/res/drawable/core_ic_no_content.xml b/core/src/main/res/drawable/core_ic_no_content.xml new file mode 100644 index 000000000..94a134d7e --- /dev/null +++ b/core/src/main/res/drawable/core_ic_no_content.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/core/src/main/res/drawable/core_ic_no_handouts.xml b/core/src/main/res/drawable/core_ic_no_handouts.xml new file mode 100644 index 000000000..d1f19a3d3 --- /dev/null +++ b/core/src/main/res/drawable/core_ic_no_handouts.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/core/src/main/res/drawable/core_ic_no_videos.xml b/core/src/main/res/drawable/core_ic_no_videos.xml new file mode 100644 index 000000000..f8a55d1b9 --- /dev/null +++ b/core/src/main/res/drawable/core_ic_no_videos.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index b023e8845..c8d529afa 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -169,6 +169,12 @@ Discussions More Dates + No course content is currently available. + There are currently no videos for this course. + Course dates are currently not available. + Unable to load discussions.\n Please try again later. + There are currently no handouts for this course. + There are currently no announcements for this course. Confirm Download Edit Offline Progress Sync 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 d76eb5eab..e15d3f7d4 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 @@ -56,13 +56,13 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.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 org.openedx.core.NoContentScreenType import org.openedx.core.UIMessage import org.openedx.core.data.model.DateType import org.openedx.core.domain.model.CourseDateBlock @@ -74,7 +74,10 @@ import org.openedx.core.presentation.CoreAnalyticsScreen import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.presentation.dialog.alert.ActionDialogFragment import org.openedx.core.presentation.settings.calendarsync.CalendarSyncState +import org.openedx.core.presentation.settings.calendarsync.CalendarSyncUIState +import org.openedx.core.ui.CircularProgress import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.NoContentScreen import org.openedx.core.ui.WindowSize import org.openedx.core.ui.WindowType import org.openedx.core.ui.displayCutoutForLandscape @@ -336,22 +339,13 @@ private fun CourseDatesUI( } } - CourseDatesUIState.Empty -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Text( - modifier = Modifier.fillMaxWidth(), - text = stringResource(id = R.string.course_dates_unavailable_message), - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.titleMedium, - textAlign = TextAlign.Center - ) - } + CourseDatesUIState.Error -> { + NoContentScreen(noContentScreenType = NoContentScreenType.COURSE_DATES) } - CourseDatesUIState.Loading -> {} + CourseDatesUIState.Loading -> { + CircularProgress() + } } } } @@ -676,6 +670,26 @@ private fun CourseDateItem( } } + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun EmptyCourseDatesScreenPreview() { + OpenEdXTheme { + CourseDatesUI( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + uiState = CourseDatesUIState.Error, + uiMessage = null, + isSelfPaced = true, + useRelativeDates = true, + onItemClick = {}, + onPLSBannerViewed = {}, + onSyncDates = {}, + onCalendarSyncStateClick = {}, + ) + } +} + @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable diff --git a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesUIState.kt b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesUIState.kt index 5623129d0..17f6e3b46 100644 --- a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesUIState.kt +++ b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesUIState.kt @@ -9,6 +9,6 @@ sealed interface CourseDatesUIState { val calendarSyncState: CalendarSyncState, ) : CourseDatesUIState - data object Empty : CourseDatesUIState + data object Error : CourseDatesUIState data object Loading : CourseDatesUIState } 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 48fd0a524..54406019d 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 @@ -101,7 +101,7 @@ class CourseDatesViewModel( isSelfPaced = courseStructure?.isSelfPaced ?: false val datesResponse = interactor.getCourseDates(courseId = courseId) if (datesResponse.datesSection.isEmpty()) { - _uiState.value = CourseDatesUIState.Empty + _uiState.value = CourseDatesUIState.Error } else { val courseDates = datesResponse.datesSection.values.flatten() val calendarState = getCalendarState(courseDates) @@ -110,10 +110,9 @@ class CourseDatesViewModel( checkIfCalendarOutOfDate() } } catch (e: Exception) { + _uiState.value = CourseDatesUIState.Error if (e.isInternetError()) { _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(CoreR.string.core_error_no_connection))) - } else { - _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(CoreR.string.core_error_unknown_error))) } } finally { courseNotifier.send(CourseLoading(false)) diff --git a/course/src/main/java/org/openedx/course/presentation/dates/DashboardUIState.kt b/course/src/main/java/org/openedx/course/presentation/dates/DashboardUIState.kt index 18aebac3f..6dbb71fb2 100644 --- a/course/src/main/java/org/openedx/course/presentation/dates/DashboardUIState.kt +++ b/course/src/main/java/org/openedx/course/presentation/dates/DashboardUIState.kt @@ -3,12 +3,11 @@ package org.openedx.course.presentation.dates import org.openedx.core.domain.model.CourseDatesResult import org.openedx.core.presentation.settings.calendarsync.CalendarSyncState -sealed interface DatesUIState { +sealed class DatesUIState { data class Dates( val courseDatesResult: CourseDatesResult, - val calendarSyncState: CalendarSyncState - ) : DatesUIState - - data object Empty : DatesUIState - data object Loading : DatesUIState + val calendarSyncState: CalendarSyncState, + ) : DatesUIState() + data object Error : DatesUIState() + data object Loading : DatesUIState() } diff --git a/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsUIState.kt b/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsUIState.kt new file mode 100644 index 000000000..860e4261f --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsUIState.kt @@ -0,0 +1,7 @@ +package org.openedx.course.presentation.handouts + +sealed class HandoutsUIState { + data object Loading : HandoutsUIState() + data class HTMLContent(val htmlContent: String) : HandoutsUIState() + data object Error : HandoutsUIState() +} diff --git a/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsViewModel.kt b/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsViewModel.kt index 92aaa139d..424f71f81 100644 --- a/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsViewModel.kt @@ -1,8 +1,9 @@ package org.openedx.course.presentation.handouts -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.openedx.core.BaseViewModel import org.openedx.core.config.Config @@ -23,26 +24,40 @@ class HandoutsViewModel( val apiHostUrl get() = config.getApiHostURL() - private val _htmlContent = MutableLiveData() - val htmlContent: LiveData - get() = _htmlContent + private val _uiState = MutableStateFlow(HandoutsUIState.Loading) + val uiState: StateFlow + get() = _uiState.asStateFlow() init { - getEnrolledCourse() + getCourseHandouts() } - private fun getEnrolledCourse() { + private fun getCourseHandouts() { viewModelScope.launch { + var emptyState = false try { if (HandoutsType.valueOf(handoutsType) == HandoutsType.Handouts) { val handouts = interactor.getHandouts(courseId) - _htmlContent.value = handoutsToHtml(handouts) + if (handouts.handoutsHtml.isNotBlank()) { + _uiState.value = HandoutsUIState.HTMLContent(handoutsToHtml(handouts)) + } else { + emptyState = true + } } else { val announcements = interactor.getAnnouncements(courseId) - _htmlContent.value = announcementsToHtml(announcements) + if (announcements.isNotEmpty()) { + _uiState.value = + HandoutsUIState.HTMLContent(announcementsToHtml(announcements)) + } else { + emptyState = true + } } } catch (e: Exception) { //ignore e.printStackTrace() + emptyState = true + } + if (emptyState) { + _uiState.value = HandoutsUIState.Error } } } diff --git a/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsWebViewFragment.kt b/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsWebViewFragment.kt index 16cc67b84..dbcbde30a 100644 --- a/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsWebViewFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsWebViewFragment.kt @@ -4,24 +4,49 @@ import android.content.res.Configuration import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId 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.compose.ui.zIndex import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf +import org.openedx.core.NoContentScreenType +import org.openedx.core.ui.CircularProgress +import org.openedx.core.ui.NoContentScreen +import org.openedx.core.ui.Toolbar import org.openedx.core.ui.WebContentScreen import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType +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.windowSizeValue import org.openedx.course.R import org.openedx.course.presentation.CourseAnalyticsEvent @@ -51,31 +76,32 @@ class HandoutsWebViewFragment : Fragment() { setContent { OpenEdXTheme { - val windowSize = rememberWindowSize() - - val htmlBody by viewModel.htmlContent.observeAsState("") val colorBackgroundValue = MaterialTheme.appColors.background.value val colorTextValue = MaterialTheme.appColors.textPrimary.value - - WebContentScreen( - windowSize = windowSize, - apiHostUrl = viewModel.apiHostUrl, + val uiState by viewModel.uiState.collectAsState() + HandoutsScreens( + handoutType = HandoutsType.valueOf(viewModel.handoutsType), + uiState = uiState, title = title, - htmlBody = viewModel.injectDarkMode( - htmlBody, - colorBackgroundValue, - colorTextValue - ), + apiHostUrl = viewModel.apiHostUrl, + onInjectDarkMode = { + viewModel.injectDarkMode( + (uiState as HandoutsUIState.HTMLContent).htmlContent, + colorBackgroundValue, + colorTextValue + ) + }, onBackClick = { requireActivity().supportFragmentManager.popBackStack() - }) + } + ) } } } companion object { - private val ARG_TYPE = "argType" - private val ARG_COURSE_ID = "argCourse" + private const val ARG_TYPE = "argType" + private const val ARG_COURSE_ID = "argCourse" fun newInstance( type: String, @@ -91,24 +117,163 @@ class HandoutsWebViewFragment : Fragment() { } } +@Composable +fun HandoutsScreens( + handoutType: HandoutsType, + uiState: HandoutsUIState, + title: String, + apiHostUrl: String, + onInjectDarkMode: () -> String, + onBackClick: () -> Unit +) { + val windowSize = rememberWindowSize() + when (uiState) { + is HandoutsUIState.Loading -> { + CircularProgress() + } + + is HandoutsUIState.HTMLContent -> { + WebContentScreen( + windowSize = windowSize, + apiHostUrl = apiHostUrl, + title = title, + htmlBody = onInjectDarkMode(), + onBackClick = onBackClick + ) + } + + HandoutsUIState.Error -> { + HandoutsEmptyScreen( + windowSize = windowSize, + handoutType = handoutType, + title = title, + onBackClick = onBackClick + ) + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun HandoutsEmptyScreen( + windowSize: WindowSize, + handoutType: HandoutsType, + title: String, + onBackClick: () -> Unit +) { + val handoutScreenType = + if (handoutType == HandoutsType.Handouts) NoContentScreenType.COURSE_HANDOUTS + else NoContentScreenType.COURSE_ANNOUNCEMENTS + + val scaffoldState = rememberScaffoldState() + Scaffold( + modifier = Modifier + .fillMaxSize() + .padding(bottom = 24.dp) + .semantics { + testTagsAsResourceId = true + }, + scaffoldState = scaffoldState, + backgroundColor = MaterialTheme.appColors.background + ) { + + val screenWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), + compact = Modifier.fillMaxWidth() + ) + ) + } + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(it) + .statusBarsInset() + .displayCutoutForLandscape(), + contentAlignment = Alignment.TopCenter + ) { + Column(screenWidth) { + Box( + Modifier + .fillMaxWidth() + .zIndex(1f), + contentAlignment = Alignment.CenterStart + ) { + Toolbar( + label = title, + canShowBackBtn = true, + onBackClick = onBackClick + ) + } + Surface( + Modifier.fillMaxSize(), + color = MaterialTheme.appColors.background + ) { + NoContentScreen(noContentScreenType = handoutScreenType) + } + } + } + } +} + @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -fun WebContentScreenPreview() { - WebContentScreen( - windowSize = WindowSize(WindowType.Compact, WindowType.Compact), +fun HandoutsScreensPreview() { + HandoutsScreens( + handoutType = HandoutsType.Handouts, + uiState = HandoutsUIState.HTMLContent(htmlContent = ""), + title = "Handouts", apiHostUrl = "http://localhost:8000", - title = "Handouts", onBackClick = { }, htmlBody = "" + onInjectDarkMode = { "" }, + onBackClick = { } ) } @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, device = Devices.NEXUS_9) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.NEXUS_9) @Composable -fun WebContentScreenTabletPreview() { - WebContentScreen( - windowSize = WindowSize(WindowType.Medium, WindowType.Medium), +fun HandoutsScreensTabletPreview() { + HandoutsScreens( + handoutType = HandoutsType.Handouts, + uiState = HandoutsUIState.HTMLContent(htmlContent = ""), + title = "Handouts", apiHostUrl = "http://localhost:8000", - title = "Handouts", onBackClick = { }, htmlBody = "" + onInjectDarkMode = { "" }, + onBackClick = { } ) } + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun EmptyHandoutsScreensPreview() { + OpenEdXTheme(darkTheme = true) { + HandoutsScreens( + handoutType = HandoutsType.Handouts, + uiState = HandoutsUIState.Error, + title = "Handouts", + apiHostUrl = "http://localhost:8000", + onInjectDarkMode = { "" }, + onBackClick = { } + ) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun EmptyAnnouncementsScreensPreview() { + OpenEdXTheme(darkTheme = true) { + HandoutsScreens( + handoutType = HandoutsType.Announcements, + uiState = HandoutsUIState.Error, + title = "Handouts", + apiHostUrl = "http://localhost:8000", + onInjectDarkMode = { "" }, + onBackClick = { } + ) + } +} 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 10ad4f932..90d74e7f5 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 @@ -45,6 +45,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentManager import org.openedx.core.BlockType +import org.openedx.core.NoContentScreenType import org.openedx.core.UIMessage import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block @@ -56,7 +57,9 @@ import org.openedx.core.domain.model.OfflineDownload import org.openedx.core.domain.model.Progress import org.openedx.core.extension.takeIfNotEmpty import org.openedx.core.presentation.course.CourseViewMode +import org.openedx.core.ui.CircularProgress import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.NoContentScreen import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.TextIcon import org.openedx.core.ui.WindowSize @@ -222,116 +225,130 @@ private fun CourseOutlineUI( Box { when (uiState) { is CourseOutlineUIState.CourseData -> { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = listBottomPadding - ) { - if (uiState.datesBannerInfo.isBannerAvailableForDashboard()) { - item { - Box( - modifier = Modifier - .padding(all = 8.dp) - ) { - if (windowSize.isTablet) { - CourseDatesBannerTablet( - banner = uiState.datesBannerInfo, - resetDates = onResetDatesClick, - ) - } else { - CourseDatesBanner( - banner = uiState.datesBannerInfo, - resetDates = onResetDatesClick, - ) + if (uiState.courseStructure.blockData.isEmpty()) { + NoContentScreen(noContentScreenType = NoContentScreenType.COURSE_OUTLINE) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = listBottomPadding + ) { + if (uiState.datesBannerInfo.isBannerAvailableForDashboard()) { + item { + Box( + modifier = Modifier + .padding(all = 8.dp) + ) { + if (windowSize.isTablet) { + CourseDatesBannerTablet( + banner = uiState.datesBannerInfo, + resetDates = onResetDatesClick, + ) + } else { + CourseDatesBanner( + banner = uiState.datesBannerInfo, + resetDates = onResetDatesClick, + ) + } } } } - } - val certificate = uiState.courseStructure.certificate - if (certificate?.isCertificateEarned() == true) { - item { - CourseMessage( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 12.dp) - .then(listPadding), - icon = painterResource(R.drawable.ic_course_certificate), - message = stringResource( - R.string.course_you_earned_certificate, - uiState.courseStructure.name - ), - action = stringResource(R.string.course_view_certificate), - onActionClick = { - onCertificateClick(certificate.certificateURL ?: "") - } - ) + val certificate = uiState.courseStructure.certificate + if (certificate?.isCertificateEarned() == true) { + item { + CourseMessage( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp) + .then(listPadding), + icon = painterResource(R.drawable.ic_course_certificate), + message = stringResource( + R.string.course_you_earned_certificate, + uiState.courseStructure.name + ), + action = stringResource(R.string.course_view_certificate), + onActionClick = { + onCertificateClick( + certificate.certificateURL ?: "" + ) + } + ) + } } - } - val progress = uiState.courseStructure.progress - if (progress != null && progress.totalAssignmentsCount > 0) { - item { - CourseProgress( - modifier = Modifier - .fillMaxWidth() - .padding(top = 16.dp, start = 24.dp, end = 24.dp), - progress = progress - ) + val progress = uiState.courseStructure.progress + if (progress != null && progress.totalAssignmentsCount > 0) { + item { + CourseProgress( + modifier = Modifier + .fillMaxWidth() + .padding( + top = 16.dp, + start = 24.dp, + end = 24.dp + ), + progress = progress + ) + } } - } - if (uiState.resumeComponent != null) { - item { - Box(listPadding) { - if (windowSize.isTablet) { - ResumeCourseTablet( - modifier = Modifier.padding(vertical = 16.dp), - block = uiState.resumeComponent, - displayName = uiState.resumeUnitTitle, - onResumeClick = onResumeClick - ) - } else { - ResumeCourse( - modifier = Modifier.padding(vertical = 16.dp), - block = uiState.resumeComponent, - displayName = uiState.resumeUnitTitle, - onResumeClick = onResumeClick - ) + if (uiState.resumeComponent != null) { + item { + Box(listPadding) { + if (windowSize.isTablet) { + ResumeCourseTablet( + modifier = Modifier.padding(vertical = 16.dp), + block = uiState.resumeComponent, + displayName = uiState.resumeUnitTitle, + onResumeClick = onResumeClick + ) + } else { + ResumeCourse( + modifier = Modifier.padding(vertical = 16.dp), + block = uiState.resumeComponent, + displayName = uiState.resumeUnitTitle, + onResumeClick = onResumeClick + ) + } } } } - } - - item { - Spacer(modifier = Modifier.height(12.dp)) - } - uiState.courseStructure.blockData.forEach { section -> - val courseSubSections = - uiState.courseSubSections[section.id] - val courseSectionsState = - uiState.courseSectionsState[section.id] item { - CourseSection( - modifier = listPadding.padding(vertical = 4.dp), - block = section, - onItemClick = onExpandClick, - useRelativeDates = uiState.useRelativeDates, - courseSectionsState = courseSectionsState, - courseSubSections = courseSubSections, - downloadedStateMap = uiState.downloadedState, - onSubSectionClick = onSubSectionClick, - onDownloadClick = onDownloadClick - ) + Spacer(modifier = Modifier.height(12.dp)) + } + uiState.courseStructure.blockData.forEach { section -> + val courseSubSections = + uiState.courseSubSections[section.id] + val courseSectionsState = + uiState.courseSectionsState[section.id] + + item { + CourseSection( + modifier = listPadding.padding(vertical = 4.dp), + block = section, + onItemClick = onExpandClick, + useRelativeDates = uiState.useRelativeDates, + courseSectionsState = courseSectionsState, + courseSubSections = courseSubSections, + downloadedStateMap = uiState.downloadedState, + onSubSectionClick = onSubSectionClick, + onDownloadClick = onDownloadClick + ) + } } } } } - CourseOutlineUIState.Error -> {} + CourseOutlineUIState.Error -> { + NoContentScreen(noContentScreenType = NoContentScreenType.COURSE_OUTLINE) + } - CourseOutlineUIState.Loading -> {} + CourseOutlineUIState.Loading -> { + CircularProgress() + } } } } 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 73afb3d0b..5fd4ea981 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 @@ -18,8 +18,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.material.AlertDialog import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Divider @@ -48,7 +46,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp @@ -56,6 +53,7 @@ import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentManager import org.openedx.core.AppDataConstants import org.openedx.core.BlockType +import org.openedx.core.NoContentScreenType import org.openedx.core.UIMessage import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block @@ -68,7 +66,9 @@ import org.openedx.core.extension.toFileSize import org.openedx.core.module.download.DownloadModelsSize import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.presentation.settings.video.VideoQualityType +import org.openedx.core.ui.CircularProgress import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.NoContentScreen import org.openedx.core.ui.WindowSize import org.openedx.core.ui.WindowType import org.openedx.core.ui.displayCutoutForLandscape @@ -241,20 +241,7 @@ private fun CourseVideosUI( ) { when (uiState) { is CourseVideosUIState.Empty -> { - Box( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()), - contentAlignment = Alignment.Center - ) { - Text( - text = stringResource(id = R.string.course_does_not_include_videos), - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.headlineSmall, - textAlign = TextAlign.Center, - modifier = Modifier.padding(horizontal = 40.dp) - ) - } + NoContentScreen(noContentScreenType = NoContentScreenType.COURSE_VIDEOS) } is CourseVideosUIState.CourseData -> { @@ -309,7 +296,9 @@ private fun CourseVideosUI( } } - CourseVideosUIState.Loading -> {} + CourseVideosUIState.Loading -> { + CircularProgress() + } } } } @@ -656,9 +645,7 @@ private fun CourseVideosScreenEmptyPreview() { CourseVideosUI( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), uiMessage = null, - uiState = CourseVideosUIState.Empty( - "This course does not include any videos." - ), + uiState = CourseVideosUIState.Empty, courseTitle = "", onExpandClick = { }, onSubSectionClick = { }, 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 e5bbffe05..a02eac54c 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 @@ -154,9 +154,7 @@ class CourseVideoViewModel( var courseStructure = interactor.getCourseStructureForVideos(courseId) val blocks = courseStructure.blockData if (blocks.isEmpty()) { - _uiState.value = CourseVideosUIState.Empty( - message = resourceManager.getString(R.string.course_does_not_include_videos) - ) + _uiState.value = CourseVideosUIState.Empty } else { setBlocks(courseStructure.blockData) courseSubSections.clear() @@ -180,9 +178,7 @@ class CourseVideoViewModel( } courseNotifier.send(CourseLoading(false)) } catch (e: Exception) { - _uiState.value = CourseVideosUIState.Empty( - message = resourceManager.getString(R.string.course_does_not_include_videos) - ) + _uiState.value = CourseVideosUIState.Empty } } } diff --git a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideosUIState.kt b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideosUIState.kt index 44f485c98..245fb2380 100644 --- a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideosUIState.kt +++ b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideosUIState.kt @@ -16,6 +16,6 @@ sealed class CourseVideosUIState { val useRelativeDates: Boolean ) : CourseVideosUIState() - data class Empty(val message: String) : CourseVideosUIState() - object Loading : CourseVideosUIState() + data object Empty : CourseVideosUIState() + data object Loading : CourseVideosUIState() } diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index 8be55b9d4..c0b03e756 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -12,7 +12,6 @@ Next Next Unit Finish - This course does not include any videos. Last unit: Resume Discussion 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 ed4e28f58..389196f31 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 @@ -180,7 +180,7 @@ class CourseDatesViewModelTest { coVerify(exactly = 1) { interactor.getCourseDates(any()) } Assert.assertEquals(noInternet, message.await()?.message) - assert(viewModel.uiState.value is CourseDatesUIState.Loading) + assert(viewModel.uiState.value is CourseDatesUIState.Error) } @Test @@ -209,8 +209,8 @@ class CourseDatesViewModelTest { coVerify(exactly = 1) { interactor.getCourseDates(any()) } - Assert.assertEquals(somethingWrong, message.await()?.message) - assert(viewModel.uiState.value is CourseDatesUIState.Loading) + assert(message.await()?.message.isNullOrEmpty()) + assert(viewModel.uiState.value is CourseDatesUIState.Error) } @Test @@ -273,6 +273,6 @@ class CourseDatesViewModelTest { coVerify(exactly = 1) { interactor.getCourseDates(any()) } assert(message.await()?.message.isNullOrEmpty()) - assert(viewModel.uiState.value is CourseDatesUIState.Empty) + assert(viewModel.uiState.value is CourseDatesUIState.Error) } } diff --git a/course/src/test/java/org/openedx/course/presentation/handouts/HandoutsViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/handouts/HandoutsViewModelTest.kt index 6e8d2dab2..41074294a 100644 --- a/course/src/test/java/org/openedx/course/presentation/handouts/HandoutsViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/handouts/HandoutsViewModelTest.kt @@ -5,22 +5,24 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk -import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.* +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain import org.junit.After -import org.junit.Assert.* import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule import org.openedx.core.config.Config -import org.openedx.core.domain.model.* +import org.openedx.core.domain.model.AnnouncementModel +import org.openedx.core.domain.model.HandoutsModel import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics import java.net.UnknownHostException -import java.util.* @OptIn(ExperimentalCoroutinesApi::class) class HandoutsViewModelTest { @@ -57,7 +59,7 @@ class HandoutsViewModelTest { coEvery { interactor.getHandouts(any()) } throws UnknownHostException() advanceUntilIdle() - assert(viewModel.htmlContent.value == null) + assert(viewModel.uiState.value == HandoutsUIState.Error) } @Test @@ -66,7 +68,7 @@ class HandoutsViewModelTest { coEvery { interactor.getHandouts(any()) } throws Exception() advanceUntilIdle() - assert(viewModel.htmlContent.value == null) + assert(viewModel.uiState.value == HandoutsUIState.Error) } @Test @@ -79,7 +81,7 @@ class HandoutsViewModelTest { coVerify(exactly = 1) { interactor.getHandouts(any()) } coVerify(exactly = 0) { interactor.getAnnouncements(any()) } - assert(viewModel.htmlContent.value != null) + assert(viewModel.uiState.value is HandoutsUIState.HTMLContent) } @Test @@ -97,7 +99,7 @@ class HandoutsViewModelTest { coVerify(exactly = 0) { interactor.getHandouts(any()) } coVerify(exactly = 1) { interactor.getAnnouncements(any()) } - assert(viewModel.htmlContent.value != null) + assert(viewModel.uiState.value is HandoutsUIState.HTMLContent) } @Test @@ -111,7 +113,7 @@ class HandoutsViewModelTest { ) ) viewModel.injectDarkMode( - viewModel.htmlContent.value.toString(), + viewModel.uiState.value.toString(), ULong.MAX_VALUE, ULong.MAX_VALUE ) @@ -119,6 +121,6 @@ class HandoutsViewModelTest { coVerify(exactly = 0) { interactor.getHandouts(any()) } coVerify(exactly = 1) { interactor.getAnnouncements(any()) } - assert(viewModel.htmlContent.value != null) + assert(viewModel.uiState.value is HandoutsUIState.HTMLContent) } } 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 562bca77b..812962c83 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 @@ -193,7 +193,6 @@ class CourseVideoViewModelTest { @Before fun setUp() { - every { resourceManager.getString(R.string.course_does_not_include_videos) } returns "" every { resourceManager.getString(R.string.course_can_download_only_with_wifi) } returns cantDownload Dispatchers.setMain(dispatcher) every { config.getApiHostURL() } returns "http://localhost:8000" diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsScreen.kt b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsScreen.kt index 62ec564b6..990e14260 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsScreen.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsScreen.kt @@ -37,10 +37,11 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentManager -import org.koin.androidx.compose.koinViewModel import org.openedx.core.FragmentViewType +import org.openedx.core.NoContentScreenType import org.openedx.core.UIMessage import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.NoContentScreen import org.openedx.core.ui.StaticSearchBar import org.openedx.core.ui.WindowSize import org.openedx.core.ui.WindowType @@ -51,10 +52,10 @@ 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.discussion.R import org.openedx.discussion.domain.model.Topic import org.openedx.discussion.presentation.ui.ThreadItemCategory import org.openedx.discussion.presentation.ui.TopicItem -import org.openedx.discussion.R as discussionR @Composable fun DiscussionTopicsScreen( @@ -157,15 +158,17 @@ private fun DiscussionTopicsUI( contentAlignment = Alignment.TopCenter ) { Column(screenWidth) { - StaticSearchBar( - modifier = Modifier - .height(48.dp) - .then(searchTabWidth) - .padding(horizontal = contentPaddings) - .fillMaxWidth(), - text = stringResource(id = discussionR.string.discussion_search_all_posts), - onClick = onSearchClick - ) + if ((uiState is DiscussionTopicsUIState.Error).not()) { + StaticSearchBar( + modifier = Modifier + .height(48.dp) + .then(searchTabWidth) + .padding(horizontal = contentPaddings) + .fillMaxWidth(), + text = stringResource(id = R.string.discussion_search_all_posts), + onClick = onSearchClick + ) + } Surface( modifier = Modifier.padding(top = 10.dp), color = MaterialTheme.appColors.background, @@ -188,7 +191,7 @@ private fun DiscussionTopicsUI( item { Text( modifier = Modifier, - text = stringResource(id = discussionR.string.discussion_main_categories), + text = stringResource(id = R.string.discussion_main_categories), style = MaterialTheme.appTypography.titleMedium, color = MaterialTheme.appColors.textPrimaryVariant ) @@ -199,8 +202,8 @@ private fun DiscussionTopicsUI( horizontalArrangement = Arrangement.spacedBy(14.dp) ) { ThreadItemCategory( - name = stringResource(id = discussionR.string.discussion_all_posts), - painterResource = painterResource(id = discussionR.drawable.discussion_all_posts), + name = stringResource(id = R.string.discussion_all_posts), + painterResource = painterResource(id = R.drawable.discussion_all_posts), modifier = Modifier .weight(1f) .height(categoriesHeight), @@ -208,12 +211,12 @@ private fun DiscussionTopicsUI( onItemClick( DiscussionTopicsViewModel.ALL_POSTS, "", - context.getString(discussionR.string.discussion_all_posts) + context.getString(R.string.discussion_all_posts) ) }) ThreadItemCategory( - name = stringResource(id = discussionR.string.discussion_posts_following), - painterResource = painterResource(id = discussionR.drawable.discussion_star), + name = stringResource(id = R.string.discussion_posts_following), + painterResource = painterResource(id = R.drawable.discussion_star), modifier = Modifier .weight(1f) .height(categoriesHeight), @@ -221,7 +224,7 @@ private fun DiscussionTopicsUI( onItemClick( DiscussionTopicsViewModel.FOLLOWING_POSTS, "", - context.getString(discussionR.string.discussion_posts_following) + context.getString(R.string.discussion_posts_following) ) }) } @@ -253,6 +256,9 @@ private fun DiscussionTopicsUI( } DiscussionTopicsUIState.Loading -> {} + else -> { + NoContentScreen(noContentScreenType = NoContentScreenType.COURSE_DISCUSSIONS) + } } } } @@ -279,6 +285,23 @@ private fun DiscussionTopicsScreenPreview() { } } +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "NEXUS_5_Light", device = Devices.NEXUS_5, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "NEXUS_5_Dark", device = Devices.NEXUS_5, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ErrorDiscussionTopicsScreenPreview() { + OpenEdXTheme { + DiscussionTopicsUI( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + uiState = DiscussionTopicsUIState.Error, + uiMessage = null, + onItemClick = { _, _, _ -> }, + onSearchClick = {} + ) + } +} + @Preview(name = "NEXUS_9_Light", device = Devices.NEXUS_9, uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(name = "NEXUS_9_Dark", device = Devices.NEXUS_9, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsUIState.kt b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsUIState.kt index c57f55e9b..f1becc420 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsUIState.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsUIState.kt @@ -5,5 +5,6 @@ import org.openedx.discussion.domain.model.Topic sealed class DiscussionTopicsUIState { data class Topics(val data: List) : DiscussionTopicsUIState() - object Loading : DiscussionTopicsUIState() -} \ No newline at end of file + data object Loading : DiscussionTopicsUIState() + data object Error : DiscussionTopicsUIState() +} 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 456eb79c2..516ee50f8 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 @@ -47,12 +47,15 @@ class DiscussionTopicsViewModel( viewModelScope.launch { try { val response = interactor.getCourseTopics(courseId) - _uiState.value = DiscussionTopicsUIState.Topics(response) + if (response.isEmpty().not()) { + _uiState.value = DiscussionTopicsUIState.Topics(response) + } else { + _uiState.value = DiscussionTopicsUIState.Error + } } catch (e: Exception) { + _uiState.value = DiscussionTopicsUIState.Error 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 { courseNotifier.send(CourseLoading(false)) diff --git a/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt b/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt index 29a38a6a9..96e3c49f4 100644 --- a/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt +++ b/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt @@ -23,20 +23,16 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule -import org.openedx.core.BlockType import org.openedx.core.R import org.openedx.core.UIMessage -import org.openedx.core.domain.model.AssignmentProgress -import org.openedx.core.domain.model.Block -import org.openedx.core.domain.model.BlockCounts import org.openedx.core.system.ResourceManager import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier import org.openedx.discussion.domain.interactor.DiscussionInteractor +import org.openedx.discussion.domain.model.Topic import org.openedx.discussion.presentation.DiscussionAnalytics import org.openedx.discussion.presentation.DiscussionRouter import java.net.UnknownHostException -import java.util.Date @OptIn(ExperimentalCoroutinesApi::class) class DiscussionTopicsViewModelTest { @@ -53,79 +49,18 @@ class DiscussionTopicsViewModelTest { private val courseNotifier = mockk() private val noInternet = "Slow or no internet connection" - private val somethingWrong = "Something went wrong" - private val assignmentProgress = AssignmentProgress( - assignmentType = "Homework", - numPointsEarned = 1f, - numPointsPossible = 3f - ) - - private val blocks = listOf( - Block( - id = "id", - blockId = "blockId", - lmsWebUrl = "lmsWebUrl", - legacyWebUrl = "legacyWebUrl", - studentViewUrl = "studentViewUrl", - type = BlockType.CHAPTER, - displayName = "Block", - graded = false, - studentViewData = null, - studentViewMultiDevice = false, - blockCounts = BlockCounts(0), - descendants = listOf("1", "id1"), - descendantsType = BlockType.HTML, - completion = 0.0, - assignmentProgress = assignmentProgress, - due = Date(), - offlineDownload = null, - ), - Block( - id = "id1", - blockId = "blockId", - lmsWebUrl = "lmsWebUrl", - legacyWebUrl = "legacyWebUrl", - studentViewUrl = "studentViewUrl", - type = BlockType.HTML, - displayName = "Block", - graded = false, - studentViewData = null, - studentViewMultiDevice = false, - blockCounts = BlockCounts(0), - descendants = listOf("id2"), - descendantsType = BlockType.HTML, - completion = 0.0, - assignmentProgress = assignmentProgress, - due = Date(), - offlineDownload = null, - ), - Block( - id = "id2", - blockId = "blockId", - lmsWebUrl = "lmsWebUrl", - legacyWebUrl = "legacyWebUrl", - studentViewUrl = "studentViewUrl", - type = BlockType.HTML, - displayName = "Block", - graded = false, - studentViewData = null, - studentViewMultiDevice = false, - blockCounts = BlockCounts(0), - descendants = emptyList(), - descendantsType = BlockType.HTML, - completion = 0.0, - assignmentProgress = assignmentProgress, - due = Date(), - offlineDownload = null, - ) + private val mockTopic = Topic( + id = "", + name = "All Topics", + threadListUrl = "", + children = emptyList() ) @Before fun setUp() { Dispatchers.setMain(dispatcher) every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet - every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong every { courseNotifier.notifier } returns flowOf(CourseLoading(false)) coEvery { courseNotifier.send(any()) } returns Unit } @@ -166,14 +101,15 @@ class DiscussionTopicsViewModelTest { coVerify(exactly = 1) { interactor.getCourseTopics(any()) } - assertEquals(somethingWrong, message.await()?.message) + assert(message.await()?.message.isNullOrEmpty()) + assert(viewModel.uiState.value is DiscussionTopicsUIState.Error) } @Test fun `getCourseTopics success`() = runTest(UnconfinedTestDispatcher()) { val viewModel = DiscussionTopicsViewModel("id", "", interactor, resourceManager, analytics, courseNotifier, router) - coEvery { interactor.getCourseTopics(any()) } returns mockk() + coEvery { interactor.getCourseTopics(any()) } returns listOf(mockTopic, mockTopic) advanceUntilIdle() val message = async { withTimeoutOrNull(5000) { @@ -217,14 +153,15 @@ class DiscussionTopicsViewModelTest { coVerify(exactly = 1) { interactor.getCourseTopics(any()) } - assertEquals(somethingWrong, message.await()?.message) + assert(message.await()?.message.isNullOrEmpty()) + assert(viewModel.uiState.value is DiscussionTopicsUIState.Error) } @Test fun `updateCourseTopics success`() = runTest(UnconfinedTestDispatcher()) { val viewModel = DiscussionTopicsViewModel("id", "", interactor, resourceManager, analytics, courseNotifier, router) - coEvery { interactor.getCourseTopics(any()) } returns mockk() + coEvery { interactor.getCourseTopics(any()) } returns listOf(mockTopic, mockTopic) val message = async { withTimeoutOrNull(5000) { viewModel.uiMessage.first() as? UIMessage.SnackBarMessage From a005e851857bfa9a5fa4e980e4c945345ef16787 Mon Sep 17 00:00:00 2001 From: Igor Date: Fri, 1 Nov 2024 16:25:28 +0100 Subject: [PATCH 53/56] fix: bug when unable to see downloaded html content (#396) --- .../openedx/course/presentation/unit/html/HtmlUnitFragment.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt index b5747d4d0..e342f1f06 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt @@ -169,7 +169,7 @@ class HtmlUnitFragment : Fragment() { if (isAdded) viewModel.setWebPageLoaded(requireContext().assets) }, onWebPageLoadError = { - viewModel.onWebPageLoadError() + if (!fromDownloadedContent) viewModel.onWebPageLoadError() }, saveXBlockProgress = { jsonProgress -> viewModel.saveXBlockProgress(jsonProgress) From e61523ec975da2a06b7fac465e1375e761e55121 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk <141041606+PavloNetrebchuk@users.noreply.github.com> Date: Mon, 4 Nov 2024 09:57:14 +0200 Subject: [PATCH 54/56] feat: foundation lib and analytic plugin implementation (#395) --- Documentation/ConfigurationManagement.md | 2 - app/build.gradle | 41 ++---- app/proguard-rules.pro | 2 - .../java/org/openedx/app/AnalyticsManager.kt | 46 ++----- .../main/java/org/openedx/app/AppActivity.kt | 6 +- .../main/java/org/openedx/app/AppViewModel.kt | 10 +- .../java/org/openedx/app/MainViewModel.kt | 2 +- .../main/java/org/openedx/app/OpenEdXApp.kt | 10 ++ .../java/org/openedx/app/PluginManager.kt | 12 ++ .../org/openedx/app/analytics/Analytics.kt | 7 - .../app/analytics/FirebaseAnalytics.kt | 36 ----- .../app/analytics/FullstoryAnalytics.kt | 41 ------ .../openedx/app/analytics/SegmentAnalytics.kt | 56 -------- .../app/data/storage/PreferencesManager.kt | 2 +- .../main/java/org/openedx/app/di/AppModule.kt | 20 ++- .../java/org/openedx/app/di/ScreenModule.kt | 2 +- .../test/java/org/openedx/AppViewModelTest.kt | 4 +- auth/build.gradle | 19 +-- .../auth/presentation/AgreementProvider.kt | 2 +- .../logistration/LogistrationViewModel.kt | 4 +- .../restore/RestorePasswordFragment.kt | 10 +- .../restore/RestorePasswordViewModel.kt | 12 +- .../presentation/signin/SignInFragment.kt | 2 +- .../presentation/signin/SignInViewModel.kt | 10 +- .../presentation/signin/compose/SignInView.kt | 8 +- .../presentation/signup/SignUpFragment.kt | 2 +- .../presentation/signup/SignUpViewModel.kt | 8 +- .../presentation/signup/compose/SignUpView.kt | 11 +- .../presentation/sso/FacebookAuthHelper.kt | 2 +- .../presentation/sso/MicrosoftAuthHelper.kt | 2 +- .../openedx/auth/presentation/ui/AuthUI.kt | 7 +- .../restore/RestorePasswordViewModelTest.kt | 4 +- .../signin/SignInViewModelTest.kt | 5 +- .../signup/SignUpViewModelTest.kt | 4 +- build.gradle | 45 ++---- core/build.gradle | 75 ++-------- .../java/org/openedx/core/BaseViewModel.kt | 6 - .../org/openedx/core/SingleEventLiveData.kt | 39 ------ .../main/java/org/openedx/core/UIMessage.kt | 12 -- .../openedx/core/config/AnalyticsSource.kt | 11 -- .../java/org/openedx/core/config/Config.kt | 10 -- .../org/openedx/core/config/FirebaseConfig.kt | 9 +- .../openedx/core/config/FullstoryConfig.kt | 11 -- .../org/openedx/core/config/SegmentConfig.kt | 11 -- .../org/openedx/core/extension/AssetExt.kt | 15 -- .../org/openedx/core/extension/BundleExt.kt | 34 ----- .../openedx/core/extension/ContinuationExt.kt | 12 -- .../openedx/core/extension/FlowExtension.kt | 18 --- .../org/openedx/core/extension/FragmentExt.kt | 28 ---- .../org/openedx/core/extension/GsonExt.kt | 9 -- .../core/extension/ImageUploaderExtension.kt | 17 --- .../java/org/openedx/core/extension/IntExt.kt | 5 - .../org/openedx/core/extension/ListExt.kt | 30 ---- .../org/openedx/core/extension/LongExt.kt | 18 --- .../java/org/openedx/core/extension/MapExt.kt | 13 -- .../org/openedx/core/extension/StringExt.kt | 44 ------ .../openedx/core/extension/ThrowableExt.kt | 8 -- .../java/org/openedx/core/extension/UriExt.kt | 15 -- .../org/openedx/core/extension/ViewExt.kt | 59 -------- .../org/openedx/core/module/DownloadWorker.kt | 5 +- .../openedx/core/module/TranscriptManager.kt | 5 +- .../module/download/BaseDownloadViewModel.kt | 2 +- .../core/module/download/DownloadHelper.kt | 3 +- .../dialog/alert/ActionDialogFragment.kt | 2 +- .../appreview/BaseAppReviewDialogFragment.kt | 2 +- .../SelectBottomDialogFragment.kt | 4 +- .../SelectDialogViewModel.kt | 4 +- .../presentation/global/WindowSizeHolder.kt | 4 +- .../global/webview/WebContentFragment.kt | 2 +- .../calendarsync/CalendarSyncDialog.kt | 4 +- .../settings/video/VideoQualityFragment.kt | 14 +- .../settings/video/VideoQualityViewModel.kt | 2 +- .../core/system/PreviewFragmentManager.kt | 5 - .../openedx/core/system/ResourceManager.kt | 43 ------ .../java/org/openedx/core/ui/ComposeCommon.kt | 58 ++++---- .../org/openedx/core/ui/WebContentScreen.kt | 8 +- .../java/org/openedx/core/ui/WindowSize.kt | 55 -------- .../java/org/openedx/core/utils/FileUtil.kt | 128 +++--------------- .../java/org/openedx/core/utils/TimeUtils.kt | 2 +- .../java/org/openedx/core/utils/UrlUtils.kt | 67 --------- course/build.gradle | 11 +- .../openedx/course/DatesShiftedSnackBar.kt | 2 +- .../course/data/storage/CourseConverter.kt | 2 +- .../presentation/ChapterEndFragmentDialog.kt | 2 +- .../container/CollapsingLayout.kt | 2 +- .../container/CourseContainerFragment.kt | 9 +- .../container/CourseContainerViewModel.kt | 14 +- .../presentation/container/HeaderContent.kt | 2 +- .../NoAccessCourseContainerFragment.kt | 27 +++- .../presentation/dates/CourseDatesScreen.kt | 77 +---------- .../dates/CourseDatesViewModel.kt | 8 +- .../download/DownloadConfirmDialogFragment.kt | 6 +- .../download/DownloadErrorDialogFragment.kt | 4 +- .../DownloadStorageErrorDialogFragment.kt | 6 +- .../presentation/download/DownloadView.kt | 2 +- .../presentation/handouts/HandoutsScreen.kt | 6 +- .../handouts/HandoutsViewModel.kt | 2 +- .../handouts/HandoutsWebViewFragment.kt | 6 +- .../offline/CourseOfflineScreen.kt | 8 +- .../offline/CourseOfflineViewModel.kt | 4 +- .../outline/CourseOutlineScreen.kt | 10 +- .../outline/CourseOutlineViewModel.kt | 8 +- .../section/CourseSectionFragment.kt | 22 +-- .../section/CourseSectionViewModel.kt | 10 +- .../course/presentation/ui/CourseUI.kt | 8 +- .../course/presentation/ui/CourseVideosUI.kt | 16 ++- .../unit/NotAvailableUnitFragment.kt | 8 +- .../container/CourseUnitContainerFragment.kt | 2 +- .../container/CourseUnitContainerViewModel.kt | 6 +- .../unit/html/HtmlUnitFragment.kt | 10 +- .../unit/html/HtmlUnitViewModel.kt | 7 +- .../unit/video/BaseVideoViewModel.kt | 2 +- .../unit/video/VideoFullScreenFragment.kt | 2 +- .../unit/video/VideoUnitFragment.kt | 10 +- .../video/YoutubeVideoFullScreenFragment.kt | 2 +- .../unit/video/YoutubeVideoUnitFragment.kt | 8 +- .../videos/CourseVideoViewModel.kt | 6 +- .../download/DownloadQueueFragment.kt | 8 +- .../openedx/course/utils}/ImageProcessor.kt | 2 +- .../container/CourseContainerViewModelTest.kt | 4 +- .../dates/CourseDatesViewModelTest.kt | 4 +- .../outline/CourseOutlineViewModelTest.kt | 6 +- .../section/CourseSectionViewModelTest.kt | 4 +- .../videos/CourseVideoViewModelTest.kt | 6 +- dashboard/build.gradle | 11 +- .../presentation/AllEnrolledCoursesView.kt | 8 +- .../AllEnrolledCoursesViewModel.kt | 8 +- .../presentation/DashboardGalleryView.kt | 6 +- .../presentation/DashboardGalleryViewModel.kt | 12 +- .../data/repository/DashboardRepository.kt | 2 +- .../presentation/DashboardListFragment.kt | 12 +- .../presentation/DashboardListViewModel.kt | 12 +- .../learn/presentation/LearnFragment.kt | 4 +- .../learn/presentation/LearnViewModel.kt | 2 +- .../DashboardListViewModelTest.kt | 4 +- default_config/dev/config.yaml | 11 +- default_config/prod/config.yaml | 9 -- default_config/stage/config.yaml | 9 -- discovery/build.gradle | 15 +- .../presentation/NativeDiscoveryFragment.kt | 10 +- .../presentation/NativeDiscoveryViewModel.kt | 10 +- .../presentation/WebViewDiscoveryFragment.kt | 8 +- .../presentation/WebViewDiscoveryViewModel.kt | 4 +- .../presentation/catalog/CatalogWebView.kt | 2 +- .../catalog/DefaultWebViewClient.kt | 2 +- .../presentation/catalog/WebViewLink.kt | 2 +- .../detail/CourseDetailsFragment.kt | 14 +- .../detail/CourseDetailsViewModel.kt | 10 +- .../presentation/info/CourseInfoFragment.kt | 10 +- .../presentation/info/CourseInfoViewModel.kt | 8 +- .../presentation/program/ProgramFragment.kt | 12 +- .../presentation/program/ProgramUIState.kt | 2 +- .../presentation/program/ProgramViewModel.kt | 8 +- .../search/CourseSearchFragment.kt | 10 +- .../search/CourseSearchViewModel.kt | 10 +- .../discovery/presentation/ui/DiscoveryUI.kt | 10 +- .../NativeDiscoveryViewModelTest.kt | 4 +- .../detail/CourseDetailsViewModelTest.kt | 4 +- .../search/CourseSearchViewModelTest.kt | 4 +- discussion/build.gradle | 9 +- .../data/repository/DiscussionRepository.kt | 2 +- .../comments/DiscussionCommentsFragment.kt | 55 ++++++-- .../comments/DiscussionCommentsViewModel.kt | 12 +- .../responses/DiscussionResponsesFragment.kt | 54 ++++++-- .../responses/DiscussionResponsesViewModel.kt | 12 +- .../search/DiscussionSearchThreadFragment.kt | 51 +++++-- .../search/DiscussionSearchThreadViewModel.kt | 10 +- .../threads/DiscussionAddThreadFragment.kt | 10 +- .../threads/DiscussionAddThreadViewModel.kt | 12 +- .../threads/DiscussionThreadsFragment.kt | 58 +++++++- .../threads/DiscussionThreadsViewModel.kt | 10 +- .../topics/DiscussionTopicsScreen.kt | 8 +- .../topics/DiscussionTopicsViewModel.kt | 8 +- .../DiscussionCommentsViewModelTest.kt | 39 ++++-- .../DiscussionResponsesViewModelTest.kt | 31 +++-- .../DiscussionSearchThreadViewModelTest.kt | 27 ++-- .../DiscussionAddThreadViewModelTest.kt | 32 +++-- .../threads/DiscussionThreadsViewModelTest.kt | 4 +- .../topics/DiscussionTopicsViewModelTest.kt | 4 +- gradle/wrapper/gradle-wrapper.properties | 2 +- profile/build.gradle | 9 +- .../AnothersProfileFragment.kt | 10 +- .../AnothersProfileViewModel.kt | 8 +- .../presentation/calendar/CalendarFragment.kt | 4 +- .../calendar/CalendarSetUpView.kt | 6 +- .../calendar/CalendarSettingsView.kt | 6 +- .../calendar/CalendarViewModel.kt | 2 +- .../calendar/CoursesToSyncFragment.kt | 8 +- .../calendar/CoursesToSyncViewModel.kt | 8 +- .../DisableCalendarSyncDialogFragment.kt | 2 +- .../DisableCalendarSyncDialogViewModel.kt | 2 +- .../calendar/NewCalendarDialogFragment.kt | 4 +- .../calendar/NewCalendarDialogViewModel.kt | 4 +- .../delete/DeleteProfileFragment.kt | 10 +- .../delete/DeleteProfileViewModel.kt | 8 +- .../presentation/edit/EditProfileFragment.kt | 22 ++- .../presentation/edit/EditProfileViewModel.kt | 8 +- .../manageaccount/ManageAccountFragment.kt | 2 +- .../manageaccount/ManageAccountViewModel.kt | 8 +- .../compose/ManageAccountView.kt | 8 +- .../presentation/profile/ProfileFragment.kt | 2 +- .../presentation/profile/ProfileViewModel.kt | 8 +- .../profile/compose/ProfileView.kt | 8 +- .../presentation/settings/SettingsFragment.kt | 2 +- .../presentation/settings/SettingsScreenUI.kt | 6 +- .../settings/SettingsViewModel.kt | 8 +- .../profile/presentation/ui/SettingsUI.kt | 2 +- .../video/VideoSettingsFragment.kt | 8 +- .../video/VideoSettingsViewModel.kt | 2 +- .../edit/EditProfileViewModelTest.kt | 4 +- .../profile/AnothersProfileViewModelTest.kt | 4 +- .../profile/ProfileViewModelTest.kt | 4 +- settings.gradle | 11 +- whatsnew/build.gradle | 10 +- .../presentation/whatsnew/WhatsNewFragment.kt | 9 +- .../whatsnew/WhatsNewViewModel.kt | 2 +- 216 files changed, 917 insertions(+), 1786 deletions(-) create mode 100644 app/src/main/java/org/openedx/app/PluginManager.kt delete mode 100644 app/src/main/java/org/openedx/app/analytics/Analytics.kt delete mode 100644 app/src/main/java/org/openedx/app/analytics/FirebaseAnalytics.kt delete mode 100644 app/src/main/java/org/openedx/app/analytics/FullstoryAnalytics.kt delete mode 100644 app/src/main/java/org/openedx/app/analytics/SegmentAnalytics.kt delete mode 100644 core/src/main/java/org/openedx/core/BaseViewModel.kt delete mode 100644 core/src/main/java/org/openedx/core/SingleEventLiveData.kt delete mode 100644 core/src/main/java/org/openedx/core/UIMessage.kt delete mode 100644 core/src/main/java/org/openedx/core/config/AnalyticsSource.kt delete mode 100644 core/src/main/java/org/openedx/core/config/FullstoryConfig.kt delete mode 100644 core/src/main/java/org/openedx/core/config/SegmentConfig.kt delete mode 100644 core/src/main/java/org/openedx/core/extension/AssetExt.kt delete mode 100644 core/src/main/java/org/openedx/core/extension/BundleExt.kt delete mode 100644 core/src/main/java/org/openedx/core/extension/ContinuationExt.kt delete mode 100644 core/src/main/java/org/openedx/core/extension/FlowExtension.kt delete mode 100644 core/src/main/java/org/openedx/core/extension/FragmentExt.kt delete mode 100644 core/src/main/java/org/openedx/core/extension/GsonExt.kt delete mode 100644 core/src/main/java/org/openedx/core/extension/ImageUploaderExtension.kt delete mode 100644 core/src/main/java/org/openedx/core/extension/IntExt.kt delete mode 100644 core/src/main/java/org/openedx/core/extension/LongExt.kt delete mode 100644 core/src/main/java/org/openedx/core/extension/MapExt.kt delete mode 100644 core/src/main/java/org/openedx/core/extension/ThrowableExt.kt delete mode 100644 core/src/main/java/org/openedx/core/extension/UriExt.kt delete mode 100644 core/src/main/java/org/openedx/core/system/PreviewFragmentManager.kt delete mode 100644 core/src/main/java/org/openedx/core/system/ResourceManager.kt delete mode 100644 core/src/main/java/org/openedx/core/ui/WindowSize.kt delete mode 100644 core/src/main/java/org/openedx/core/utils/UrlUtils.kt rename {core/src/main/java/org/openedx/core => course/src/main/java/org/openedx/course/utils}/ImageProcessor.kt (98%) diff --git a/Documentation/ConfigurationManagement.md b/Documentation/ConfigurationManagement.md index 0e8456ed0..548e84759 100644 --- a/Documentation/ConfigurationManagement.md +++ b/Documentation/ConfigurationManagement.md @@ -49,7 +49,6 @@ TOKEN_TYPE: "JWT" FIREBASE: ENABLED: false - ANALYTICS_SOURCE: '' CLOUD_MESSAGING_ENABLED: false PROJECT_NUMBER: '' PROJECT_ID: '' @@ -82,7 +81,6 @@ android: - **Facebook:** Sign in and Sign up via Facebook - **Branch:** Deeplinks - **Braze:** Cloud Messaging -- **SegmentIO:** Analytics ## Available Feature Flags - **PRE_LOGIN_EXPERIENCE_ENABLED:** Enables the pre login courses discovery experience. diff --git a/app/build.gradle b/app/build.gradle index 3c017ee0b..baabb18d2 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -3,17 +3,12 @@ def appId = config.getOrDefault("APPLICATION_ID", "org.openedx.app") def themeDirectory = config.getOrDefault("THEME_DIRECTORY", "openedx") def firebaseConfig = config.get('FIREBASE') def firebaseEnabled = firebaseConfig?.getOrDefault('ENABLED', false) -def fullstoryConfig = config.get("FULLSTORY") -def fullstoryEnabled = fullstoryConfig?.getOrDefault('ENABLED', false) apply plugin: 'com.android.application' apply plugin: 'org.jetbrains.kotlin.android' apply plugin: 'kotlin-parcelize' -apply plugin: 'kotlin-kapt' - -if (fullstoryEnabled) { - apply plugin: 'fullstory' -} +apply plugin: 'com.google.devtools.ksp' +apply plugin: 'org.jetbrains.kotlin.plugin.compose' if (firebaseEnabled) { apply plugin: 'com.google.gms.google-services' @@ -32,18 +27,6 @@ if (firebaseEnabled) { preBuild.dependsOn(removeGoogleServicesJson) } -if (fullstoryEnabled) { - def fullstoryOrgId = fullstoryConfig?.get("ORG_ID") - - fullstory { - org fullstoryOrgId - composeEnabled true - composeSelectorVersion 4 - enabledVariants 'debug|release' - logcatLevel 'error' - } -} - android { compileSdk 34 @@ -117,9 +100,6 @@ android { compose true buildConfig true } - composeOptions { - kotlinCompilerExtensionVersion = "$compose_compiler_version" - } bundle { language { enableSplit = false @@ -146,29 +126,24 @@ dependencies { implementation project(path: ':discussion') implementation project(path: ':whatsnew') - kapt "androidx.room:room-compiler:$room_version" + ksp "androidx.room:room-compiler:$room_version" implementation 'androidx.core:core-splashscreen:1.0.1' api platform("com.google.firebase:firebase-bom:$firebase_version") api "com.google.firebase:firebase-messaging" - // Segment Library - implementation "com.segment.analytics.kotlin:android:1.14.2" - // Segment's Firebase integration - implementation 'com.segment.analytics.kotlin.destinations:firebase:1.5.2' // Braze SDK Integration - implementation "com.braze:braze-segment-kotlin:1.4.2" implementation "com.braze:android-sdk-ui:30.2.0" - androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' - androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version" - debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version" + // Plugins + implementation("com.github.openedx:openedx-app-firebase-analytics-android:1.0.0") + + androidTestImplementation 'androidx.test.ext:junit:1.2.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' testImplementation "junit:junit:$junit_version" testImplementation "io.mockk:mockk:$mockk_version" testImplementation "io.mockk:mockk-android:$mockk_version" - testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version" testImplementation "androidx.arch.core:core-testing:$android_arch_version" } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 373a73186..825176c61 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -46,8 +46,6 @@ -dontwarn com.google.crypto.tink.subtle.Ed25519Sign -dontwarn com.google.crypto.tink.subtle.Ed25519Verify -dontwarn com.google.crypto.tink.subtle.X25519 --dontwarn com.segment.analytics.kotlin.core.platform.plugins.logger.LogFilterKind --dontwarn com.segment.analytics.kotlin.core.platform.plugins.logger.LogTargetKt -dontwarn edu.umd.cs.findbugs.annotations.NonNull -dontwarn edu.umd.cs.findbugs.annotations.Nullable -dontwarn edu.umd.cs.findbugs.annotations.SuppressFBWarnings diff --git a/app/src/main/java/org/openedx/app/AnalyticsManager.kt b/app/src/main/java/org/openedx/app/AnalyticsManager.kt index 9d8169863..aa78f8d04 100644 --- a/app/src/main/java/org/openedx/app/AnalyticsManager.kt +++ b/app/src/main/java/org/openedx/app/AnalyticsManager.kt @@ -1,70 +1,46 @@ package org.openedx.app -import android.content.Context -import org.openedx.app.analytics.Analytics -import org.openedx.app.analytics.FirebaseAnalytics -import org.openedx.app.analytics.FullstoryAnalytics -import org.openedx.app.analytics.SegmentAnalytics import org.openedx.auth.presentation.AuthAnalytics -import org.openedx.core.config.Config import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.presentation.dialog.appreview.AppReviewAnalytics import org.openedx.course.presentation.CourseAnalytics import org.openedx.dashboard.presentation.DashboardAnalytics import org.openedx.discovery.presentation.DiscoveryAnalytics import org.openedx.discussion.presentation.DiscussionAnalytics +import org.openedx.foundation.interfaces.Analytics import org.openedx.profile.presentation.ProfileAnalytics import org.openedx.whatsnew.presentation.WhatsNewAnalytics -class AnalyticsManager( - context: Context, - config: Config, -) : AppAnalytics, AppReviewAnalytics, AuthAnalytics, CoreAnalytics, CourseAnalytics, - DashboardAnalytics, DiscoveryAnalytics, DiscussionAnalytics, ProfileAnalytics, - WhatsNewAnalytics { +class AnalyticsManager : AppAnalytics, AppReviewAnalytics, AuthAnalytics, CoreAnalytics, + CourseAnalytics, DashboardAnalytics, DiscoveryAnalytics, DiscussionAnalytics, + ProfileAnalytics, WhatsNewAnalytics { - private val services: ArrayList = arrayListOf() + private val analytics: MutableList = mutableListOf() - init { - // Initialise all the analytics libraries here - if (config.getFirebaseConfig().enabled) { - addAnalyticsTracker(FirebaseAnalytics(context = context)) - } - - val segmentConfig = config.getSegmentConfig() - if (segmentConfig.enabled && segmentConfig.segmentWriteKey.isNotBlank()) { - addAnalyticsTracker(SegmentAnalytics(context = context, config = config)) - } - - if (config.getFullstoryConfig().isEnabled) { - addAnalyticsTracker(FullstoryAnalytics()) - } - } - - private fun addAnalyticsTracker(analytic: Analytics) { - services.add(analytic) + fun addAnalyticsTracker(analytic: Analytics) { + analytics.add(analytic) } private fun logEvent(event: Event, params: Map = mapOf()) { - services.forEach { analytics -> + analytics.forEach { analytics -> analytics.logEvent(event.eventName, params) } } override fun logScreenEvent(screenName: String, params: Map) { - services.forEach { analytics -> + analytics.forEach { analytics -> analytics.logScreenEvent(screenName, params) } } override fun logEvent(event: String, params: Map) { - services.forEach { analytics -> + analytics.forEach { analytics -> analytics.logEvent(event, params) } } private fun setUserId(userId: Long) { - services.forEach { analytics -> + analytics.forEach { analytics -> analytics.logUserId(userId) } } diff --git a/app/src/main/java/org/openedx/app/AppActivity.kt b/app/src/main/java/org/openedx/app/AppActivity.kt index b75825048..b736e937c 100644 --- a/app/src/main/java/org/openedx/app/AppActivity.kt +++ b/app/src/main/java/org/openedx/app/AppActivity.kt @@ -25,14 +25,14 @@ import org.openedx.app.deeplink.DeepLink import org.openedx.auth.presentation.logistration.LogistrationFragment import org.openedx.auth.presentation.signin.SignInFragment import org.openedx.core.data.storage.CorePreferences -import org.openedx.core.extension.requestApplyInsetsWhenAttached import org.openedx.core.presentation.global.InsetHolder import org.openedx.core.presentation.global.WindowSizeHolder -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType import org.openedx.core.utils.Logger import org.openedx.core.worker.CalendarSyncScheduler import org.openedx.course.presentation.download.DownloadDialogManager +import org.openedx.foundation.extension.requestApplyInsetsWhenAttached +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType import org.openedx.profile.presentation.ProfileRouter import org.openedx.whatsnew.WhatsNewManager import org.openedx.whatsnew.presentation.whatsnew.WhatsNewFragment diff --git a/app/src/main/java/org/openedx/app/AppViewModel.kt b/app/src/main/java/org/openedx/app/AppViewModel.kt index 69fc3a9d9..e191a49c6 100644 --- a/app/src/main/java/org/openedx/app/AppViewModel.kt +++ b/app/src/main/java/org/openedx/app/AppViewModel.kt @@ -18,8 +18,6 @@ import org.openedx.app.deeplink.DeepLink import org.openedx.app.deeplink.DeepLinkRouter import org.openedx.app.system.push.RefreshFirebaseTokenWorker import org.openedx.app.system.push.SyncFirebaseTokenWorker -import org.openedx.core.BaseViewModel -import org.openedx.core.SingleEventLiveData import org.openedx.core.config.Config import org.openedx.core.data.model.User import org.openedx.core.data.storage.CorePreferences @@ -28,8 +26,10 @@ import org.openedx.core.system.notifier.DownloadNotifier import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.core.system.notifier.app.LogoutEvent import org.openedx.core.system.notifier.app.SignInEvent -import org.openedx.core.utils.FileUtil - +import org.openedx.core.utils.Directories +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.SingleEventLiveData +import org.openedx.foundation.utils.FileUtil @SuppressLint("StaticFieldLeak") class AppViewModel( @@ -104,7 +104,7 @@ class AppViewModel( } private fun resetAppDirectory() { - fileUtil.deleteOldAppDirectory() + fileUtil.deleteOldAppDirectory(Directories.VIDEOS.name) preferencesManager.canResetAppDirectory = false } diff --git a/app/src/main/java/org/openedx/app/MainViewModel.kt b/app/src/main/java/org/openedx/app/MainViewModel.kt index 5cef29361..ff24f4ff8 100644 --- a/app/src/main/java/org/openedx/app/MainViewModel.kt +++ b/app/src/main/java/org/openedx/app/MainViewModel.kt @@ -10,11 +10,11 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import org.openedx.core.BaseViewModel import org.openedx.core.config.Config import org.openedx.core.system.notifier.DiscoveryNotifier import org.openedx.core.system.notifier.NavigationToDiscovery import org.openedx.discovery.presentation.DiscoveryNavigator +import org.openedx.foundation.presentation.BaseViewModel class MainViewModel( private val config: Config, diff --git a/app/src/main/java/org/openedx/app/OpenEdXApp.kt b/app/src/main/java/org/openedx/app/OpenEdXApp.kt index ccf20d5b2..6524cde5d 100644 --- a/app/src/main/java/org/openedx/app/OpenEdXApp.kt +++ b/app/src/main/java/org/openedx/app/OpenEdXApp.kt @@ -14,10 +14,12 @@ import org.openedx.app.di.appModule import org.openedx.app.di.networkingModule import org.openedx.app.di.screenModule import org.openedx.core.config.Config +import org.openedx.firebase.OEXFirebaseAnalytics class OpenEdXApp : Application() { private val config by inject() + private val pluginManager by inject() override fun onCreate() { super.onCreate() @@ -58,5 +60,13 @@ class OpenEdXApp : Application() { BrazeDeeplinkHandler.setBrazeDeeplinkHandler(BranchBrazeDeeplinkHandler()) } } + + initPlugins() + } + + private fun initPlugins() { + if (config.getFirebaseConfig().enabled) { + pluginManager.addPlugin(OEXFirebaseAnalytics(context = this)) + } } } diff --git a/app/src/main/java/org/openedx/app/PluginManager.kt b/app/src/main/java/org/openedx/app/PluginManager.kt new file mode 100644 index 000000000..651dbc8cb --- /dev/null +++ b/app/src/main/java/org/openedx/app/PluginManager.kt @@ -0,0 +1,12 @@ +package org.openedx.app + +import org.openedx.foundation.interfaces.Analytics + +class PluginManager( + private val analyticsManager: AnalyticsManager +) { + + fun addPlugin(analytics: Analytics) { + analyticsManager.addAnalyticsTracker(analytics) + } +} diff --git a/app/src/main/java/org/openedx/app/analytics/Analytics.kt b/app/src/main/java/org/openedx/app/analytics/Analytics.kt deleted file mode 100644 index 01ac01860..000000000 --- a/app/src/main/java/org/openedx/app/analytics/Analytics.kt +++ /dev/null @@ -1,7 +0,0 @@ -package org.openedx.app.analytics - -interface Analytics { - fun logScreenEvent(screenName: String, params: Map) - fun logEvent(eventName: String, params: Map) - fun logUserId(userId: Long) -} diff --git a/app/src/main/java/org/openedx/app/analytics/FirebaseAnalytics.kt b/app/src/main/java/org/openedx/app/analytics/FirebaseAnalytics.kt deleted file mode 100644 index 17d3b3b62..000000000 --- a/app/src/main/java/org/openedx/app/analytics/FirebaseAnalytics.kt +++ /dev/null @@ -1,36 +0,0 @@ -package org.openedx.app.analytics - -import android.content.Context -import com.google.firebase.analytics.FirebaseAnalytics -import org.openedx.core.extension.toBundle -import org.openedx.core.utils.Logger - -class FirebaseAnalytics(context: Context) : Analytics { - - private val logger = Logger(TAG) - private var tracker: FirebaseAnalytics - - init { - tracker = FirebaseAnalytics.getInstance(context) - logger.d { "Firebase Analytics Builder Initialised" } - } - - override fun logScreenEvent(screenName: String, params: Map) { - tracker.logEvent(screenName, params.toBundle()) - logger.d { "Firebase Analytics log Screen Event: $screenName + $params" } - } - - override fun logEvent(eventName: String, params: Map) { - tracker.logEvent(eventName, params.toBundle()) - logger.d { "Firebase Analytics log Event $eventName: $params" } - } - - override fun logUserId(userId: Long) { - tracker.setUserId(userId.toString()) - logger.d { "Firebase Analytics User Id log Event" } - } - - private companion object { - const val TAG = "FirebaseAnalytics" - } -} diff --git a/app/src/main/java/org/openedx/app/analytics/FullstoryAnalytics.kt b/app/src/main/java/org/openedx/app/analytics/FullstoryAnalytics.kt deleted file mode 100644 index bb3473844..000000000 --- a/app/src/main/java/org/openedx/app/analytics/FullstoryAnalytics.kt +++ /dev/null @@ -1,41 +0,0 @@ -package org.openedx.app.analytics - -import com.fullstory.FS -import com.fullstory.FSSessionData -import org.openedx.core.utils.Logger - -class FullstoryAnalytics : Analytics { - - private val logger = Logger(TAG) - - init { - FS.setReadyListener { sessionData: FSSessionData -> - val sessionUrl = sessionData.currentSessionURL - logger.d { "FullStory Session URL is: $sessionUrl" } - } - } - - override fun logScreenEvent(screenName: String, params: Map) { - logger.d { "Page : $screenName $params" } - FS.page(screenName, params).start() - } - - override fun logEvent(eventName: String, params: Map) { - logger.d { "Event: $eventName $params" } - FS.event(eventName, params) - } - - override fun logUserId(userId: Long) { - logger.d { "Identify: $userId" } - FS.identify( - userId.toString(), mapOf( - DISPLAY_NAME to userId - ) - ) - } - - private companion object { - const val TAG = "FullstoryAnalytics" - private const val DISPLAY_NAME = "displayName" - } -} diff --git a/app/src/main/java/org/openedx/app/analytics/SegmentAnalytics.kt b/app/src/main/java/org/openedx/app/analytics/SegmentAnalytics.kt deleted file mode 100644 index 3a9532a71..000000000 --- a/app/src/main/java/org/openedx/app/analytics/SegmentAnalytics.kt +++ /dev/null @@ -1,56 +0,0 @@ -package org.openedx.app.analytics - -import android.content.Context -import com.segment.analytics.kotlin.destinations.braze.BrazeDestination -import com.segment.analytics.kotlin.destinations.firebase.FirebaseDestination -import org.openedx.app.BuildConfig -import org.openedx.core.config.Config -import org.openedx.core.utils.Logger -import com.segment.analytics.kotlin.android.Analytics as SegmentAnalyticsBuilder -import com.segment.analytics.kotlin.core.Analytics as SegmentTracker - -class SegmentAnalytics(context: Context, config: Config) : Analytics { - - private val logger = Logger(TAG) - private var tracker: SegmentTracker - - init { - // Create an analytics client with the given application context and Segment write key. - tracker = SegmentAnalyticsBuilder(config.getSegmentConfig().segmentWriteKey, context) { - // Automatically track Lifecycle events - trackApplicationLifecycleEvents = true - flushAt = 20 - flushInterval = 30 - } - if (config.getFirebaseConfig().isSegmentAnalyticsSource()) { - tracker.add(plugin = FirebaseDestination(context = context)) - } - - if (config.getFirebaseConfig() - .isSegmentAnalyticsSource() && config.getBrazeConfig().isEnabled - ) { - tracker.add(plugin = BrazeDestination(context)) - } - SegmentTracker.debugLogsEnabled = BuildConfig.DEBUG - logger.d { "Segment Analytics Builder Initialised" } - } - - override fun logScreenEvent(screenName: String, params: Map) { - logger.d { "Segment Analytics log Screen Event: $screenName + $params" } - tracker.screen(screenName, params) - } - - override fun logEvent(eventName: String, params: Map) { - logger.d { "Segment Analytics log Event $eventName: $params" } - tracker.track(eventName, params) - } - - override fun logUserId(userId: Long) { - logger.d { "Segment Analytics User Id log Event: $userId" } - tracker.identify(userId.toString()) - } - - private companion object { - const val TAG = "SegmentAnalytics" - } -} diff --git a/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt b/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt index 1a4974a19..ab18b7e23 100644 --- a/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt +++ b/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt @@ -10,9 +10,9 @@ import org.openedx.core.data.storage.InAppReviewPreferences import org.openedx.core.domain.model.AppConfig import org.openedx.core.domain.model.VideoQuality import org.openedx.core.domain.model.VideoSettings -import org.openedx.core.extension.replaceSpace import org.openedx.core.system.CalendarManager import org.openedx.course.data.storage.CoursePreferences +import org.openedx.foundation.extension.replaceSpace import org.openedx.profile.data.model.Account import org.openedx.profile.data.storage.ProfilePreferences import org.openedx.whatsnew.data.storage.WhatsNewPreferences 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 7cd9d7093..79d70208f 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -14,6 +14,7 @@ import org.openedx.app.AnalyticsManager import org.openedx.app.AppAnalytics import org.openedx.app.AppRouter import org.openedx.app.BuildConfig +import org.openedx.app.PluginManager import org.openedx.app.data.storage.PreferencesManager import org.openedx.app.deeplink.DeepLinkRouter import org.openedx.app.room.AppDatabase @@ -27,7 +28,7 @@ import org.openedx.auth.presentation.sso.GoogleAuthHelper import org.openedx.auth.presentation.sso.MicrosoftAuthHelper import org.openedx.auth.presentation.sso.OAuthHelper import org.openedx.core.CalendarRouter -import org.openedx.core.ImageProcessor +import org.openedx.core.R import org.openedx.core.config.Config import org.openedx.core.data.model.CourseEnrollments import org.openedx.core.data.storage.CalendarPreferences @@ -45,7 +46,6 @@ import org.openedx.core.presentation.global.WhatsNewGlobalManager import org.openedx.core.presentation.global.app_upgrade.AppUpgradeRouter import org.openedx.core.system.AppCookieManager import org.openedx.core.system.CalendarManager -import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.DiscoveryNotifier @@ -53,12 +53,12 @@ import org.openedx.core.system.notifier.DownloadNotifier import org.openedx.core.system.notifier.VideoNotifier import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.core.system.notifier.calendar.CalendarNotifier -import org.openedx.core.utils.FileUtil import org.openedx.core.worker.CalendarSyncScheduler import org.openedx.course.data.storage.CoursePreferences import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.download.DownloadDialogManager +import org.openedx.course.utils.ImageProcessor import org.openedx.course.worker.OfflineProgressSyncScheduler import org.openedx.dashboard.presentation.DashboardAnalytics import org.openedx.dashboard.presentation.DashboardRouter @@ -67,6 +67,8 @@ import org.openedx.discovery.presentation.DiscoveryRouter import org.openedx.discussion.presentation.DiscussionAnalytics import org.openedx.discussion.presentation.DiscussionRouter import org.openedx.discussion.system.notifier.DiscussionNotifier +import org.openedx.foundation.system.ResourceManager +import org.openedx.foundation.utils.FileUtil import org.openedx.profile.data.storage.ProfilePreferences import org.openedx.profile.presentation.ProfileAnalytics import org.openedx.profile.presentation.ProfileRouter @@ -181,11 +183,10 @@ val appModule = module { single { AppData(versionName = BuildConfig.VERSION_NAME) } factory { (activity: AppCompatActivity) -> AppReviewManager(activity, get(), get()) } - single { TranscriptManager(get()) } + single { TranscriptManager(get(), get()) } single { WhatsNewManager(get(), get(), get(), get()) } single { get() } - single { AnalyticsManager(get(), get()) } single { get() } single { get() } single { get() } @@ -203,10 +204,17 @@ val appModule = module { factory { MicrosoftAuthHelper() } factory { OAuthHelper(get(), get(), get()) } - factory { FileUtil(get()) } + factory { FileUtil(get(), get().getString(R.string.app_name)) } single { DownloadHelper(get(), get()) } factory { OfflineProgressSyncScheduler(get()) } single { CalendarSyncScheduler(get()) } + + single { AnalyticsManager() } + single { + PluginManager( + analyticsManager = 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 15ef16498..86d9b3dfe 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -16,7 +16,6 @@ import org.openedx.core.domain.interactor.CalendarInteractor import org.openedx.core.presentation.dialog.selectorbottomsheet.SelectDialogViewModel import org.openedx.core.presentation.settings.video.VideoQualityViewModel import org.openedx.core.repository.CalendarRepository -import org.openedx.core.ui.WindowSize import org.openedx.course.data.repository.CourseRepository import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.container.CourseContainerViewModel @@ -55,6 +54,7 @@ import org.openedx.discussion.presentation.search.DiscussionSearchThreadViewMode import org.openedx.discussion.presentation.threads.DiscussionAddThreadViewModel import org.openedx.discussion.presentation.threads.DiscussionThreadsViewModel import org.openedx.discussion.presentation.topics.DiscussionTopicsViewModel +import org.openedx.foundation.presentation.WindowSize import org.openedx.learn.presentation.LearnViewModel import org.openedx.profile.data.repository.ProfileRepository import org.openedx.profile.domain.interactor.ProfileInteractor diff --git a/app/src/test/java/org/openedx/AppViewModelTest.kt b/app/src/test/java/org/openedx/AppViewModelTest.kt index d2fb4897b..f0e748b62 100644 --- a/app/src/test/java/org/openedx/AppViewModelTest.kt +++ b/app/src/test/java/org/openedx/AppViewModelTest.kt @@ -26,13 +26,13 @@ import org.openedx.app.AppViewModel import org.openedx.app.data.storage.PreferencesManager import org.openedx.app.deeplink.DeepLinkRouter import org.openedx.app.room.AppDatabase -import org.openedx.core.system.notifier.app.LogoutEvent import org.openedx.core.config.Config import org.openedx.core.config.FirebaseConfig import org.openedx.core.data.model.User import org.openedx.core.system.notifier.DownloadNotifier import org.openedx.core.system.notifier.app.AppNotifier -import org.openedx.core.utils.FileUtil +import org.openedx.core.system.notifier.app.LogoutEvent +import org.openedx.foundation.utils.FileUtil @ExperimentalCoroutinesApi class AppViewModelTest { diff --git a/auth/build.gradle b/auth/build.gradle index cd6f00621..470174991 100644 --- a/auth/build.gradle +++ b/auth/build.gradle @@ -2,6 +2,7 @@ plugins { id 'com.android.library' id 'org.jetbrains.kotlin.android' id 'kotlin-parcelize' + id "org.jetbrains.kotlin.plugin.compose" } android { @@ -48,32 +49,26 @@ android { viewBinding true compose true } - composeOptions { - kotlinCompilerExtensionVersion = "$compose_compiler_version" - } } dependencies { implementation project(path: ':core') - implementation "androidx.credentials:credentials:1.2.0" - implementation "androidx.credentials:credentials-play-services-auth:1.2.0" + implementation "androidx.credentials:credentials:1.3.0" + implementation "androidx.credentials:credentials-play-services-auth:1.3.0" implementation "com.facebook.android:facebook-login:16.2.0" - implementation "com.google.android.gms:play-services-auth:21.0.0" - implementation "com.google.android.libraries.identity.googleid:googleid:1.1.0" + implementation "com.google.android.gms:play-services-auth:21.2.0" + implementation "com.google.android.libraries.identity.googleid:googleid:1.1.1" implementation("com.microsoft.identity.client:msal:4.9.0") { //Workaround for the error Failed to resolve: 'io.opentelemetry:opentelemetry-bom' for AS Iguana exclude(group: "io.opentelemetry") } implementation("io.opentelemetry:opentelemetry-api:1.18.0") implementation("io.opentelemetry:opentelemetry-context:1.18.0") - androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' - androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version" - debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version" + androidTestImplementation 'androidx.test.ext:junit:1.2.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' testImplementation "junit:junit:$junit_version" testImplementation "io.mockk:mockk:$mockk_version" testImplementation "io.mockk:mockk-android:$mockk_version" - testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version" testImplementation "androidx.arch.core:core-testing:$android_arch_version" } diff --git a/auth/src/main/java/org/openedx/auth/presentation/AgreementProvider.kt b/auth/src/main/java/org/openedx/auth/presentation/AgreementProvider.kt index 0141df227..2b8fc9708 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/AgreementProvider.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/AgreementProvider.kt @@ -3,7 +3,7 @@ package org.openedx.auth.presentation import androidx.compose.ui.text.intl.Locale import org.openedx.auth.R import org.openedx.core.config.Config -import org.openedx.core.system.ResourceManager +import org.openedx.foundation.system.ResourceManager class AgreementProvider( private val config: Config, diff --git a/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationViewModel.kt b/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationViewModel.kt index 090c03251..2b9ca07e2 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationViewModel.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationViewModel.kt @@ -5,9 +5,9 @@ import org.openedx.auth.presentation.AuthAnalytics import org.openedx.auth.presentation.AuthAnalyticsEvent import org.openedx.auth.presentation.AuthAnalyticsKey import org.openedx.auth.presentation.AuthRouter -import org.openedx.core.BaseViewModel import org.openedx.core.config.Config -import org.openedx.core.extension.takeIfNotEmpty +import org.openedx.foundation.extension.takeIfNotEmpty +import org.openedx.foundation.presentation.BaseViewModel class LogistrationViewModel( private val courseId: String, diff --git a/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordFragment.kt b/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordFragment.kt index 84d2d584e..6f02f231c 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordFragment.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordFragment.kt @@ -58,21 +58,21 @@ import org.koin.androidx.viewmodel.ext.android.viewModel import org.openedx.auth.presentation.ui.LoginTextField import org.openedx.core.AppUpdateState import org.openedx.core.R -import org.openedx.core.UIMessage import org.openedx.core.presentation.global.app_upgrade.AppUpgradeRequiredScreen import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OpenEdXButton -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType 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.appShapes import org.openedx.core.ui.theme.appTypography -import org.openedx.core.ui.windowSizeValue +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue import org.openedx.auth.R as authR class RestorePasswordFragment : Fragment() { diff --git a/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordViewModel.kt b/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordViewModel.kt index 6827d8e78..504f55a7e 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordViewModel.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordViewModel.kt @@ -8,16 +8,16 @@ import org.openedx.auth.domain.interactor.AuthInteractor import org.openedx.auth.presentation.AuthAnalytics import org.openedx.auth.presentation.AuthAnalyticsEvent import org.openedx.auth.presentation.AuthAnalyticsKey -import org.openedx.core.BaseViewModel import org.openedx.core.R -import org.openedx.core.SingleEventLiveData -import org.openedx.core.UIMessage -import org.openedx.core.extension.isEmailValid -import org.openedx.core.extension.isInternetError import org.openedx.core.system.EdxError -import org.openedx.core.system.ResourceManager import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.core.system.notifier.app.AppUpgradeEvent +import org.openedx.foundation.extension.isEmailValid +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.SingleEventLiveData +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager class RestorePasswordViewModel( private val interactor: AuthInteractor, diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInFragment.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInFragment.kt index fabd8a40b..d5f11ea0a 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInFragment.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInFragment.kt @@ -17,8 +17,8 @@ import org.openedx.auth.data.model.AuthType import org.openedx.auth.presentation.signin.compose.LoginScreen import org.openedx.core.AppUpdateState import org.openedx.core.presentation.global.app_upgrade.AppUpgradeRequiredScreen -import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.foundation.presentation.rememberWindowSize class SignInFragment : Fragment() { diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt index 00b91d71f..5cc08b47e 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt @@ -21,23 +21,23 @@ import org.openedx.auth.presentation.AuthAnalyticsEvent import org.openedx.auth.presentation.AuthAnalyticsKey import org.openedx.auth.presentation.AuthRouter import org.openedx.auth.presentation.sso.OAuthHelper -import org.openedx.core.BaseViewModel -import org.openedx.core.SingleEventLiveData -import org.openedx.core.UIMessage import org.openedx.core.Validator import org.openedx.core.config.Config import org.openedx.core.data.storage.CalendarPreferences import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.interactor.CalendarInteractor import org.openedx.core.domain.model.createHonorCodeField -import org.openedx.core.extension.isInternetError import org.openedx.core.presentation.global.WhatsNewGlobalManager import org.openedx.core.system.EdxError -import org.openedx.core.system.ResourceManager import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.core.system.notifier.app.SignInEvent import org.openedx.core.utils.Logger +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.SingleEventLiveData +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import org.openedx.core.R as CoreRes class SignInViewModel( diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt index 52fa9b4c4..be4e9bf53 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt @@ -62,14 +62,11 @@ import org.openedx.auth.presentation.signin.SignInUIState import org.openedx.auth.presentation.ui.LoginTextField import org.openedx.auth.presentation.ui.PasswordVisibilityIcon import org.openedx.auth.presentation.ui.SocialAuthView -import org.openedx.core.UIMessage import org.openedx.core.extension.TextConverter import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.HyperlinkText import org.openedx.core.ui.OpenEdXButton -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.noRippleClickable import org.openedx.core.ui.theme.OpenEdXTheme @@ -77,7 +74,10 @@ 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.theme.compose.SignInLogoView -import org.openedx.core.ui.windowSizeValue +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.windowSizeValue import org.openedx.core.R as coreR @OptIn(ExperimentalComposeUiApi::class) diff --git a/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpFragment.kt b/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpFragment.kt index fa27d7d60..dabcc0e31 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpFragment.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpFragment.kt @@ -18,8 +18,8 @@ import org.openedx.auth.presentation.AuthRouter import org.openedx.auth.presentation.signup.compose.SignUpView import org.openedx.core.AppUpdateState import org.openedx.core.presentation.global.app_upgrade.AppUpgradeRequiredScreen -import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.foundation.presentation.rememberWindowSize class SignUpFragment : Fragment() { diff --git a/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpViewModel.kt b/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpViewModel.kt index 0826fca5c..35da6c030 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpViewModel.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpViewModel.kt @@ -22,19 +22,19 @@ import org.openedx.auth.presentation.AuthAnalyticsKey import org.openedx.auth.presentation.AuthRouter import org.openedx.auth.presentation.sso.OAuthHelper import org.openedx.core.ApiConstants -import org.openedx.core.BaseViewModel -import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.RegistrationField import org.openedx.core.domain.model.RegistrationFieldType import org.openedx.core.domain.model.createHonorCodeField -import org.openedx.core.extension.isInternetError -import org.openedx.core.system.ResourceManager import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.core.system.notifier.app.SignInEvent import org.openedx.core.utils.Logger +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import org.openedx.core.R as coreR class SignUpViewModel( diff --git a/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt b/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt index e1e31c7b8..05c5c1d4e 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt @@ -21,7 +21,6 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.CircularProgressIndicator -import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.MaterialTheme import androidx.compose.material.ModalBottomSheetLayout import androidx.compose.material.ModalBottomSheetValue @@ -68,15 +67,12 @@ import org.openedx.auth.presentation.ui.ExpandableText import org.openedx.auth.presentation.ui.OptionalFields import org.openedx.auth.presentation.ui.RequiredFields import org.openedx.auth.presentation.ui.SocialAuthView -import org.openedx.core.UIMessage import org.openedx.core.domain.model.RegistrationField import org.openedx.core.domain.model.RegistrationFieldType import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.SheetContent -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.isImeVisibleState import org.openedx.core.ui.noRippleClickable @@ -86,10 +82,13 @@ 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.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.windowSizeValue import org.openedx.core.R as coreR -@OptIn(ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class) +@OptIn(ExperimentalComposeUiApi::class) @Composable internal fun SignUpView( windowSize: WindowSize, diff --git a/auth/src/main/java/org/openedx/auth/presentation/sso/FacebookAuthHelper.kt b/auth/src/main/java/org/openedx/auth/presentation/sso/FacebookAuthHelper.kt index f6e734629..0d00e734e 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/sso/FacebookAuthHelper.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/sso/FacebookAuthHelper.kt @@ -12,8 +12,8 @@ import kotlinx.coroutines.suspendCancellableCoroutine import org.openedx.auth.data.model.AuthType import org.openedx.auth.domain.model.SocialAuthResponse import org.openedx.core.ApiConstants -import org.openedx.core.extension.safeResume import org.openedx.core.utils.Logger +import org.openedx.foundation.extension.safeResume import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException diff --git a/auth/src/main/java/org/openedx/auth/presentation/sso/MicrosoftAuthHelper.kt b/auth/src/main/java/org/openedx/auth/presentation/sso/MicrosoftAuthHelper.kt index 7cfcef591..5b75f3896 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/sso/MicrosoftAuthHelper.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/sso/MicrosoftAuthHelper.kt @@ -12,8 +12,8 @@ import org.openedx.auth.data.model.AuthType import org.openedx.auth.domain.model.SocialAuthResponse import org.openedx.core.ApiConstants import org.openedx.core.R -import org.openedx.core.extension.safeResume import org.openedx.core.utils.Logger +import org.openedx.foundation.extension.safeResume import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException diff --git a/auth/src/main/java/org/openedx/auth/presentation/ui/AuthUI.kt b/auth/src/main/java/org/openedx/auth/presentation/ui/AuthUI.kt index 9f75a2478..8e1a31d05 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/ui/AuthUI.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/ui/AuthUI.kt @@ -3,8 +3,8 @@ package org.openedx.auth.presentation.ui import android.content.res.Configuration import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.rememberTransition import androidx.compose.animation.core.tween -import androidx.compose.animation.core.updateTransition import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -55,7 +55,6 @@ import org.openedx.auth.R import org.openedx.core.domain.model.RegistrationField import org.openedx.core.domain.model.RegistrationFieldType import org.openedx.core.extension.TextConverter -import org.openedx.core.extension.tagId import org.openedx.core.ui.HyperlinkText import org.openedx.core.ui.SheetContent import org.openedx.core.ui.noRippleClickable @@ -63,6 +62,7 @@ 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.foundation.extension.tagId @Composable fun RequiredFields( @@ -512,7 +512,7 @@ fun ExpandableText( targetState = !isExpanded } } - val transition = updateTransition(transitionState, label = "") + val transition = rememberTransition(transitionState, label = "") val arrowRotationDegree by transition.animateFloat({ tween(durationMillis = 300) }, label = "") { @@ -534,7 +534,6 @@ fun ExpandableText( }, horizontalArrangement = Arrangement.SpaceBetween ) { - //TODO: textStyle Text( modifier = Modifier, text = text, diff --git a/auth/src/test/java/org/openedx/auth/presentation/restore/RestorePasswordViewModelTest.kt b/auth/src/test/java/org/openedx/auth/presentation/restore/RestorePasswordViewModelTest.kt index 0f040e908..580688a48 100644 --- a/auth/src/test/java/org/openedx/auth/presentation/restore/RestorePasswordViewModelTest.kt +++ b/auth/src/test/java/org/openedx/auth/presentation/restore/RestorePasswordViewModelTest.kt @@ -23,10 +23,10 @@ import org.junit.rules.TestRule import org.openedx.auth.domain.interactor.AuthInteractor import org.openedx.auth.presentation.AuthAnalytics import org.openedx.core.R -import org.openedx.core.UIMessage import org.openedx.core.system.EdxError -import org.openedx.core.system.ResourceManager import org.openedx.core.system.notifier.app.AppNotifier +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException @OptIn(ExperimentalCoroutinesApi::class) diff --git a/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt b/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt index bef45ad82..48480e310 100644 --- a/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt +++ b/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt @@ -27,7 +27,6 @@ import org.openedx.auth.presentation.AgreementProvider import org.openedx.auth.presentation.AuthAnalytics import org.openedx.auth.presentation.AuthRouter import org.openedx.auth.presentation.sso.OAuthHelper -import org.openedx.core.UIMessage import org.openedx.core.Validator import org.openedx.core.config.Config import org.openedx.core.config.FacebookConfig @@ -39,10 +38,10 @@ import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.interactor.CalendarInteractor import org.openedx.core.presentation.global.WhatsNewGlobalManager import org.openedx.core.system.EdxError -import org.openedx.core.system.ResourceManager -import org.openedx.core.system.notifier.app.AppEvent import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.core.system.notifier.app.SignInEvent +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException import org.openedx.core.R as CoreRes diff --git a/auth/src/test/java/org/openedx/auth/presentation/signup/SignUpViewModelTest.kt b/auth/src/test/java/org/openedx/auth/presentation/signup/SignUpViewModelTest.kt index f61e1053e..90ef8728f 100644 --- a/auth/src/test/java/org/openedx/auth/presentation/signup/SignUpViewModelTest.kt +++ b/auth/src/test/java/org/openedx/auth/presentation/signup/SignUpViewModelTest.kt @@ -33,7 +33,6 @@ import org.openedx.auth.presentation.AuthRouter import org.openedx.auth.presentation.sso.OAuthHelper import org.openedx.core.ApiConstants import org.openedx.core.R -import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.config.FacebookConfig import org.openedx.core.config.GoogleConfig @@ -43,8 +42,9 @@ import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.AgreementUrls import org.openedx.core.domain.model.RegistrationField import org.openedx.core.domain.model.RegistrationFieldType -import org.openedx.core.system.ResourceManager import org.openedx.core.system.notifier.app.AppNotifier +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException @ExperimentalCoroutinesApi diff --git a/build.gradle b/build.gradle index 1dab497e9..e7f7d673b 100644 --- a/build.gradle +++ b/build.gradle @@ -5,19 +5,20 @@ import java.util.regex.Pattern buildscript { ext { - kotlin_version = '1.9.22' - coroutines_version = '1.7.1' - compose_version = '1.6.2' - compose_compiler_version = '1.5.10' + //Depends on versions in OEXFoundation + kotlin_version = '2.0.0' + room_version = '2.6.1' } } plugins { - id 'com.android.application' version '8.4.0' apply false - id 'com.android.library' version '8.4.0' apply false + id 'com.android.application' version '8.5.2' apply false + id 'com.android.library' version '8.5.2' apply false id 'org.jetbrains.kotlin.android' version "$kotlin_version" apply false - id 'com.google.gms.google-services' version '4.4.1' apply false - id "com.google.firebase.crashlytics" version "3.0.1" apply false + id 'com.google.gms.google-services' version '4.4.2' apply false + id "com.google.firebase.crashlytics" version "3.0.2" apply false + id "com.google.devtools.ksp" version "2.0.0-1.0.24" apply false + id "org.jetbrains.kotlin.plugin.compose" version "$kotlin_version" apply false } tasks.register('clean', Delete) { @@ -25,44 +26,22 @@ tasks.register('clean', Delete) { } ext { - core_version = "1.10.1" - appcompat_version = "1.6.1" - material_version = "1.11.0" - lifecycle_version = "2.7.0" - fragment_version = "1.6.2" - constraintlayout_version = "2.1.4" - viewpager2_version = "1.0.0" - media3_version = "1.1.1" + media3_version = "1.4.1" youtubeplayer_version = "11.1.0" firebase_version = "33.0.0" - retrofit_version = '2.11.0' - logginginterceptor_version = '4.9.1' - - koin_version = '3.5.6' - - coil_version = '2.3.0' - jsoup_version = '1.13.1' - room_version = '2.6.1' - - work_version = '2.9.0' - - window_version = '1.2.0' - in_app_review = '2.0.1' extented_spans_version = "1.3.0" - webkit_version = "1.11.0" - configHelper = new ConfigHelper(projectDir, getCurrentFlavor()) zip_version = '2.6.3' //testing - mockk_version = '1.13.3' + mockk_version = '1.13.12' android_arch_version = '2.2.0' junit_version = '4.13.2' } @@ -84,6 +63,6 @@ def getCurrentFlavor() { } } -task generateMockedRawFile() { +tasks.register('generateMockedRawFile') { doLast { configHelper.generateMicrosoftConfig() } } diff --git a/core/build.gradle b/core/build.gradle index f135f62fd..f1ae6be5e 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -4,7 +4,7 @@ buildscript { } dependencies { - classpath 'org.yaml:snakeyaml:1.33' + classpath 'org.yaml:snakeyaml:2.0' } } @@ -12,7 +12,8 @@ plugins { id 'com.android.library' id 'org.jetbrains.kotlin.android' id 'kotlin-parcelize' - id 'kotlin-kapt' + id 'com.google.devtools.ksp' + id "org.jetbrains.kotlin.plugin.compose" } def currentFlavour = getCurrentFlavor() @@ -88,91 +89,39 @@ android { compose true buildConfig true } - composeOptions { - kotlinCompilerExtensionVersion = "$compose_compiler_version" - } } dependencies { api fileTree(dir: 'libs', include: ['*.jar']) - api "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" - api "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version" - api "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version" - - //AndroidX - api "androidx.core:core-ktx:$core_version" - api "androidx.appcompat:appcompat:$appcompat_version" - api "com.google.android.material:material:$material_version" - api "androidx.fragment:fragment-ktx:$fragment_version" - api "androidx.constraintlayout:constraintlayout:$constraintlayout_version" - api "androidx.viewpager2:viewpager2:$viewpager2_version" - api "androidx.window:window:$window_version" - api "androidx.work:work-runtime-ktx:$work_version" - - //Android Jetpack - api "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version" - api "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version" - api "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version" - api "androidx.lifecycle:lifecycle-runtime-compose:$lifecycle_version" - - // Fullstory - api 'com.fullstory:instrumentation-full:1.47.0@aar' - api 'com.fullstory:compose:1.47.0@aar' - // Room - api "androidx.room:room-runtime:$room_version" - api "androidx.room:room-ktx:$room_version" - kapt "androidx.room:room-compiler:$room_version" - - //Compose - api "androidx.compose.runtime:runtime:$compose_version" - api "androidx.compose.runtime:runtime-livedata:$compose_version" - api "androidx.compose.ui:ui:$compose_version" - api "androidx.compose.material:material:$compose_version" - api "androidx.compose.foundation:foundation:$compose_version" - debugApi "androidx.compose.ui:ui-tooling:$compose_version" - api "androidx.compose.ui:ui-tooling-preview:$compose_version" - api "androidx.compose.material:material-icons-extended:$compose_version" - debugApi "androidx.customview:customview:1.2.0-alpha02" - debugApi "androidx.customview:customview-poolingcontainer:1.0.0" - - //Networking - api "com.squareup.retrofit2:retrofit:$retrofit_version" - api "com.squareup.retrofit2:converter-gson:$retrofit_version" - api "com.squareup.okhttp3:logging-interceptor:$logginginterceptor_version" - - // Koin DI - api "io.insert-koin:koin-core:$koin_version" - api "io.insert-koin:koin-android:$koin_version" - api "io.insert-koin:koin-androidx-compose:$koin_version" - - api "io.coil-kt:coil-compose:$coil_version" - api "io.coil-kt:coil-gif:$coil_version" + ksp "androidx.room:room-compiler:$room_version" + // jsoup api "org.jsoup:jsoup:$jsoup_version" + // Firebase api platform("com.google.firebase:firebase-bom:$firebase_version") api 'com.google.firebase:firebase-common-ktx' api "com.google.firebase:firebase-crashlytics-ktx" - api "com.google.firebase:firebase-analytics-ktx" //Play In-App Review api "com.google.android.play:review-ktx:$in_app_review" - api "androidx.webkit:webkit:$webkit_version" - // Branch SDK Integration api "io.branch.sdk.android:library:5.9.0" - api "com.google.android.gms:play-services-ads-identifier:18.0.1" + api "com.google.android.gms:play-services-ads-identifier:18.1.0" api "com.android.installreferrer:installreferrer:2.2" // Zip api "net.lingala.zip4j:zip4j:$zip_version" + // OpenEdx libs + api("com.github.openedx:openedx-app-foundation-android:1.0.0") + testImplementation 'junit:junit:4.13.2' - androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + androidTestImplementation 'androidx.test.ext:junit:1.2.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' } def insertBuildConfigFields(currentFlavour, buildType) { diff --git a/core/src/main/java/org/openedx/core/BaseViewModel.kt b/core/src/main/java/org/openedx/core/BaseViewModel.kt deleted file mode 100644 index ac0578624..000000000 --- a/core/src/main/java/org/openedx/core/BaseViewModel.kt +++ /dev/null @@ -1,6 +0,0 @@ -package org.openedx.core - -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.ViewModel - -open class BaseViewModel : ViewModel(), DefaultLifecycleObserver \ No newline at end of file diff --git a/core/src/main/java/org/openedx/core/SingleEventLiveData.kt b/core/src/main/java/org/openedx/core/SingleEventLiveData.kt deleted file mode 100644 index dfa53c6dd..000000000 --- a/core/src/main/java/org/openedx/core/SingleEventLiveData.kt +++ /dev/null @@ -1,39 +0,0 @@ -package org.openedx.core - -import androidx.annotation.MainThread -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.Observer - -class SingleEventLiveData : MutableLiveData() { - - @MainThread - override fun observe(owner: LifecycleOwner, observer: Observer) { - - // Being strict about the observer numbers is up to you - // I thought it made sense to only allow one to handle the event - if (hasActiveObservers()) { - throw IllegalAccessException("Only one observer at a time may observe to a SingleEventLiveData") - } - - super.observe(owner, Observer { data -> - // We ignore any null values and early return - if (data == null) return@Observer - observer.onChanged(data) - // We set the value to null straight after emitting the change to the observer - value = null - // This means that the state of the data will always be null / non existent - // It will only be available to the observer in its callback and since we do not emit null values - // the observer never receives a null value and any observers resuming do not receive the last event. - // Therefore it only emits to the observer the single action - // so you are free to show messages over and over again - // Or launch an activity/dialog or anything that should only happen once per action / click :). - }) - } - - // Just a nicely named method that wraps setting the value - @MainThread - fun sendAction(data: T) { - value = data - } -} \ No newline at end of file diff --git a/core/src/main/java/org/openedx/core/UIMessage.kt b/core/src/main/java/org/openedx/core/UIMessage.kt deleted file mode 100644 index 8a9267f36..000000000 --- a/core/src/main/java/org/openedx/core/UIMessage.kt +++ /dev/null @@ -1,12 +0,0 @@ -package org.openedx.core - -import androidx.compose.material.SnackbarDuration - -open class UIMessage { - class SnackBarMessage( - val message: String, - val duration: SnackbarDuration = SnackbarDuration.Long, - ) : UIMessage() - - class ToastMessage(val message: String) : UIMessage() -} diff --git a/core/src/main/java/org/openedx/core/config/AnalyticsSource.kt b/core/src/main/java/org/openedx/core/config/AnalyticsSource.kt deleted file mode 100644 index b3ee82211..000000000 --- a/core/src/main/java/org/openedx/core/config/AnalyticsSource.kt +++ /dev/null @@ -1,11 +0,0 @@ -package org.openedx.core.config - -import com.google.gson.annotations.SerializedName - -enum class AnalyticsSource { - @SerializedName("segment") - SEGMENT, - - @SerializedName("none") - NONE, -} 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 c97bf7b47..e38a923b5 100644 --- a/core/src/main/java/org/openedx/core/config/Config.kt +++ b/core/src/main/java/org/openedx/core/config/Config.kt @@ -60,14 +60,6 @@ class Config(context: Context) { return getObjectOrNewInstance(FIREBASE, FirebaseConfig::class.java) } - fun getSegmentConfig(): SegmentConfig { - return getObjectOrNewInstance(SEGMENT_IO, SegmentConfig::class.java) - } - - fun getFullstoryConfig(): FullstoryConfig { - return getObjectOrNewInstance(FULLSTORY, FullstoryConfig::class.java) - } - fun getBrazeConfig(): BrazeConfig { return getObjectOrNewInstance(BRAZE, BrazeConfig::class.java) } @@ -164,8 +156,6 @@ class Config(context: Context) { private const val WHATS_NEW_ENABLED = "WHATS_NEW_ENABLED" private const val SOCIAL_AUTH_ENABLED = "SOCIAL_AUTH_ENABLED" private const val FIREBASE = "FIREBASE" - private const val SEGMENT_IO = "SEGMENT_IO" - private const val FULLSTORY = "FULLSTORY" private const val BRAZE = "BRAZE" private const val FACEBOOK = "FACEBOOK" private const val GOOGLE = "GOOGLE" diff --git a/core/src/main/java/org/openedx/core/config/FirebaseConfig.kt b/core/src/main/java/org/openedx/core/config/FirebaseConfig.kt index f5b2e9136..878b1e734 100644 --- a/core/src/main/java/org/openedx/core/config/FirebaseConfig.kt +++ b/core/src/main/java/org/openedx/core/config/FirebaseConfig.kt @@ -6,9 +6,6 @@ data class FirebaseConfig( @SerializedName("ENABLED") val enabled: Boolean = false, - @SerializedName("ANALYTICS_SOURCE") - val analyticsSource: AnalyticsSource = AnalyticsSource.NONE, - @SerializedName("CLOUD_MESSAGING_ENABLED") val isCloudMessagingEnabled: Boolean = false, @@ -23,8 +20,4 @@ data class FirebaseConfig( @SerializedName("API_KEY") val apiKey: String = "", -) { - fun isSegmentAnalyticsSource(): Boolean { - return enabled && analyticsSource == AnalyticsSource.SEGMENT - } -} +) diff --git a/core/src/main/java/org/openedx/core/config/FullstoryConfig.kt b/core/src/main/java/org/openedx/core/config/FullstoryConfig.kt deleted file mode 100644 index 00bc00e81..000000000 --- a/core/src/main/java/org/openedx/core/config/FullstoryConfig.kt +++ /dev/null @@ -1,11 +0,0 @@ -package org.openedx.core.config - -import com.google.gson.annotations.SerializedName - -data class FullstoryConfig( - @SerializedName("ENABLED") - val isEnabled: Boolean = false, - - @SerializedName("ORG_ID") - private val orgId: String = "" -) diff --git a/core/src/main/java/org/openedx/core/config/SegmentConfig.kt b/core/src/main/java/org/openedx/core/config/SegmentConfig.kt deleted file mode 100644 index ffa43e8bc..000000000 --- a/core/src/main/java/org/openedx/core/config/SegmentConfig.kt +++ /dev/null @@ -1,11 +0,0 @@ -package org.openedx.core.config - -import com.google.gson.annotations.SerializedName - -data class SegmentConfig( - @SerializedName("ENABLED") - val enabled: Boolean = false, - - @SerializedName("SEGMENT_IO_WRITE_KEY") - val segmentWriteKey: String = "", -) diff --git a/core/src/main/java/org/openedx/core/extension/AssetExt.kt b/core/src/main/java/org/openedx/core/extension/AssetExt.kt deleted file mode 100644 index 190f68721..000000000 --- a/core/src/main/java/org/openedx/core/extension/AssetExt.kt +++ /dev/null @@ -1,15 +0,0 @@ -package org.openedx.core.extension - -import android.content.res.AssetManager -import android.util.Log -import java.io.BufferedReader - -fun AssetManager.readAsText(fileName: String): String? { - return try { - open(fileName).bufferedReader().use(BufferedReader::readText) - } catch (e: Exception) { - Log.e("AssetExt", "Unable to load file $fileName from assets") - e.printStackTrace() - null - } -} diff --git a/core/src/main/java/org/openedx/core/extension/BundleExt.kt b/core/src/main/java/org/openedx/core/extension/BundleExt.kt deleted file mode 100644 index 59c0b9f93..000000000 --- a/core/src/main/java/org/openedx/core/extension/BundleExt.kt +++ /dev/null @@ -1,34 +0,0 @@ -@file:Suppress("NOTHING_TO_INLINE") - -package org.openedx.core.extension - -import android.os.Build.VERSION.SDK_INT -import android.os.Bundle -import android.os.Parcelable -import com.google.gson.Gson -import java.io.Serializable - -inline fun Bundle.parcelable(key: String): T? = when { - SDK_INT >= 33 -> getParcelable(key, T::class.java) - else -> @Suppress("DEPRECATION") getParcelable(key) as? T -} - -inline fun Bundle.serializable(key: String): T? = when { - SDK_INT >= 33 -> getSerializable(key, T::class.java) - else -> @Suppress("DEPRECATION") getSerializable(key) as? T -} - -inline fun Bundle.parcelableArrayList(key: String): ArrayList? = when { - SDK_INT >= 33 -> getParcelableArrayList(key, T::class.java) - else -> @Suppress("DEPRECATION") getParcelableArrayList(key) -} - -inline fun objectToString(value: T): String = Gson().toJson(value) - -inline fun stringToObject(value: String): T? { - return try { - Gson().fromJson(value, genericType()) - } catch (e: Exception) { - null - } -} diff --git a/core/src/main/java/org/openedx/core/extension/ContinuationExt.kt b/core/src/main/java/org/openedx/core/extension/ContinuationExt.kt deleted file mode 100644 index 8de4ec05b..000000000 --- a/core/src/main/java/org/openedx/core/extension/ContinuationExt.kt +++ /dev/null @@ -1,12 +0,0 @@ -package org.openedx.core.extension - -import kotlinx.coroutines.CancellableContinuation -import kotlin.coroutines.resume - -inline fun CancellableContinuation.safeResume(value: T, onExceptionCalled: () -> Unit) { - if (isActive) { - resume(value) - } else { - onExceptionCalled() - } -} diff --git a/core/src/main/java/org/openedx/core/extension/FlowExtension.kt b/core/src/main/java/org/openedx/core/extension/FlowExtension.kt deleted file mode 100644 index e88aff9ac..000000000 --- a/core/src/main/java/org/openedx/core/extension/FlowExtension.kt +++ /dev/null @@ -1,18 +0,0 @@ -package org.openedx.core.extension - -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.launch - -fun Flow.doWorkWhenStarted(lifecycleOwner: LifecycleOwner, doWork: (it: T) -> Unit) { - lifecycleOwner.lifecycleScope.launch { - lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - this@doWorkWhenStarted.collect { - doWork(it) - } - } - } -} \ No newline at end of file diff --git a/core/src/main/java/org/openedx/core/extension/FragmentExt.kt b/core/src/main/java/org/openedx/core/extension/FragmentExt.kt deleted file mode 100644 index 5d340b55d..000000000 --- a/core/src/main/java/org/openedx/core/extension/FragmentExt.kt +++ /dev/null @@ -1,28 +0,0 @@ -package org.openedx.core.extension - -import androidx.fragment.app.Fragment -import androidx.window.layout.WindowMetricsCalculator -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType - -fun Fragment.computeWindowSizeClasses(): WindowSize { - val metrics = WindowMetricsCalculator.getOrCreate() - .computeCurrentWindowMetrics(requireActivity()) - - val widthDp = metrics.bounds.width() / - resources.displayMetrics.density - val widthWindowSize = when { - widthDp < 600f -> WindowType.Compact - widthDp < 840f -> WindowType.Medium - else -> WindowType.Expanded - } - - val heightDp = metrics.bounds.height() / - resources.displayMetrics.density - val heightWindowSize = when { - heightDp < 480f -> WindowType.Compact - heightDp < 900f -> WindowType.Medium - else -> WindowType.Expanded - } - return WindowSize(widthWindowSize, heightWindowSize) -} \ No newline at end of file diff --git a/core/src/main/java/org/openedx/core/extension/GsonExt.kt b/core/src/main/java/org/openedx/core/extension/GsonExt.kt deleted file mode 100644 index 579a5ee6d..000000000 --- a/core/src/main/java/org/openedx/core/extension/GsonExt.kt +++ /dev/null @@ -1,9 +0,0 @@ -package org.openedx.core.extension - -import com.google.gson.Gson -import com.google.gson.reflect.TypeToken - -inline fun genericType() = object: TypeToken() {}.type - -inline fun Gson.fromJson(json: String) = fromJson(json, object: TypeToken() {}.type) - diff --git a/core/src/main/java/org/openedx/core/extension/ImageUploaderExtension.kt b/core/src/main/java/org/openedx/core/extension/ImageUploaderExtension.kt deleted file mode 100644 index a716544ca..000000000 --- a/core/src/main/java/org/openedx/core/extension/ImageUploaderExtension.kt +++ /dev/null @@ -1,17 +0,0 @@ -package org.openedx.core.extension - -import android.content.ContentResolver -import android.net.Uri -import android.provider.OpenableColumns - -fun ContentResolver.getFileName(fileUri: Uri): String { - var name = "" - val returnCursor = this.query(fileUri, null, null, null, null) - if (returnCursor != null) { - val nameIndex = returnCursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) - returnCursor.moveToFirst() - name = returnCursor.getString(nameIndex) - returnCursor.close() - } - return name -} \ No newline at end of file diff --git a/core/src/main/java/org/openedx/core/extension/IntExt.kt b/core/src/main/java/org/openedx/core/extension/IntExt.kt deleted file mode 100644 index 5739007f5..000000000 --- a/core/src/main/java/org/openedx/core/extension/IntExt.kt +++ /dev/null @@ -1,5 +0,0 @@ -package org.openedx.core.extension - -fun Int.nonZero(): Int? { - return if (this != 0) this else null -} diff --git a/core/src/main/java/org/openedx/core/extension/ListExt.kt b/core/src/main/java/org/openedx/core/extension/ListExt.kt index 1c2a242f7..6d97816ae 100644 --- a/core/src/main/java/org/openedx/core/extension/ListExt.kt +++ b/core/src/main/java/org/openedx/core/extension/ListExt.kt @@ -3,30 +3,6 @@ package org.openedx.core.extension import org.openedx.core.BlockType import org.openedx.core.domain.model.Block -inline fun List.indexOfFirstFromIndex(startIndex: Int, predicate: (T) -> Boolean): Int { - var index = 0 - for ((i, item) in this.withIndex()) { - if (i > startIndex) { - if (predicate(item)) - return index - } - index++ - } - return -1 -} - -fun ArrayList.clearAndAddAll(collection: Collection): ArrayList { - this.clear() - this.addAll(collection) - return this -} - -fun MutableList.clearAndAddAll(collection: Collection): MutableList { - this.clear() - this.addAll(collection) - return this -} - fun List.getVerticalBlocks(): List { return this.filter { it.type == BlockType.VERTICAL } } @@ -34,9 +10,3 @@ fun List.getVerticalBlocks(): List { fun List.getSequentialBlocks(): List { return this.filter { it.type == BlockType.SEQUENTIAL } } - -fun List?.isNotEmptyThenLet(block: (List) -> Unit) { - if (!isNullOrEmpty()) { - block(this) - } -} diff --git a/core/src/main/java/org/openedx/core/extension/LongExt.kt b/core/src/main/java/org/openedx/core/extension/LongExt.kt deleted file mode 100644 index 2071b6946..000000000 --- a/core/src/main/java/org/openedx/core/extension/LongExt.kt +++ /dev/null @@ -1,18 +0,0 @@ -package org.openedx.core.extension - -import kotlin.math.log10 -import kotlin.math.pow - -fun Long.toFileSize(round: Int = 2, space: Boolean = true): String { - try { - if (this <= 0) return "0MB" - val units = arrayOf("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB") - val digitGroups = (log10(this.toDouble()) / log10(1024.0)).toInt() - val size = this / 1024.0.pow(digitGroups.toDouble()) - val formatString = if (size % 1 < 0.05 || size % 1 >= 0.95) "%.0f" else "%.${round}f" - return String.format(formatString, size) + if (space) " " else "" + units[digitGroups] - } catch (e: Exception) { - println(e.toString()) - } - return "" -} diff --git a/core/src/main/java/org/openedx/core/extension/MapExt.kt b/core/src/main/java/org/openedx/core/extension/MapExt.kt deleted file mode 100644 index f985d119d..000000000 --- a/core/src/main/java/org/openedx/core/extension/MapExt.kt +++ /dev/null @@ -1,13 +0,0 @@ -package org.openedx.core.extension - -import android.os.Bundle - -fun Map.toBundle(): Bundle { - val bundle = Bundle() - for ((key, value) in this.entries) { - value?.let { - bundle.putString(key, it.toString()) - } - } - return bundle -} diff --git a/core/src/main/java/org/openedx/core/extension/StringExt.kt b/core/src/main/java/org/openedx/core/extension/StringExt.kt index 0ecc86e1f..301e9deb9 100644 --- a/core/src/main/java/org/openedx/core/extension/StringExt.kt +++ b/core/src/main/java/org/openedx/core/extension/StringExt.kt @@ -1,43 +1,6 @@ package org.openedx.core.extension -import android.util.Patterns import java.net.URL -import java.util.Locale -import java.util.regex.Pattern - - -fun String.isEmailValid(): Boolean { - val regex = - "^[\\w!#$%&'*+/=?`{|}~^-]+(?:\\.[\\w!#$%&'*+/=?`{|}~^-]+)*@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,6}$" - return Pattern.compile(regex).matcher(this).matches() -} - -fun String.isLinkValid() = Patterns.WEB_URL.matcher(this).matches() - -fun String.replaceLinkTags(isDarkTheme: Boolean): String { - val linkColor = if (isDarkTheme) "879FF5" else "0000EE" - var text = ("" - + "" - + "" + this) + "" - var str: String - while (text.indexOf("\u0082") > 0) { - if (text.indexOf("\u0082") > 0 && text.indexOf("\u0083") > 0) { - str = text.substring(text.indexOf("\u0082") + 1, text.indexOf("\u0083")) - text = text.replace(("\u0082" + str + "\u0083").toRegex(), "$str") - } - } - return text -} - -fun String.replaceSpace(target: String = ""): String = this.replace(" ", target) - -fun String.tagId(): String = this.replaceSpace("_").lowercase(Locale.getDefault()) - -fun String.takeIfNotEmpty(): String? { - return if (this.isEmpty().not()) this else null -} fun String?.equalsHost(host: String?): Boolean { return try { @@ -46,10 +9,3 @@ fun String?.equalsHost(host: String?): Boolean { false } } - -fun String.toImageLink(apiHostURL: String): String = - if (this.isLinkValid()) { - this - } else { - (apiHostURL + this).replace(Regex("(? { - val paramsMap = mutableMapOf() - - queryParameterNames.forEach { name -> - getQueryParameter(name)?.let { value -> - paramsMap[name] = value - } - } - - return paramsMap -} diff --git a/core/src/main/java/org/openedx/core/extension/ViewExt.kt b/core/src/main/java/org/openedx/core/extension/ViewExt.kt index 498619480..81a153ba1 100644 --- a/core/src/main/java/org/openedx/core/extension/ViewExt.kt +++ b/core/src/main/java/org/openedx/core/extension/ViewExt.kt @@ -1,59 +1,10 @@ package org.openedx.core.extension -import android.content.Context -import android.content.res.Resources -import android.graphics.Rect -import android.os.Build -import android.util.DisplayMetrics -import android.view.View -import android.view.ViewGroup import android.webkit.WebView -import android.widget.Toast -import androidx.fragment.app.DialogFragment -import androidx.webkit.WebSettingsCompat -import androidx.webkit.WebViewFeature import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.openedx.core.system.AppCookieManager -fun Context.dpToPixel(dp: Int): Float { - return dp * (resources.displayMetrics.densityDpi.toFloat() / DisplayMetrics.DENSITY_DEFAULT) -} - -fun Context.dpToPixel(dp: Float): Float { - return dp * (resources.displayMetrics.densityDpi.toFloat() / DisplayMetrics.DENSITY_DEFAULT) -} - -fun View.requestApplyInsetsWhenAttached() { - if (isAttachedToWindow) { - // We're already attached, just request as normal - requestApplyInsets() - } else { - // We're not attached to the hierarchy, add a listener to - // request when we are - addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener { - override fun onViewAttachedToWindow(v: View) { - v.removeOnAttachStateChangeListener(this) - v.requestApplyInsets() - } - - override fun onViewDetachedFromWindow(v: View) = Unit - }) - } -} - -fun DialogFragment.setWidthPercent(percentage: Int) { - val percent = percentage.toFloat() / 100 - val dm = Resources.getSystem().displayMetrics - val rect = dm.run { Rect(0, 0, widthPixels, heightPixels) } - val percentWidth = rect.width() * percent - dialog?.window?.setLayout(percentWidth.toInt(), ViewGroup.LayoutParams.WRAP_CONTENT) -} - -fun Context.toastMessage(message: String) { - Toast.makeText(this, message, Toast.LENGTH_SHORT).show() -} - fun WebView.loadUrl(url: String, scope: CoroutineScope, cookieManager: AppCookieManager) { if (cookieManager.isSessionCookieMissingOrExpired()) { scope.launch { @@ -64,13 +15,3 @@ fun WebView.loadUrl(url: String, scope: CoroutineScope, cookieManager: AppCookie loadUrl(url) } } - -fun WebView.applyDarkModeIfEnabled(isDarkTheme: Boolean) { - if (isDarkTheme && WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) { - try { - WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, true) - } catch (e: Exception) { - e.printStackTrace() - } - } -} 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 2186dbfc6..b3c211916 100644 --- a/core/src/main/java/org/openedx/core/module/DownloadWorker.kt +++ b/core/src/main/java/org/openedx/core/module/DownloadWorker.kt @@ -26,7 +26,7 @@ import org.openedx.core.module.download.FileDownloader import org.openedx.core.system.notifier.DownloadFailed import org.openedx.core.system.notifier.DownloadNotifier import org.openedx.core.system.notifier.DownloadProgressChanged -import org.openedx.core.utils.FileUtil +import org.openedx.foundation.utils.FileUtil class DownloadWorker( val context: Context, @@ -43,7 +43,8 @@ class DownloadWorker( private var downloadEnqueue = listOf() private var downloadError = mutableListOf() - private val folder = FileUtil(context).getExternalAppDir() + private val fileUtil: FileUtil by inject(FileUtil::class.java) + private val folder = fileUtil.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 114fc3147..6db81533c 100644 --- a/core/src/main/java/org/openedx/core/module/TranscriptManager.kt +++ b/core/src/main/java/org/openedx/core/module/TranscriptManager.kt @@ -4,9 +4,9 @@ import android.content.Context 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 org.openedx.foundation.utils.FileUtil import subtitleFile.FormatSRT import subtitleFile.TimedTextObject import java.io.File @@ -18,6 +18,7 @@ import java.util.concurrent.TimeUnit class TranscriptManager( val context: Context, + val fileUtil: FileUtil ) { private val transcriptDownloader = object : AbstractDownloader() { @@ -118,7 +119,7 @@ class TranscriptManager( } private fun getTranscriptDir(): File? { - val externalAppDir: File = FileUtil(context).getExternalAppDir() + val externalAppDir: File = fileUtil.getExternalAppDir() if (externalAppDir.exists()) { val videosDir = File(externalAppDir, Directories.VIDEOS.name) val transcriptDir = File(videosDir, Directories.SUBTITLES.name) diff --git a/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt b/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt index 40d3f1f41..b6635047f 100644 --- a/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt +++ b/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt @@ -6,7 +6,6 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -import org.openedx.core.BaseViewModel import org.openedx.core.BlockType import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Block @@ -17,6 +16,7 @@ import org.openedx.core.module.db.DownloadedState import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.presentation.CoreAnalyticsEvent import org.openedx.core.presentation.CoreAnalyticsKey +import org.openedx.foundation.presentation.BaseViewModel abstract class BaseDownloadViewModel( private val courseId: String, diff --git a/core/src/main/java/org/openedx/core/module/download/DownloadHelper.kt b/core/src/main/java/org/openedx/core/module/download/DownloadHelper.kt index 7c687f58e..79e44ab3c 100644 --- a/core/src/main/java/org/openedx/core/module/download/DownloadHelper.kt +++ b/core/src/main/java/org/openedx/core/module/download/DownloadHelper.kt @@ -5,8 +5,9 @@ import org.openedx.core.domain.model.Block import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.db.FileType -import org.openedx.core.utils.FileUtil import org.openedx.core.utils.Sha1Util +import org.openedx.core.utils.unzipFile +import org.openedx.foundation.utils.FileUtil import java.io.File class DownloadHelper( diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/alert/ActionDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/alert/ActionDialogFragment.kt index b7b3167e6..451d94915 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/alert/ActionDialogFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/alert/ActionDialogFragment.kt @@ -40,7 +40,7 @@ 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.UrlUtils +import org.openedx.foundation.utils.UrlUtils class ActionDialogFragment : DialogFragment() { diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/appreview/BaseAppReviewDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/appreview/BaseAppReviewDialogFragment.kt index 57dcdc233..245b8fe11 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/appreview/BaseAppReviewDialogFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/appreview/BaseAppReviewDialogFragment.kt @@ -3,8 +3,8 @@ package org.openedx.core.presentation.dialog.appreview import androidx.fragment.app.DialogFragment import org.koin.android.ext.android.inject import org.openedx.core.data.storage.InAppReviewPreferences -import org.openedx.core.extension.nonZero import org.openedx.core.presentation.global.AppData +import org.openedx.foundation.extension.nonZero open class BaseAppReviewDialogFragment : DialogFragment() { diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/selectorbottomsheet/SelectBottomDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/selectorbottomsheet/SelectBottomDialogFragment.kt index e2b6bdd58..8eca02a99 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/selectorbottomsheet/SelectBottomDialogFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/selectorbottomsheet/SelectBottomDialogFragment.kt @@ -32,13 +32,13 @@ import com.google.android.material.bottomsheet.BottomSheetDialogFragment import org.koin.androidx.viewmodel.ext.android.viewModel import org.openedx.core.R import org.openedx.core.domain.model.RegistrationField -import org.openedx.core.extension.parcelableArrayList import org.openedx.core.ui.SheetContent import org.openedx.core.ui.isImeVisibleState import org.openedx.core.ui.noRippleClickable import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes +import org.openedx.foundation.extension.parcelableArrayList class SelectBottomDialogFragment : BottomSheetDialogFragment() { @@ -95,7 +95,7 @@ class SelectBottomDialogFragment : BottomSheetDialogFragment() { ) .clip(MaterialTheme.appShapes.screenBackgroundShape) .padding(bottom = if (isImeVisible) 120.dp else 0.dp) - .noRippleClickable { } + .noRippleClickable { } ) { SheetContent( searchValue = searchValue, diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/selectorbottomsheet/SelectDialogViewModel.kt b/core/src/main/java/org/openedx/core/presentation/dialog/selectorbottomsheet/SelectDialogViewModel.kt index 6a09f5724..84d6d1407 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/selectorbottomsheet/SelectDialogViewModel.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/selectorbottomsheet/SelectDialogViewModel.kt @@ -1,11 +1,11 @@ package org.openedx.core.presentation.dialog.selectorbottomsheet import androidx.lifecycle.viewModelScope -import org.openedx.core.BaseViewModel +import kotlinx.coroutines.launch import org.openedx.core.domain.model.RegistrationField import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseSubtitleLanguageChanged -import kotlinx.coroutines.launch +import org.openedx.foundation.presentation.BaseViewModel class SelectDialogViewModel( private val notifier: CourseNotifier diff --git a/core/src/main/java/org/openedx/core/presentation/global/WindowSizeHolder.kt b/core/src/main/java/org/openedx/core/presentation/global/WindowSizeHolder.kt index 463f27ef2..510163b70 100644 --- a/core/src/main/java/org/openedx/core/presentation/global/WindowSizeHolder.kt +++ b/core/src/main/java/org/openedx/core/presentation/global/WindowSizeHolder.kt @@ -1,7 +1,7 @@ package org.openedx.core.presentation.global -import org.openedx.core.ui.WindowSize +import org.openedx.foundation.presentation.WindowSize interface WindowSizeHolder { val windowSize: WindowSize -} \ No newline at end of file +} diff --git a/core/src/main/java/org/openedx/core/presentation/global/webview/WebContentFragment.kt b/core/src/main/java/org/openedx/core/presentation/global/webview/WebContentFragment.kt index b1a496743..567a8ccce 100644 --- a/core/src/main/java/org/openedx/core/presentation/global/webview/WebContentFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/global/webview/WebContentFragment.kt @@ -11,8 +11,8 @@ import androidx.fragment.app.Fragment import org.koin.android.ext.android.inject import org.openedx.core.config.Config import org.openedx.core.ui.WebContentScreen -import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.foundation.presentation.rememberWindowSize class WebContentFragment : Fragment() { diff --git a/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncDialog.kt b/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncDialog.kt index ac358228e..f53e27e90 100644 --- a/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncDialog.kt +++ b/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncDialog.kt @@ -24,12 +24,12 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import org.openedx.core.R -import org.openedx.core.extension.takeIfNotEmpty import org.openedx.core.presentation.global.app_upgrade.TransparentTextButton 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.foundation.extension.takeIfNotEmpty import androidx.compose.ui.window.DialogProperties as AlertDialogProperties import org.openedx.core.R as CoreR @@ -230,5 +230,5 @@ private fun CalendarSyncDialogsPreview( } private class CalendarSyncDialogTypeProvider : PreviewParameterProvider { - override val values = CalendarSyncDialogType.values().dropLast(1).asSequence() + override val values = CalendarSyncDialogType.entries.dropLast(1).asSequence() } diff --git a/core/src/main/java/org/openedx/core/presentation/settings/video/VideoQualityFragment.kt b/core/src/main/java/org/openedx/core/presentation/settings/video/VideoQualityFragment.kt index edd00ce53..660a52a94 100644 --- a/core/src/main/java/org/openedx/core/presentation/settings/video/VideoQualityFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/settings/video/VideoQualityFragment.kt @@ -49,18 +49,18 @@ import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.core.R import org.openedx.core.domain.model.VideoQuality -import org.openedx.core.extension.nonZero -import org.openedx.core.extension.tagId import org.openedx.core.ui.Toolbar -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType 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.foundation.extension.nonZero +import org.openedx.foundation.extension.tagId +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue class VideoQualityFragment : Fragment() { @@ -183,7 +183,7 @@ private fun VideoQualityScreen( .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally ) { - VideoQuality.values().forEach { videoQuality -> + VideoQuality.entries.forEach { videoQuality -> QualityOption( title = stringResource(id = videoQuality.titleResId), description = videoQuality.desResId.nonZero() diff --git a/core/src/main/java/org/openedx/core/presentation/settings/video/VideoQualityViewModel.kt b/core/src/main/java/org/openedx/core/presentation/settings/video/VideoQualityViewModel.kt index bf30bbe30..2f8935e7a 100644 --- a/core/src/main/java/org/openedx/core/presentation/settings/video/VideoQualityViewModel.kt +++ b/core/src/main/java/org/openedx/core/presentation/settings/video/VideoQualityViewModel.kt @@ -4,7 +4,6 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.launch -import org.openedx.core.BaseViewModel import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.VideoQuality import org.openedx.core.presentation.CoreAnalytics @@ -12,6 +11,7 @@ import org.openedx.core.presentation.CoreAnalyticsEvent import org.openedx.core.presentation.CoreAnalyticsKey import org.openedx.core.system.notifier.VideoNotifier import org.openedx.core.system.notifier.VideoQualityChanged +import org.openedx.foundation.presentation.BaseViewModel class VideoQualityViewModel( private val qualityType: String, diff --git a/core/src/main/java/org/openedx/core/system/PreviewFragmentManager.kt b/core/src/main/java/org/openedx/core/system/PreviewFragmentManager.kt deleted file mode 100644 index 36d4b39eb..000000000 --- a/core/src/main/java/org/openedx/core/system/PreviewFragmentManager.kt +++ /dev/null @@ -1,5 +0,0 @@ -package org.openedx.core.system - -import androidx.fragment.app.FragmentManager - -object PreviewFragmentManager : FragmentManager() diff --git a/core/src/main/java/org/openedx/core/system/ResourceManager.kt b/core/src/main/java/org/openedx/core/system/ResourceManager.kt deleted file mode 100644 index 541eae56f..000000000 --- a/core/src/main/java/org/openedx/core/system/ResourceManager.kt +++ /dev/null @@ -1,43 +0,0 @@ -package org.openedx.core.system - -import android.content.Context -import android.graphics.Typeface -import android.graphics.drawable.Drawable -import androidx.annotation.* -import androidx.core.content.ContextCompat -import androidx.core.content.res.ResourcesCompat -import java.io.InputStream - -class ResourceManager(private val context: Context) { - - fun getString(@StringRes id: Int): String = context.getString(id) - - fun getString(@StringRes id: Int, vararg formatArgs: Any): String = - context.getString(id, *formatArgs) - - fun getStringArray(@ArrayRes id: Int): Array = context.resources.getStringArray(id) - - fun getIntArray(@ArrayRes id: Int): IntArray = context.resources.getIntArray(id) - - @ColorInt - fun getColor(@ColorRes id: Int): Int = context.getColor(id) - - fun getFont(@FontRes id: Int): Typeface? = ResourcesCompat.getFont(context, id) - - fun getRaw(@RawRes id: Int): InputStream { - return context.resources.openRawResource(id) - } - - fun getQuantityString(@PluralsRes id: Int, quantity: Int): String { - return context.resources.getQuantityString(id, quantity) - } - - fun getQuantityString(@PluralsRes id: Int, quantity: Int, vararg formatArgs: Any): String { - return context.resources.getQuantityString(id, quantity, *formatArgs) - } - - fun getDrawable(@DrawableRes id: Int): Drawable { - return ContextCompat.getDrawable(context, id)!! - } - -} \ No newline at end of file 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 3c4578d58..23f0d3315 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -33,7 +34,7 @@ import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.text.ClickableText +import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll @@ -77,6 +78,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager @@ -109,16 +111,16 @@ import coil.decode.ImageDecoderDecoder import kotlinx.coroutines.launch import org.openedx.core.NoContentScreenType import org.openedx.core.R -import org.openedx.core.UIMessage import org.openedx.core.domain.model.RegistrationField import org.openedx.core.extension.LinkedImageText -import org.openedx.core.extension.tagId -import org.openedx.core.extension.toastMessage import org.openedx.core.presentation.global.ErrorType 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.foundation.extension.tagId +import org.openedx.foundation.extension.toastMessage +import org.openedx.foundation.presentation.UIMessage @Composable fun StaticSearchBar( @@ -479,18 +481,19 @@ fun HyperlinkText( val uriHandler = LocalUriHandler.current - ClickableText( - modifier = modifier, + BasicText( text = annotatedString, - style = textStyle, - onClick = { - annotatedString - .getStringAnnotations("URL", it, it) - .firstOrNull()?.let { stringAnnotation -> - action?.invoke(stringAnnotation.item) - ?: uriHandler.openUri(stringAnnotation.item) - } - } + modifier = modifier.pointerInput(Unit) { + detectTapGestures { offset -> + val position = offset.x.toInt() + annotatedString.getStringAnnotations("URL", position, position) + .firstOrNull()?.let { stringAnnotation -> + action?.invoke(stringAnnotation.item) + ?: uriHandler.openUri(stringAnnotation.item) + } + } + }, + style = textStyle ) } @@ -590,17 +593,18 @@ fun HyperlinkImageText( .build() Column(Modifier.fillMaxWidth()) { - ClickableText( - modifier = modifier, + BasicText( text = annotatedString, - style = textStyle, - onClick = { - annotatedString - .getStringAnnotations("URL", it, it) - .firstOrNull()?.let { stringAnnotation -> - uriHandler.openUri(stringAnnotation.item) - } - } + modifier = modifier.pointerInput(Unit) { + detectTapGestures { offset -> + val position = offset.x.toInt() + annotatedString.getStringAnnotations("URL", position, position) + .firstOrNull()?.let { stringAnnotation -> + uriHandler.openUri(stringAnnotation.item) + } + } + }, + style = textStyle ) imageText.imageLinks.values.forEach { Spacer(Modifier.height(8.dp)) @@ -941,11 +945,11 @@ fun IconText( @Composable fun TextIcon( + modifier: Modifier = Modifier, text: String, icon: ImageVector, color: Color, textStyle: TextStyle = MaterialTheme.appTypography.bodySmall, - modifier: Modifier = Modifier, iconModifier: Modifier? = null, onClick: (() -> Unit)? = null, ) { @@ -971,11 +975,11 @@ fun TextIcon( @Composable fun TextIcon( + iconModifier: Modifier = Modifier, text: String, painter: Painter, color: Color, textStyle: TextStyle = MaterialTheme.appTypography.bodySmall, - iconModifier: Modifier = Modifier, onClick: (() -> Unit)? = null, ) { val modifier = if (onClick == null) { diff --git a/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt b/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt index 2fe762b26..807acd918 100644 --- a/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt +++ b/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt @@ -35,11 +35,13 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.zIndex -import org.openedx.core.extension.applyDarkModeIfEnabled -import org.openedx.core.extension.isEmailValid -import org.openedx.core.extension.replaceLinkTags import org.openedx.core.ui.theme.appColors import org.openedx.core.utils.EmailUtil +import org.openedx.foundation.extension.applyDarkModeIfEnabled +import org.openedx.foundation.extension.isEmailValid +import org.openedx.foundation.extension.replaceLinkTags +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.windowSizeValue import java.nio.charset.StandardCharsets @OptIn(ExperimentalComposeUiApi::class) diff --git a/core/src/main/java/org/openedx/core/ui/WindowSize.kt b/core/src/main/java/org/openedx/core/ui/WindowSize.kt deleted file mode 100644 index 735dfc209..000000000 --- a/core/src/main/java/org/openedx/core/ui/WindowSize.kt +++ /dev/null @@ -1,55 +0,0 @@ -package org.openedx.core.ui - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalConfiguration - -data class WindowSize( - val width: WindowType, - val height: WindowType -) { - val isTablet: Boolean - get() = height != WindowType.Compact && width != WindowType.Compact -} - -fun WindowSize.windowSizeValue(expanded: T, compact: T): T { - return if (height != WindowType.Compact && width != WindowType.Compact) { - expanded - } else { - compact - } -} - -enum class WindowType { - Compact, Medium, Expanded -} - -@Composable -fun rememberWindowSize(): WindowSize { - val configuration = LocalConfiguration.current - val screenWidth by remember(key1 = configuration) { - mutableStateOf(configuration.screenWidthDp) - } - val screenHeight by remember(key1 = configuration) { - mutableStateOf(configuration.screenHeightDp) - } - - return WindowSize( - width = getScreenWidth(screenWidth), - height = getScreenHeight(screenHeight) - ) -} - -fun getScreenWidth(width: Int): WindowType = when { - width < 600 -> WindowType.Compact - width < 840 -> WindowType.Medium - else -> WindowType.Expanded -} - -fun getScreenHeight(height: Int): WindowType = when { - height < 480 -> WindowType.Compact - height < 900 -> WindowType.Medium - else -> WindowType.Expanded -} \ No newline at end of file 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 7c7423e60..5f890e690 100644 --- a/core/src/main/java/org/openedx/core/utils/FileUtil.kt +++ b/core/src/main/java/org/openedx/core/utils/FileUtil.kt @@ -1,122 +1,28 @@ package org.openedx.core.utils -import android.content.Context -import android.util.Log -import com.google.gson.Gson -import com.google.gson.GsonBuilder import net.lingala.zip4j.ZipFile import net.lingala.zip4j.exception.ZipException +import org.openedx.foundation.utils.FileUtil import java.io.File -import java.util.Collections -class FileUtil(val context: Context) { - - 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) - file.mkdirs() - 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 - } - } - - /** - * Deletes all the files and directories in the app's external storage directory. - */ - fun deleteOldAppDirectory() { - val externalFilesDir = context.getExternalFilesDir(null) - val externalAppDir = File(externalFilesDir?.parentFile, Directories.VIDEOS.name) - if (externalAppDir.isDirectory) { - deleteRecursive(externalAppDir, Collections.emptyList()) - } - } - - /** - * Deletes a file or directory and all its content recursively. - * - * @param fileOrDirectory The file or directory that needs to be deleted. - * @param exceptions Names of the files or directories that need to be skipped while deletion. - */ - private fun deleteRecursive( - fileOrDirectory: File, - exceptions: List, - ) { - if (exceptions.contains(fileOrDirectory.name)) return - - if (fileOrDirectory.isDirectory) { - val filesList = fileOrDirectory.listFiles() - if (filesList != null) { - for (child in filesList) { - deleteRecursive(child, exceptions) - } - } - } - - // Don't break the recursion upon encountering an error - // noinspection ResultOfMethodCallIgnored - fileOrDirectory.delete() - } - - fun unzipFile(filepath: String): String? { - val archive = File(filepath) - val destinationFolder = File( - archive.parentFile.absolutePath + "/" + archive.name + "-unzipped" - ) - try { - if (!destinationFolder.exists()) { - destinationFolder.mkdirs() - } - val zip = ZipFile(archive) - zip.extractAll(destinationFolder.absolutePath) - deleteFile(archive.absolutePath) - return destinationFolder.absolutePath - } catch (e: ZipException) { - e.printStackTrace() - deleteFile(destinationFolder.absolutePath) - } - return null - } - - private fun deleteFile(filepath: String?): Boolean { - try { - if (filepath != null) { - val file = File(filepath) - if (file.exists()) { - if (file.delete()) { - Log.d(this.javaClass.name, "Deleted: " + file.path) - return true - } else { - Log.d(this.javaClass.name, "Delete failed: " + file.path) - } - } else { - Log.d(this.javaClass.name, "Delete failed, file does NOT exist: " + file.path) - return true - } - } - } catch (e: Exception) { - e.printStackTrace() +fun FileUtil.unzipFile(filepath: String): String? { + val archive = File(filepath) + val destinationFolder = File( + archive.parentFile.absolutePath + "/" + archive.name + "-unzipped" + ) + try { + if (!destinationFolder.exists()) { + destinationFolder.mkdirs() } - return false + val zip = ZipFile(archive) + zip.extractAll(destinationFolder.absolutePath) + deleteFile(archive.absolutePath) + return destinationFolder.absolutePath + } catch (e: ZipException) { + e.printStackTrace() + deleteFile(destinationFolder.absolutePath) } + return 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 02e0bde2f..f39b9369a 100644 --- a/core/src/main/java/org/openedx/core/utils/TimeUtils.kt +++ b/core/src/main/java/org/openedx/core/utils/TimeUtils.kt @@ -5,7 +5,7 @@ import android.text.format.DateUtils import com.google.gson.internal.bind.util.ISO8601Utils import org.openedx.core.R import org.openedx.core.domain.model.StartType -import org.openedx.core.system.ResourceManager +import org.openedx.foundation.system.ResourceManager import java.text.DateFormat import java.text.ParseException import java.text.ParsePosition diff --git a/core/src/main/java/org/openedx/core/utils/UrlUtils.kt b/core/src/main/java/org/openedx/core/utils/UrlUtils.kt deleted file mode 100644 index 191edd4da..000000000 --- a/core/src/main/java/org/openedx/core/utils/UrlUtils.kt +++ /dev/null @@ -1,67 +0,0 @@ -package org.openedx.core.utils - -import android.content.Context -import android.content.Intent -import android.net.Uri - -object UrlUtils { - - const val QUERY_PARAM_SEARCH = "q" - - fun openInBrowser(activity: Context, apiHostUrl: String, url: String) { - if (url.isEmpty()) { - return - } - if (url.startsWith("/")) { - // Use API host as the base URL for relative paths - val absoluteUrl = "$apiHostUrl$url" - openInBrowser(activity, absoluteUrl) - return - } - openInBrowser(activity, url) - } - - private fun openInBrowser(context: Context, url: String) { - val intent = Intent(Intent.ACTION_VIEW) - intent.setData(Uri.parse(url)) - context.startActivity(intent) - } - - /** - * Utility function to remove the given query parameter from the URL - * Ref: https://stackoverflow.com/a/56108097 - * - * @param url that needs to update - * @param queryParam that needs to remove from the URL - * @return The URL after removing the given params - */ - private fun removeQueryParameterFromURL(url: String, queryParam: String): String { - val uri = Uri.parse(url) - val params = uri.queryParameterNames - val newUri = uri.buildUpon().clearQuery() - for (param in params) { - if (queryParam != param) { - newUri.appendQueryParameter(param, uri.getQueryParameter(param)) - } - } - return newUri.build().toString() - } - - /** - * Builds a valid URL with the given query params. - * - * @param url The base URL. - * @param queryParams The query params to add in the URL. - * @return URL String with query params added to it. - */ - fun buildUrlWithQueryParams(url: String, queryParams: Map): String { - val uriBuilder = Uri.parse(url).buildUpon() - for ((key, value) in queryParams) { - if (url.contains(key)) { - removeQueryParameterFromURL(url, key) - } - uriBuilder.appendQueryParameter(key, value) - } - return uriBuilder.build().toString() - } -} diff --git a/course/build.gradle b/course/build.gradle index 49946ca92..3b8096dc4 100644 --- a/course/build.gradle +++ b/course/build.gradle @@ -2,6 +2,7 @@ plugins { id 'com.android.library' id 'org.jetbrains.kotlin.android' id 'kotlin-parcelize' + id "org.jetbrains.kotlin.plugin.compose" } android { @@ -36,9 +37,6 @@ android { viewBinding true compose true } - composeOptions { - kotlinCompilerExtensionVersion = "$compose_compiler_version" - } flavorDimensions += "env" productFlavors { @@ -68,13 +66,10 @@ dependencies { implementation "androidx.media3:media3-cast:$media3_version" implementation "me.saket.extendedspans:extendedspans:$extented_spans_version" - androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' - androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version" - debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version" + androidTestImplementation 'androidx.test.ext:junit:1.2.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' testImplementation "junit:junit:$junit_version" testImplementation "io.mockk:mockk:$mockk_version" testImplementation "io.mockk:mockk-android:$mockk_version" - testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version" testImplementation "androidx.arch.core:core-testing:$android_arch_version" } \ No newline at end of file diff --git a/course/src/main/java/org/openedx/course/DatesShiftedSnackBar.kt b/course/src/main/java/org/openedx/course/DatesShiftedSnackBar.kt index fd2a3ce6b..1c7d5fcaf 100644 --- a/course/src/main/java/org/openedx/course/DatesShiftedSnackBar.kt +++ b/course/src/main/java/org/openedx/course/DatesShiftedSnackBar.kt @@ -1,5 +1,5 @@ package org.openedx.course -import org.openedx.core.UIMessage +import org.openedx.foundation.presentation.UIMessage class DatesShiftedSnackBar : UIMessage() 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 1865a3c34..f71c8593f 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 @@ -5,7 +5,7 @@ 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 +import org.openedx.foundation.extension.genericType class CourseConverter { diff --git a/course/src/main/java/org/openedx/course/presentation/ChapterEndFragmentDialog.kt b/course/src/main/java/org/openedx/course/presentation/ChapterEndFragmentDialog.kt index c416aa497..13380ddde 100644 --- a/course/src/main/java/org/openedx/course/presentation/ChapterEndFragmentDialog.kt +++ b/course/src/main/java/org/openedx/course/presentation/ChapterEndFragmentDialog.kt @@ -40,7 +40,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.os.bundleOf import androidx.fragment.app.DialogFragment -import org.openedx.core.extension.setWidthPercent import org.openedx.core.ui.AutoSizeText import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.OpenEdXOutlinedButton @@ -50,6 +49,7 @@ import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography import org.openedx.course.R +import org.openedx.foundation.extension.setWidthPercent class ChapterEndFragmentDialog : DialogFragment() { 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 08f6cf96a..b40387266 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 @@ -68,10 +68,10 @@ import kotlinx.coroutines.launch import org.openedx.core.R import org.openedx.core.ui.RoundTabsBar 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.foundation.presentation.rememberWindowSize import kotlin.math.roundToInt @Composable 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 9e3db405c..856b40c4f 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 @@ -55,13 +55,10 @@ import kotlinx.coroutines.launch 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.global.viewBinding import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OfflineModeDialog import org.openedx.core.ui.RoundTabsBar -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.course.DatesShiftedSnackBar @@ -75,6 +72,9 @@ import org.openedx.course.presentation.outline.CourseOutlineScreen import org.openedx.course.presentation.ui.CourseVideosScreen import org.openedx.course.presentation.ui.DatesShiftedSnackBar import org.openedx.discussion.presentation.topics.DiscussionTopicsScreen +import org.openedx.foundation.extension.takeIfNotEmpty +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.rememberWindowSize class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { @@ -368,7 +368,6 @@ fun CourseDashboard( } } -@OptIn(ExperimentalFoundationApi::class) @Composable fun DashboardPager( windowSize: WindowSize, @@ -382,7 +381,7 @@ fun DashboardPager( HorizontalPager( state = pagerState, userScrollEnabled = isNavigationEnabled, - beyondBoundsPageCount = CourseContainerTab.entries.size + beyondViewportPageCount = CourseContainerTab.entries.size ) { page -> when (CourseContainerTab.entries[page]) { CourseContainerTab.HOME -> { 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 d30d68c00..f27227b8f 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 @@ -15,18 +15,11 @@ 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.ImageProcessor -import org.openedx.core.SingleEventLiveData -import org.openedx.core.UIMessage 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.extension.toImageLink import org.openedx.core.presentation.settings.calendarsync.CalendarSyncDialogType import org.openedx.core.presentation.settings.calendarsync.CalendarSyncUIState -import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEvent import org.openedx.core.system.notifier.CourseCompletionSet @@ -45,6 +38,13 @@ 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.utils.ImageProcessor +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.extension.toImageLink +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.SingleEventLiveData +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import java.util.Date import java.util.concurrent.atomic.AtomicReference import org.openedx.core.R as CoreR diff --git a/course/src/main/java/org/openedx/course/presentation/container/HeaderContent.kt b/course/src/main/java/org/openedx/course/presentation/container/HeaderContent.kt index a2070eb66..5b1625d49 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/HeaderContent.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/HeaderContent.kt @@ -11,10 +11,10 @@ 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 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.appTypography +import org.openedx.foundation.presentation.rememberWindowSize @Composable internal fun ExpandedHeaderContent( diff --git a/course/src/main/java/org/openedx/course/presentation/container/NoAccessCourseContainerFragment.kt b/course/src/main/java/org/openedx/course/presentation/container/NoAccessCourseContainerFragment.kt index f6f5d8e7d..e9b3b2e89 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/NoAccessCourseContainerFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/NoAccessCourseContainerFragment.kt @@ -4,10 +4,24 @@ import android.content.res.Configuration import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup -import androidx.compose.foundation.layout.* -import androidx.compose.material.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.Icon +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.Error +import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -25,12 +39,15 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import androidx.core.os.bundleOf import androidx.fragment.app.Fragment -import org.openedx.core.extension.parcelable -import org.openedx.core.ui.* +import org.openedx.core.ui.BackBtn +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 java.util.* +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue import org.openedx.course.R as courseR class NoAccessCourseContainerFragment : Fragment() { 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 e15d3f7d4..adb633b98 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 @@ -33,8 +33,6 @@ import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold import androidx.compose.material.Surface -import androidx.compose.material.Switch -import androidx.compose.material.SwitchDefaults import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight @@ -63,35 +61,32 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentManager import org.openedx.core.NoContentScreenType -import org.openedx.core.UIMessage import org.openedx.core.data.model.DateType import org.openedx.core.domain.model.CourseDateBlock import org.openedx.core.domain.model.CourseDatesBannerInfo import org.openedx.core.domain.model.CourseDatesResult import org.openedx.core.domain.model.DatesSection -import org.openedx.core.extension.isNotEmptyThenLet import org.openedx.core.presentation.CoreAnalyticsScreen import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.presentation.dialog.alert.ActionDialogFragment import org.openedx.core.presentation.settings.calendarsync.CalendarSyncState -import org.openedx.core.presentation.settings.calendarsync.CalendarSyncUIState import org.openedx.core.ui.CircularProgress import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.NoContentScreen -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors -import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography -import org.openedx.core.ui.windowSizeValue import org.openedx.core.utils.TimeUtils import org.openedx.core.utils.TimeUtils.formatToString import org.openedx.core.utils.clearTime -import org.openedx.course.R import org.openedx.course.presentation.ui.CourseDatesBanner import org.openedx.course.presentation.ui.CourseDatesBannerTablet +import org.openedx.foundation.extension.isNotEmptyThenLet +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.windowSizeValue import java.util.Date import org.openedx.core.R as CoreR @@ -353,68 +348,6 @@ private fun CourseDatesUI( } } -@Composable -fun CalendarSyncCard( - modifier: Modifier = Modifier, - checked: Boolean, - onCalendarSync: (Boolean) -> Unit, -) { - val cardModifier = modifier - .background( - MaterialTheme.appColors.cardViewBackground, - MaterialTheme.appShapes.material.medium - ) - .border( - 1.dp, - MaterialTheme.appColors.cardViewBorder, - MaterialTheme.appShapes.material.medium - ) - .padding(16.dp) - - Column(modifier = cardModifier) { - Row( - modifier = Modifier - .fillMaxWidth() - .height(40.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - Icon( - painter = painterResource(id = R.drawable.course_ic_calenday_sync), - contentDescription = null, - modifier = Modifier.size(24.dp) - ) - Text( - modifier = Modifier - .padding(start = 8.dp, end = 8.dp) - .weight(1f), - text = stringResource(id = CoreR.string.core_header_sync_to_calendar), - style = MaterialTheme.appTypography.titleMedium, - color = MaterialTheme.appColors.textDark - ) - Switch( - checked = checked, - onCheckedChange = onCalendarSync, - modifier = Modifier.size(48.dp), - colors = SwitchDefaults.colors( - checkedThumbColor = MaterialTheme.appColors.primary, - checkedTrackColor = MaterialTheme.appColors.primary - ) - ) - } - - Text( - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp) - .height(40.dp), - text = stringResource(id = CoreR.string.core_body_sync_to_calendar), - style = MaterialTheme.appTypography.bodyMedium, - color = MaterialTheme.appColors.textDark, - ) - } -} - @Composable fun ExpandableView( sectionKey: DatesSection = DatesSection.NONE, 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 54406019d..3f716607f 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 @@ -9,10 +9,8 @@ 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.CalendarRouter import org.openedx.core.R -import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.interactor.CalendarInteractor @@ -22,10 +20,8 @@ import org.openedx.core.domain.model.CourseDateBlock import org.openedx.core.domain.model.CourseStructure import org.openedx.core.extension.getSequentialBlocks import org.openedx.core.extension.getVerticalBlocks -import org.openedx.core.extension.isInternetError import org.openedx.core.presentation.settings.calendarsync.CalendarSyncDialogType import org.openedx.core.presentation.settings.calendarsync.CalendarSyncState -import org.openedx.core.system.ResourceManager import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEvent import org.openedx.core.system.notifier.CourseDatesShifted import org.openedx.core.system.notifier.CourseLoading @@ -37,6 +33,10 @@ 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.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import org.openedx.core.R as CoreR class CourseDatesViewModel( diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt index 1c220903f..c591966f4 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt @@ -33,10 +33,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.os.bundleOf import androidx.fragment.app.DialogFragment -import org.openedx.core.extension.parcelable -import org.openedx.core.extension.toFileSize import org.openedx.core.presentation.dialog.DefaultDialogBox -import org.openedx.core.system.PreviewFragmentManager import org.openedx.core.ui.AutoSizeText import org.openedx.core.ui.IconText import org.openedx.core.ui.OpenEdXButton @@ -46,6 +43,9 @@ import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography import org.openedx.course.R import org.openedx.course.domain.model.DownloadDialogResource +import org.openedx.foundation.extension.parcelable +import org.openedx.foundation.extension.toFileSize +import org.openedx.foundation.system.PreviewFragmentManager import androidx.compose.ui.graphics.Color as ComposeColor import org.openedx.core.R as coreR diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogFragment.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogFragment.kt index 05d7e0243..96cdf3d40 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogFragment.kt @@ -29,9 +29,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.os.bundleOf import androidx.fragment.app.DialogFragment -import org.openedx.core.extension.parcelable import org.openedx.core.presentation.dialog.DefaultDialogBox -import org.openedx.core.system.PreviewFragmentManager import org.openedx.core.ui.AutoSizeText import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.OpenEdXOutlinedButton @@ -40,6 +38,8 @@ import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography import org.openedx.course.R import org.openedx.course.domain.model.DownloadDialogResource +import org.openedx.foundation.extension.parcelable +import org.openedx.foundation.system.PreviewFragmentManager import org.openedx.core.R as coreR class DownloadErrorDialogFragment : DialogFragment() { diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadStorageErrorDialogFragment.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadStorageErrorDialogFragment.kt index 0059f2bec..4c192209f 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadStorageErrorDialogFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadStorageErrorDialogFragment.kt @@ -41,10 +41,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.os.bundleOf import androidx.fragment.app.DialogFragment -import org.openedx.core.extension.parcelable -import org.openedx.core.extension.toFileSize import org.openedx.core.presentation.dialog.DefaultDialogBox -import org.openedx.core.system.PreviewFragmentManager import org.openedx.core.system.StorageManager import org.openedx.core.ui.AutoSizeText import org.openedx.core.ui.OpenEdXOutlinedButton @@ -54,6 +51,9 @@ import org.openedx.core.ui.theme.appTypography import org.openedx.course.R import org.openedx.course.domain.model.DownloadDialogResource import org.openedx.course.presentation.download.DownloadDialogManager.Companion.DOWNLOAD_SIZE_FACTOR +import org.openedx.foundation.extension.parcelable +import org.openedx.foundation.extension.toFileSize +import org.openedx.foundation.system.PreviewFragmentManager import org.openedx.core.R as coreR class DownloadStorageErrorDialogFragment : DialogFragment() { diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadView.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadView.kt index 2a760c772..fd70dd723 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadView.kt +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadView.kt @@ -15,9 +15,9 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import org.openedx.core.R -import org.openedx.core.extension.toFileSize import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography +import org.openedx.foundation.extension.toFileSize @Composable fun DownloadDialogItem( diff --git a/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsScreen.kt b/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsScreen.kt index 184031091..9720740a2 100644 --- a/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsScreen.kt @@ -34,14 +34,14 @@ 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 org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography -import org.openedx.core.ui.windowSizeValue import org.openedx.course.presentation.ui.CardArrow +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.windowSizeValue import org.openedx.course.R as courseR @Composable diff --git a/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsViewModel.kt b/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsViewModel.kt index 424f71f81..c8d9a87f8 100644 --- a/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsViewModel.kt @@ -5,7 +5,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import org.openedx.core.BaseViewModel import org.openedx.core.config.Config import org.openedx.core.domain.model.AnnouncementModel import org.openedx.core.domain.model.HandoutsModel @@ -13,6 +12,7 @@ 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.foundation.presentation.BaseViewModel class HandoutsViewModel( private val courseId: String, diff --git a/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsWebViewFragment.kt b/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsWebViewFragment.kt index dbcbde30a..24240954a 100644 --- a/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsWebViewFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsWebViewFragment.kt @@ -40,15 +40,15 @@ import org.openedx.core.ui.CircularProgress import org.openedx.core.ui.NoContentScreen import org.openedx.core.ui.Toolbar import org.openedx.core.ui.WebContentScreen -import org.openedx.core.ui.WindowSize 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.windowSizeValue import org.openedx.course.R import org.openedx.course.presentation.CourseAnalyticsEvent +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue class HandoutsWebViewFragment : Fragment() { diff --git a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt index cdad27742..9a4374aec 100644 --- a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt @@ -49,21 +49,21 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentManager -import org.openedx.core.extension.toFileSize import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.db.FileType import org.openedx.core.ui.IconText import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.OpenEdXOutlinedButton -import org.openedx.core.ui.WindowSize import org.openedx.core.ui.displayCutoutForLandscape -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.appTypography -import org.openedx.core.ui.windowSizeValue import org.openedx.course.R +import org.openedx.foundation.extension.toFileSize +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue import org.openedx.core.R as coreR @Composable diff --git a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt index 6fc72607f..88c8a60c4 100644 --- a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt @@ -14,7 +14,6 @@ import kotlinx.coroutines.launch import org.openedx.core.BlockType import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Block -import org.openedx.core.extension.toFileSize import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.db.DownloadModel @@ -23,10 +22,11 @@ import org.openedx.core.module.download.BaseDownloadViewModel import org.openedx.core.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.system.connection.NetworkConnection -import org.openedx.core.utils.FileUtil import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.download.DownloadDialogItem import org.openedx.course.presentation.download.DownloadDialogManager +import org.openedx.foundation.extension.toFileSize +import org.openedx.foundation.utils.FileUtil class CourseOfflineViewModel( val courseId: String, 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 90d74e7f5..6d6b10af7 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 @@ -46,7 +46,6 @@ import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentManager import org.openedx.core.BlockType import org.openedx.core.NoContentScreenType -import org.openedx.core.UIMessage import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts @@ -55,25 +54,26 @@ import org.openedx.core.domain.model.CourseStructure import org.openedx.core.domain.model.CoursewareAccess import org.openedx.core.domain.model.OfflineDownload import org.openedx.core.domain.model.Progress -import org.openedx.core.extension.takeIfNotEmpty import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.ui.CircularProgress import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.NoContentScreen import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.TextIcon -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography -import org.openedx.core.ui.windowSizeValue import org.openedx.course.R import org.openedx.course.presentation.ui.CourseDatesBanner import org.openedx.course.presentation.ui.CourseDatesBannerTablet import org.openedx.course.presentation.ui.CourseMessage import org.openedx.course.presentation.ui.CourseSection +import org.openedx.foundation.extension.takeIfNotEmpty +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.windowSizeValue import java.util.Date import org.openedx.core.R as CoreR 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 006176b56..b613bea49 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 @@ -11,7 +11,6 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.openedx.core.BlockType import org.openedx.core.R -import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Block @@ -22,7 +21,6 @@ import org.openedx.core.domain.model.CourseDatesResult import org.openedx.core.domain.model.CourseStructure import org.openedx.core.extension.getSequentialBlocks import org.openedx.core.extension.getVerticalBlocks -import org.openedx.core.extension.isInternetError import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.download.BaseDownloadViewModel @@ -30,7 +28,6 @@ import org.openedx.core.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.presentation.settings.calendarsync.CalendarSyncDialogType -import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEvent import org.openedx.core.system.notifier.CourseDatesShifted @@ -38,13 +35,16 @@ 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.core.utils.FileUtil 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.presentation.download.DownloadDialogManager +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager +import org.openedx.foundation.utils.FileUtil class CourseOutlineViewModel( val courseId: String, 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 2aee3cbc5..7a08bd9b0 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 @@ -55,27 +55,27 @@ import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.core.BlockType -import org.openedx.core.UIMessage import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts -import org.openedx.core.extension.serializable import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType 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.appShapes import org.openedx.core.ui.theme.appTypography -import org.openedx.core.ui.windowSizeValue import org.openedx.course.R import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.ui.CardArrow +import org.openedx.foundation.extension.serializable +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue import java.util.Date import org.openedx.core.R as CoreR @@ -337,16 +337,6 @@ private fun CourseSubsectionItem( } } -private fun getUnitBlockIcon(block: Block): Int { - return when (block.descendantsType) { - BlockType.VIDEO -> R.drawable.ic_course_video - BlockType.PROBLEM -> R.drawable.ic_course_pen - BlockType.DISCUSSION -> R.drawable.ic_course_discussion - else -> R.drawable.ic_course_block - } -} - - @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable diff --git a/course/src/main/java/org/openedx/course/presentation/section/CourseSectionViewModel.kt b/course/src/main/java/org/openedx/course/presentation/section/CourseSectionViewModel.kt index 7f12a314f..d760620af 100644 --- a/course/src/main/java/org/openedx/course/presentation/section/CourseSectionViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/section/CourseSectionViewModel.kt @@ -5,21 +5,21 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.launch -import org.openedx.core.BaseViewModel import org.openedx.core.BlockType import org.openedx.core.R -import org.openedx.core.SingleEventLiveData -import org.openedx.core.UIMessage import org.openedx.core.domain.model.Block -import org.openedx.core.extension.isInternetError import org.openedx.core.presentation.course.CourseViewMode -import org.openedx.core.system.ResourceManager import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseSectionChanged 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.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.SingleEventLiveData +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager class CourseSectionViewModel( val courseId: String, 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 780a7361d..6927c0106 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 @@ -84,8 +84,6 @@ import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts import org.openedx.core.domain.model.CourseDatesBannerInfo -import org.openedx.core.extension.nonZero -import org.openedx.core.extension.toFileSize import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.db.FileType @@ -103,6 +101,8 @@ import org.openedx.core.utils.TimeUtils import org.openedx.course.R import org.openedx.course.presentation.dates.mockedCourseBannerInfo import org.openedx.course.presentation.outline.getUnitBlockIcon +import org.openedx.foundation.extension.nonZero +import org.openedx.foundation.extension.toFileSize import subtitleFile.Caption import subtitleFile.TimedTextObject import java.util.Date @@ -238,7 +238,7 @@ fun OfflineQueueCard( maxLines = 1 ) Text( - text = downloadModel.size.toLong().toFileSize(), + text = downloadModel.size.toFileSize(), style = MaterialTheme.appTypography.titleSmall, color = MaterialTheme.appColors.textSecondary, overflow = TextOverflow.Ellipsis, @@ -758,7 +758,7 @@ fun CourseSubSectionItem( val due by rememberSaveable { mutableStateOf(block.due?.let { TimeUtils.formatToString(context, it, useRelativeDates) } ?: "") } - val isAssignmentEnable = !block.isCompleted() && block.assignmentProgress != null && !due.isNullOrEmpty() + val isAssignmentEnable = !block.isCompleted() && block.assignmentProgress != null && due.isNotEmpty() Column( modifier = modifier .fillMaxWidth() 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 5fd4ea981..f8bcd7355 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 @@ -51,10 +51,10 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentManager +import org.koin.compose.koinInject import org.openedx.core.AppDataConstants import org.openedx.core.BlockType import org.openedx.core.NoContentScreenType -import org.openedx.core.UIMessage import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts @@ -62,24 +62,25 @@ import org.openedx.core.domain.model.CourseStructure import org.openedx.core.domain.model.CoursewareAccess import org.openedx.core.domain.model.Progress import org.openedx.core.domain.model.VideoSettings -import org.openedx.core.extension.toFileSize import org.openedx.core.module.download.DownloadModelsSize import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.presentation.settings.video.VideoQualityType import org.openedx.core.ui.CircularProgress import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.NoContentScreen -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography -import org.openedx.core.ui.windowSizeValue -import org.openedx.core.utils.FileUtil import org.openedx.course.R import org.openedx.course.presentation.videos.CourseVideoViewModel import org.openedx.course.presentation.videos.CourseVideosUIState +import org.openedx.foundation.extension.toFileSize +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.windowSizeValue +import org.openedx.foundation.utils.FileUtil import java.util.Date @Composable @@ -92,6 +93,7 @@ fun CourseVideosScreen( val uiMessage by viewModel.uiMessage.collectAsState(null) val videoSettings by viewModel.videoSettings.collectAsState() val context = LocalContext.current + val fileUtil: FileUtil = koinInject() CourseVideosUI( windowSize = windowSize, @@ -137,7 +139,7 @@ fun CourseVideosScreen( viewModel.removeAllDownloadModels() } else { viewModel.saveAllDownloadModels( - FileUtil(context).getExternalAppDir().path + fileUtil.getExternalAppDir().path ) } }, diff --git a/course/src/main/java/org/openedx/course/presentation/unit/NotAvailableUnitFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/NotAvailableUnitFragment.kt index b29a7ac8f..b983822b2 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/NotAvailableUnitFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/NotAvailableUnitFragment.kt @@ -37,14 +37,14 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.core.os.bundleOf import androidx.fragment.app.Fragment -import org.openedx.core.extension.parcelable -import org.openedx.core.ui.WindowSize -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.ui.windowSizeValue +import org.openedx.foundation.extension.parcelable +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue import org.openedx.course.R as courseR class NotAvailableUnitFragment : Fragment() { diff --git a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt index 1bc26e1a4..d8870914a 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt @@ -31,7 +31,6 @@ import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.core.BlockType -import org.openedx.core.extension.serializable import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.presentation.global.InsetHolder import org.openedx.core.ui.theme.OpenEdXTheme @@ -47,6 +46,7 @@ import org.openedx.course.presentation.ui.NavigationUnitsButtons import org.openedx.course.presentation.ui.SubSectionUnitsList import org.openedx.course.presentation.ui.SubSectionUnitsTitle import org.openedx.course.presentation.ui.VerticalPageIndicator +import org.openedx.foundation.extension.serializable class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_container) { diff --git a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt index 20c0c7c3c..5a4cb0393 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt @@ -9,12 +9,9 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import org.openedx.core.BaseViewModel import org.openedx.core.BlockType import org.openedx.core.config.Config import org.openedx.core.domain.model.Block -import org.openedx.core.extension.clearAndAddAll -import org.openedx.core.extension.indexOfFirstFromIndex import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadedState import org.openedx.core.presentation.course.CourseViewMode @@ -26,6 +23,9 @@ 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.foundation.extension.clearAndAddAll +import org.openedx.foundation.extension.indexOfFirstFromIndex +import org.openedx.foundation.presentation.BaseViewModel class CourseUnitContainerViewModel( val courseId: String, diff --git a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt index e342f1f06..7bf313ac8 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt @@ -52,19 +52,19 @@ import androidx.fragment.app.Fragment import kotlinx.coroutines.launch import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf -import org.openedx.core.extension.applyDarkModeIfEnabled import org.openedx.core.extension.equalsHost -import org.openedx.core.extension.isEmailValid import org.openedx.core.extension.loadUrl import org.openedx.core.system.AppCookieManager import org.openedx.core.ui.FullScreenErrorView -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.roundBorderWithoutBottom import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors -import org.openedx.core.ui.windowSizeValue import org.openedx.core.utils.EmailUtil +import org.openedx.foundation.extension.applyDarkModeIfEnabled +import org.openedx.foundation.extension.isEmailValid +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue class HtmlUnitFragment : Fragment() { diff --git a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitViewModel.kt index bccdcd0fd..ca79ce90b 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitViewModel.kt @@ -4,13 +4,9 @@ import android.content.res.AssetManager import androidx.lifecycle.viewModelScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import org.openedx.core.BaseViewModel import org.openedx.core.config.Config -import org.openedx.core.extension.readAsText import org.openedx.core.presentation.global.ErrorType import org.openedx.core.system.AppCookieManager import org.openedx.core.system.connection.NetworkConnection @@ -18,6 +14,8 @@ import org.openedx.core.system.notifier.CourseCompletionSet import org.openedx.core.system.notifier.CourseNotifier import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.worker.OfflineProgressSyncScheduler +import org.openedx.foundation.extension.readAsText +import org.openedx.foundation.presentation.BaseViewModel class HtmlUnitViewModel( private val blockId: String, @@ -92,6 +90,7 @@ class HtmlUnitViewModel( courseInteractor.submitOfflineXBlockProgress(blockId, courseId) } } catch (e: Exception) { + e.printStackTrace() } finally { _uiState.value = HtmlUnitUIState.Loading } diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/BaseVideoViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/BaseVideoViewModel.kt index 96d285223..7c67329e6 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/BaseVideoViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/BaseVideoViewModel.kt @@ -1,9 +1,9 @@ package org.openedx.course.presentation.unit.video -import org.openedx.core.BaseViewModel import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseAnalyticsEvent import org.openedx.course.presentation.CourseAnalyticsKey +import org.openedx.foundation.presentation.BaseViewModel open class BaseVideoViewModel( private val courseId: String, diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoFullScreenFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoFullScreenFragment.kt index 3caa4d7c6..7bbf0bd25 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoFullScreenFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoFullScreenFragment.kt @@ -27,12 +27,12 @@ import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.core.domain.model.VideoQuality -import org.openedx.core.extension.requestApplyInsetsWhenAttached import org.openedx.core.presentation.dialog.appreview.AppReviewManager import org.openedx.core.presentation.global.viewBinding import org.openedx.course.R import org.openedx.course.databinding.FragmentVideoFullScreenBinding import org.openedx.course.presentation.CourseAnalyticsKey +import org.openedx.foundation.extension.requestApplyInsetsWhenAttached class VideoFullScreenFragment : Fragment(R.layout.fragment_video_full_screen) { diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitFragment.kt index 49431ba46..0cc44dac3 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitFragment.kt @@ -23,15 +23,10 @@ import androidx.window.layout.WindowMetricsCalculator import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf -import org.openedx.core.extension.computeWindowSizeClasses -import org.openedx.core.extension.dpToPixel -import org.openedx.core.extension.objectToString -import org.openedx.core.extension.stringToObject import org.openedx.core.presentation.dialog.appreview.AppReviewManager import org.openedx.core.presentation.dialog.selectorbottomsheet.SelectBottomDialogFragment import org.openedx.core.presentation.global.viewBinding import org.openedx.core.ui.ConnectionErrorView -import org.openedx.core.ui.WindowSize import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.utils.LocaleUtils import org.openedx.course.R @@ -41,6 +36,11 @@ import org.openedx.course.presentation.CourseAnalyticsKey import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.ui.VideoSubtitles import org.openedx.course.presentation.ui.VideoTitle +import org.openedx.foundation.extension.computeWindowSizeClasses +import org.openedx.foundation.extension.dpToPixel +import org.openedx.foundation.extension.objectToString +import org.openedx.foundation.extension.stringToObject +import org.openedx.foundation.presentation.WindowSize import kotlin.math.roundToInt class VideoUnitFragment : Fragment(R.layout.fragment_video_unit) { diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoFullScreenFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoFullScreenFragment.kt index f62659c26..397c36baf 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoFullScreenFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoFullScreenFragment.kt @@ -16,12 +16,12 @@ import com.pierfrancescosoffritti.androidyoutubeplayer.core.ui.DefaultPlayerUiCo import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf -import org.openedx.core.extension.requestApplyInsetsWhenAttached import org.openedx.core.presentation.dialog.appreview.AppReviewManager import org.openedx.core.presentation.global.viewBinding import org.openedx.course.R import org.openedx.course.databinding.FragmentYoutubeVideoFullScreenBinding import org.openedx.course.presentation.CourseAnalyticsKey +import org.openedx.foundation.extension.requestApplyInsetsWhenAttached class YoutubeVideoFullScreenFragment : Fragment(R.layout.fragment_youtube_video_full_screen) { diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoUnitFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoUnitFragment.kt index b163a7cde..58aaaf377 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoUnitFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoUnitFragment.kt @@ -20,13 +20,9 @@ import com.pierfrancescosoffritti.androidyoutubeplayer.core.ui.DefaultPlayerUiCo import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf -import org.openedx.core.extension.computeWindowSizeClasses -import org.openedx.core.extension.objectToString -import org.openedx.core.extension.stringToObject import org.openedx.core.presentation.dialog.appreview.AppReviewManager import org.openedx.core.presentation.dialog.selectorbottomsheet.SelectBottomDialogFragment import org.openedx.core.ui.ConnectionErrorView -import org.openedx.core.ui.WindowSize import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.utils.LocaleUtils import org.openedx.course.R @@ -35,6 +31,10 @@ import org.openedx.course.presentation.CourseAnalyticsKey import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.ui.VideoSubtitles import org.openedx.course.presentation.ui.VideoTitle +import org.openedx.foundation.extension.computeWindowSizeClasses +import org.openedx.foundation.extension.objectToString +import org.openedx.foundation.extension.stringToObject +import org.openedx.foundation.presentation.WindowSize class YoutubeVideoUnitFragment : Fragment(R.layout.fragment_youtube_video_unit) { 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 a02eac54c..3d197859f 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 @@ -10,7 +10,6 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.openedx.core.BlockType -import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Block @@ -20,19 +19,20 @@ import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.download.BaseDownloadViewModel import org.openedx.core.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics -import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseStructureUpdated import org.openedx.core.system.notifier.VideoNotifier import org.openedx.core.system.notifier.VideoQualityChanged -import org.openedx.core.utils.FileUtil import org.openedx.course.R import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.download.DownloadDialogManager +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager +import org.openedx.foundation.utils.FileUtil class CourseVideoViewModel( val courseId: String, diff --git a/course/src/main/java/org/openedx/course/settings/download/DownloadQueueFragment.kt b/course/src/main/java/org/openedx/course/settings/download/DownloadQueueFragment.kt index 3db1ee158..ceea27806 100644 --- a/course/src/main/java/org/openedx/course/settings/download/DownloadQueueFragment.kt +++ b/course/src/main/java/org/openedx/course/settings/download/DownloadQueueFragment.kt @@ -47,18 +47,18 @@ import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.db.FileType import org.openedx.core.ui.BackBtn -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType 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.appShapes import org.openedx.core.ui.theme.appTypography -import org.openedx.core.ui.windowSizeValue import org.openedx.course.R import org.openedx.course.presentation.ui.OfflineQueueCard +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue class DownloadQueueFragment : Fragment() { diff --git a/core/src/main/java/org/openedx/core/ImageProcessor.kt b/course/src/main/java/org/openedx/course/utils/ImageProcessor.kt similarity index 98% rename from core/src/main/java/org/openedx/core/ImageProcessor.kt rename to course/src/main/java/org/openedx/course/utils/ImageProcessor.kt index d3a6c4a4c..b83f4a5e5 100644 --- a/core/src/main/java/org/openedx/core/ImageProcessor.kt +++ b/course/src/main/java/org/openedx/course/utils/ImageProcessor.kt @@ -1,6 +1,6 @@ @file:Suppress("DEPRECATION") -package org.openedx.core +package org.openedx.course.utils import android.content.Context import android.graphics.Bitmap 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 63ad22b05..5f9f19756 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 @@ -22,7 +22,6 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule -import org.openedx.core.ImageProcessor import org.openedx.core.R import org.openedx.core.config.Config import org.openedx.core.data.api.CourseApi @@ -33,7 +32,6 @@ import org.openedx.core.domain.model.AppConfig import org.openedx.core.domain.model.CourseDatesCalendarSync import org.openedx.core.domain.model.CourseStructure import org.openedx.core.domain.model.CoursewareAccess -import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseStructureUpdated @@ -42,6 +40,8 @@ import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseAnalyticsEvent import org.openedx.course.presentation.CourseRouter +import org.openedx.course.utils.ImageProcessor +import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException import java.util.Date 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 389196f31..f9392df33 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 @@ -25,7 +25,6 @@ import org.junit.Test import org.junit.rules.TestRule import org.openedx.core.CalendarRouter import org.openedx.core.R -import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.data.model.DateType import org.openedx.core.data.storage.CorePreferences @@ -37,7 +36,6 @@ import org.openedx.core.domain.model.CourseDatesResult import org.openedx.core.domain.model.CourseStructure import org.openedx.core.domain.model.CoursewareAccess import org.openedx.core.domain.model.DatesSection -import org.openedx.core.system.ResourceManager import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEvent import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier @@ -47,6 +45,8 @@ import org.openedx.core.system.notifier.calendar.CalendarSynced import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseRouter +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException import java.util.Date 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 679dfedc9..a9ea6c5e9 100644 --- a/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt @@ -29,7 +29,6 @@ import org.junit.Test import org.junit.rules.TestRule import org.openedx.core.BlockType import org.openedx.core.R -import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.data.model.DateType import org.openedx.core.data.storage.CorePreferences @@ -52,15 +51,16 @@ import org.openedx.core.module.db.FileType import org.openedx.core.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.presentation.CoreAnalyticsEvent -import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseStructureUpdated -import org.openedx.core.utils.FileUtil import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.download.DownloadDialogManager +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager +import org.openedx.foundation.utils.FileUtil import java.net.UnknownHostException import java.util.Date diff --git a/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt index 45ff2a72f..e1c6a98ca 100644 --- a/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt @@ -25,7 +25,6 @@ import org.junit.Test import org.junit.rules.TestRule import org.openedx.core.BlockType import org.openedx.core.R -import org.openedx.core.UIMessage import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block @@ -41,11 +40,12 @@ import org.openedx.core.module.db.FileType import org.openedx.core.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.presentation.course.CourseViewMode -import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException import java.util.Date 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 812962c83..e5df7e948 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 @@ -29,7 +29,6 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule import org.openedx.core.BlockType -import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.AssignmentProgress @@ -46,18 +45,19 @@ import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.db.FileType import org.openedx.core.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics -import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseStructureUpdated import org.openedx.core.system.notifier.VideoNotifier -import org.openedx.core.utils.FileUtil import org.openedx.course.R import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.download.DownloadDialogManager +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager +import org.openedx.foundation.utils.FileUtil import java.util.Date @OptIn(ExperimentalCoroutinesApi::class) diff --git a/dashboard/build.gradle b/dashboard/build.gradle index 2fea01174..13119287f 100644 --- a/dashboard/build.gradle +++ b/dashboard/build.gradle @@ -1,6 +1,7 @@ plugins { id 'com.android.library' id 'org.jetbrains.kotlin.android' + id "org.jetbrains.kotlin.plugin.compose" } android { @@ -36,9 +37,6 @@ android { viewBinding true compose true } - composeOptions { - kotlinCompilerExtensionVersion = "$compose_compiler_version" - } flavorDimensions += "env" productFlavors { @@ -57,13 +55,10 @@ android { dependencies { implementation project(path: ':core') - androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' - androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version" - debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version" + androidTestImplementation 'androidx.test.ext:junit:1.2.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' testImplementation "junit:junit:$junit_version" testImplementation "io.mockk:mockk:$mockk_version" testImplementation "io.mockk:mockk-android:$mockk_version" - testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version" testImplementation "androidx.arch.core:core-testing:$android_arch_version" } \ No newline at end of file diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt index 7bd060bb5..e6ca810a1 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt @@ -73,7 +73,6 @@ 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 @@ -82,22 +81,23 @@ 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.extension.toImageLink 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 org.openedx.foundation.extension.toImageLink +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue import java.util.Date @Composable diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesViewModel.kt b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesViewModel.kt index d52475eca..9c6129623 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesViewModel.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesViewModel.kt @@ -11,13 +11,9 @@ 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 @@ -25,6 +21,10 @@ import org.openedx.dashboard.domain.CourseStatusFilter import org.openedx.dashboard.domain.interactor.DashboardInteractor import org.openedx.dashboard.presentation.DashboardAnalytics import org.openedx.dashboard.presentation.DashboardRouter +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager class AllEnrolledCoursesViewModel( private val config: Config, diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt index 493f81a00..40e2b0318 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt @@ -71,7 +71,6 @@ import coil.request.ImageRequest import org.koin.androidx.compose.koinViewModel import org.koin.core.parameter.parametersOf 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 @@ -86,18 +85,19 @@ 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.extension.toImageLink 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 org.openedx.foundation.extension.toImageLink +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.rememberWindowSize import java.util.Date import org.openedx.core.R as CoreR diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt index 2c6ba0ccb..cf36699f1 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt @@ -9,23 +9,23 @@ 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.data.storage.CorePreferences 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.ui.WindowSize -import org.openedx.core.utils.FileUtil import org.openedx.dashboard.domain.interactor.DashboardInteractor import org.openedx.dashboard.presentation.DashboardRouter +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.system.ResourceManager +import org.openedx.foundation.utils.FileUtil class DashboardGalleryViewModel( private val config: Config, 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 a65ca8d07..17c41e07d 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 @@ -5,9 +5,9 @@ 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 +import org.openedx.foundation.utils.FileUtil class DashboardRepository( private val api: CourseApi, diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt index eea1b9db1..3ab2d7555 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt @@ -75,7 +75,6 @@ import coil.request.ImageRequest import org.koin.android.ext.android.inject 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 @@ -84,23 +83,24 @@ 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.extension.toImageLink import org.openedx.core.presentation.global.app_upgrade.AppUpgradeRecommendedBox import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OfflineModeDialog -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType import org.openedx.core.ui.displayCutoutForLandscape -import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.shouldLoadMore 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.R +import org.openedx.foundation.extension.toImageLink +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue import java.util.Date import org.openedx.core.R as CoreR diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListViewModel.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListViewModel.kt index bfafc81c4..e04ddb258 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListViewModel.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListViewModel.kt @@ -5,20 +5,20 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.launch -import org.openedx.core.BaseViewModel import org.openedx.core.R -import org.openedx.core.SingleEventLiveData -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.app.AppUpgradeEvent import org.openedx.core.system.notifier.CourseDashboardUpdate import org.openedx.core.system.notifier.DiscoveryNotifier import org.openedx.core.system.notifier.app.AppNotifier +import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.dashboard.domain.interactor.DashboardInteractor +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.SingleEventLiveData +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager class DashboardListViewModel( private val config: Config, diff --git a/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt b/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt index b1f4bbbb7..a0e304170 100644 --- a/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt +++ b/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt @@ -46,14 +46,14 @@ 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.dashboard.R import org.openedx.dashboard.databinding.FragmentLearnBinding +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue import org.openedx.learn.LearnType import org.openedx.core.R as CoreR diff --git a/dashboard/src/main/java/org/openedx/learn/presentation/LearnViewModel.kt b/dashboard/src/main/java/org/openedx/learn/presentation/LearnViewModel.kt index 9a2d17f1e..ee38caf75 100644 --- a/dashboard/src/main/java/org/openedx/learn/presentation/LearnViewModel.kt +++ b/dashboard/src/main/java/org/openedx/learn/presentation/LearnViewModel.kt @@ -2,12 +2,12 @@ package org.openedx.learn.presentation import androidx.fragment.app.FragmentManager import org.openedx.DashboardNavigator -import org.openedx.core.BaseViewModel import org.openedx.core.config.Config import org.openedx.dashboard.presentation.DashboardAnalytics import org.openedx.dashboard.presentation.DashboardAnalyticsEvent import org.openedx.dashboard.presentation.DashboardAnalyticsKey import org.openedx.dashboard.presentation.DashboardRouter +import org.openedx.foundation.presentation.BaseViewModel class LearnViewModel( private val config: Config, diff --git a/dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardListViewModelTest.kt b/dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardListViewModelTest.kt index 216d8ecbf..1eb943cca 100644 --- a/dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardListViewModelTest.kt +++ b/dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardListViewModelTest.kt @@ -25,16 +25,16 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule import org.openedx.core.R -import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.domain.model.DashboardCourseList import org.openedx.core.domain.model.Pagination -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.app.AppNotifier import org.openedx.dashboard.domain.interactor.DashboardInteractor +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException @OptIn(ExperimentalCoroutinesApi::class) diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index 19c1e600a..df9d1f401 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -33,17 +33,12 @@ DASHBOARD: FIREBASE: ENABLED: false - ANALYTICS_SOURCE: '' # segment | none CLOUD_MESSAGING_ENABLED: false PROJECT_NUMBER: '' PROJECT_ID: '' APPLICATION_ID: '' #App ID field from the Firebase console or mobilesdk_app_id from the google-services.json file. API_KEY: '' -SEGMENT_IO: - ENABLED: false - SEGMENT_IO_WRITE_KEY: '' - BRAZE: ENABLED: false PUSH_NOTIFICATIONS_ENABLED: false @@ -69,10 +64,6 @@ BRANCH: HOST: '' ALTERNATE_HOST: '' -FULLSTORY: - ENABLED: false - ORG_ID: '' - #Platform names PLATFORM_NAME: "OpenEdX" PLATFORM_FULL_NAME: "OpenEdX" @@ -90,4 +81,4 @@ REGISTRATION_ENABLED: true UI_COMPONENTS: COURSE_DROPDOWN_NAVIGATION_ENABLED: false COURSE_UNIT_PROGRESS_ENABLED: false - COURSE_DOWNLOAD_QUEUE_SCREEN: false + COURSE_DOWNLOAD_QUEUE_SCREEN: false \ No newline at end of file diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml index 19c1e600a..19e53ef73 100644 --- a/default_config/prod/config.yaml +++ b/default_config/prod/config.yaml @@ -33,17 +33,12 @@ DASHBOARD: FIREBASE: ENABLED: false - ANALYTICS_SOURCE: '' # segment | none CLOUD_MESSAGING_ENABLED: false PROJECT_NUMBER: '' PROJECT_ID: '' APPLICATION_ID: '' #App ID field from the Firebase console or mobilesdk_app_id from the google-services.json file. API_KEY: '' -SEGMENT_IO: - ENABLED: false - SEGMENT_IO_WRITE_KEY: '' - BRAZE: ENABLED: false PUSH_NOTIFICATIONS_ENABLED: false @@ -69,10 +64,6 @@ BRANCH: HOST: '' ALTERNATE_HOST: '' -FULLSTORY: - ENABLED: false - ORG_ID: '' - #Platform names PLATFORM_NAME: "OpenEdX" PLATFORM_FULL_NAME: "OpenEdX" diff --git a/default_config/stage/config.yaml b/default_config/stage/config.yaml index 19c1e600a..19e53ef73 100644 --- a/default_config/stage/config.yaml +++ b/default_config/stage/config.yaml @@ -33,17 +33,12 @@ DASHBOARD: FIREBASE: ENABLED: false - ANALYTICS_SOURCE: '' # segment | none CLOUD_MESSAGING_ENABLED: false PROJECT_NUMBER: '' PROJECT_ID: '' APPLICATION_ID: '' #App ID field from the Firebase console or mobilesdk_app_id from the google-services.json file. API_KEY: '' -SEGMENT_IO: - ENABLED: false - SEGMENT_IO_WRITE_KEY: '' - BRAZE: ENABLED: false PUSH_NOTIFICATIONS_ENABLED: false @@ -69,10 +64,6 @@ BRANCH: HOST: '' ALTERNATE_HOST: '' -FULLSTORY: - ENABLED: false - ORG_ID: '' - #Platform names PLATFORM_NAME: "OpenEdX" PLATFORM_FULL_NAME: "OpenEdX" diff --git a/discovery/build.gradle b/discovery/build.gradle index 5e6e1887b..d9c4419fc 100644 --- a/discovery/build.gradle +++ b/discovery/build.gradle @@ -2,7 +2,8 @@ plugins { id 'com.android.library' id 'org.jetbrains.kotlin.android' id 'kotlin-parcelize' - id 'kotlin-kapt' + id 'com.google.devtools.ksp' + id "org.jetbrains.kotlin.plugin.compose" } android { @@ -38,9 +39,6 @@ android { viewBinding true compose true } - composeOptions { - kotlinCompilerExtensionVersion = "$compose_compiler_version" - } flavorDimensions += "env" productFlavors { @@ -59,17 +57,14 @@ android { dependencies { implementation project(path: ':core') - kapt "androidx.room:room-compiler:$room_version" + ksp "androidx.room:room-compiler:$room_version" implementation 'androidx.activity:activity-compose:1.8.1' - androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' - androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version" - debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version" + androidTestImplementation 'androidx.test.ext:junit:1.2.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' testImplementation "junit:junit:$junit_version" testImplementation "io.mockk:mockk:$mockk_version" testImplementation "io.mockk:mockk-android:$mockk_version" - testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version" testImplementation "androidx.arch.core:core-testing:$android_arch_version" } diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt index 1e9c6663f..5efb7a5b0 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt @@ -59,7 +59,6 @@ import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.openedx.core.AppUpdateState import org.openedx.core.AppUpdateState.wasUpdateDialogClosed -import org.openedx.core.UIMessage import org.openedx.core.domain.model.Media import org.openedx.core.presentation.dialog.appupgrade.AppUpgradeDialogFragment import org.openedx.core.presentation.global.app_upgrade.AppUpgradeRecommendedBox @@ -70,19 +69,20 @@ import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OfflineModeDialog import org.openedx.core.ui.StaticSearchBar import org.openedx.core.ui.Toolbar -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType 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.appTypography -import org.openedx.core.ui.windowSizeValue import org.openedx.discovery.R import org.openedx.discovery.domain.model.Course import org.openedx.discovery.presentation.ui.DiscoveryCourseItem +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue class NativeDiscoveryFragment : Fragment() { diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryViewModel.kt b/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryViewModel.kt index 19bfb21f9..923846e8a 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryViewModel.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryViewModel.kt @@ -6,19 +6,19 @@ import androidx.lifecycle.viewModelScope import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.launch -import org.openedx.core.BaseViewModel import org.openedx.core.R -import org.openedx.core.SingleEventLiveData -import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences -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.app.AppNotifier import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.discovery.domain.interactor.DiscoveryInteractor import org.openedx.discovery.domain.model.Course +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.SingleEventLiveData +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager class NativeDiscoveryViewModel( private val config: Config, diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryFragment.kt index cf67d1a2c..72ce6126c 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryFragment.kt @@ -59,17 +59,17 @@ import org.openedx.core.presentation.global.webview.WebViewUIState import org.openedx.core.ui.AuthButtonsPanel import org.openedx.core.ui.FullScreenErrorView import org.openedx.core.ui.Toolbar -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType 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.windowSizeValue import org.openedx.discovery.R import org.openedx.discovery.presentation.catalog.CatalogWebViewScreen import org.openedx.discovery.presentation.catalog.WebViewLink +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue import org.openedx.core.R as CoreR class WebViewDiscoveryFragment : Fragment() { diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryViewModel.kt b/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryViewModel.kt index a8f8cfc45..2cb7afd69 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryViewModel.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryViewModel.kt @@ -4,13 +4,13 @@ import androidx.fragment.app.FragmentManager import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import org.openedx.core.BaseViewModel import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.presentation.global.ErrorType import org.openedx.core.presentation.global.webview.WebViewUIState import org.openedx.core.system.connection.NetworkConnection -import org.openedx.core.utils.UrlUtils +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.utils.UrlUtils class WebViewDiscoveryViewModel( private val querySearch: String, diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/catalog/CatalogWebView.kt b/discovery/src/main/java/org/openedx/discovery/presentation/catalog/CatalogWebView.kt index 785e77767..aaac503a3 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/catalog/CatalogWebView.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/catalog/CatalogWebView.kt @@ -8,7 +8,7 @@ import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext -import org.openedx.core.extension.applyDarkModeIfEnabled +import org.openedx.foundation.extension.applyDarkModeIfEnabled import org.openedx.core.extension.equalsHost import org.openedx.discovery.presentation.catalog.WebViewLink.Authority as linkAuthority diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/catalog/DefaultWebViewClient.kt b/discovery/src/main/java/org/openedx/discovery/presentation/catalog/DefaultWebViewClient.kt index 9cf94ecda..a039cab2f 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/catalog/DefaultWebViewClient.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/catalog/DefaultWebViewClient.kt @@ -7,8 +7,8 @@ import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse import android.webkit.WebView import android.webkit.WebViewClient -import org.openedx.core.extension.isEmailValid import org.openedx.core.utils.EmailUtil +import org.openedx.foundation.extension.isEmailValid open class DefaultWebViewClient( val context: Context, diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/catalog/WebViewLink.kt b/discovery/src/main/java/org/openedx/discovery/presentation/catalog/WebViewLink.kt index a467707ce..a482fc581 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/catalog/WebViewLink.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/catalog/WebViewLink.kt @@ -1,7 +1,7 @@ package org.openedx.discovery.presentation.catalog import android.net.Uri -import org.openedx.core.extension.getQueryParams +import org.openedx.foundation.extension.getQueryParams /** * To parse and store links that we need within a WebView. diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt index 32c795c56..4bf51b23b 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt @@ -79,31 +79,31 @@ import androidx.fragment.app.Fragment import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf -import org.openedx.core.UIMessage import org.openedx.core.domain.model.Media -import org.openedx.core.extension.applyDarkModeIfEnabled -import org.openedx.core.extension.isEmailValid import org.openedx.core.ui.AuthButtonsPanel import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OfflineModeDialog import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.Toolbar -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.isPreview -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.core.utils.EmailUtil import org.openedx.discovery.R import org.openedx.discovery.domain.model.Course import org.openedx.discovery.presentation.DiscoveryRouter import org.openedx.discovery.presentation.ui.ImageHeader import org.openedx.discovery.presentation.ui.WarningLabel +import org.openedx.foundation.extension.applyDarkModeIfEnabled +import org.openedx.foundation.extension.isEmailValid +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue import java.nio.charset.StandardCharsets import java.util.Date import org.openedx.core.R as CoreR diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModel.kt b/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModel.kt index 81b36e651..b512ea99e 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModel.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModel.kt @@ -4,14 +4,9 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.launch -import org.openedx.core.BaseViewModel import org.openedx.core.R -import org.openedx.core.SingleEventLiveData -import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences -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 @@ -21,6 +16,11 @@ import org.openedx.discovery.domain.model.Course import org.openedx.discovery.presentation.DiscoveryAnalytics import org.openedx.discovery.presentation.DiscoveryAnalyticsEvent import org.openedx.discovery.presentation.DiscoveryAnalyticsKey +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.SingleEventLiveData +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager class CourseDetailsViewModel( val courseId: String, diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoFragment.kt index 4906e91f8..3c6cb6c31 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoFragment.kt @@ -42,7 +42,6 @@ import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf -import org.openedx.core.UIMessage import org.openedx.core.presentation.dialog.alert.ActionDialogFragment import org.openedx.core.presentation.dialog.alert.InfoDialogFragment import org.openedx.core.presentation.global.webview.WebViewUIAction @@ -51,18 +50,19 @@ import org.openedx.core.ui.AuthButtonsPanel import org.openedx.core.ui.FullScreenErrorView import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.Toolbar -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType 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.windowSizeValue import org.openedx.discovery.R import org.openedx.discovery.presentation.DiscoveryAnalyticsScreen import org.openedx.discovery.presentation.catalog.CatalogWebViewScreen import org.openedx.discovery.presentation.catalog.WebViewLink +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue import java.util.concurrent.atomic.AtomicReference import org.openedx.core.R as CoreR import org.openedx.discovery.presentation.catalog.WebViewLink.Authority as linkAuthority diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoViewModel.kt b/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoViewModel.kt index 65907f5cf..d5a935df3 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoViewModel.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoViewModel.kt @@ -12,15 +12,11 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.openedx.core.BaseViewModel -import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences -import org.openedx.core.extension.isInternetError import org.openedx.core.presentation.CoreAnalyticsKey import org.openedx.core.presentation.global.ErrorType import org.openedx.core.presentation.global.webview.WebViewUIState -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 @@ -31,6 +27,10 @@ import org.openedx.discovery.presentation.DiscoveryAnalyticsEvent import org.openedx.discovery.presentation.DiscoveryAnalyticsKey import org.openedx.discovery.presentation.DiscoveryRouter import org.openedx.discovery.presentation.catalog.WebViewLink +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import java.util.concurrent.atomic.AtomicReference import org.openedx.core.R as CoreR 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 85809a9fb..07e59dc11 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 @@ -46,8 +46,6 @@ import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel import org.koin.androidx.viewmodel.ext.android.viewModel import org.openedx.core.extension.loadUrl -import org.openedx.core.extension.takeIfNotEmpty -import org.openedx.core.extension.toastMessage import org.openedx.core.presentation.dialog.alert.ActionDialogFragment import org.openedx.core.presentation.dialog.alert.InfoDialogFragment import org.openedx.core.presentation.global.webview.WebViewUIAction @@ -55,18 +53,20 @@ import org.openedx.core.system.AppCookieManager import org.openedx.core.ui.FullScreenErrorView import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.Toolbar -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType 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.windowSizeValue import org.openedx.discovery.R import org.openedx.discovery.presentation.DiscoveryAnalyticsScreen import org.openedx.discovery.presentation.catalog.CatalogWebViewScreen import org.openedx.discovery.presentation.catalog.WebViewLink +import org.openedx.foundation.extension.takeIfNotEmpty +import org.openedx.foundation.extension.toastMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue import org.openedx.core.R as coreR import org.openedx.discovery.presentation.catalog.WebViewLink.Authority as linkAuthority diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramUIState.kt b/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramUIState.kt index bed418100..b4ad7341d 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramUIState.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramUIState.kt @@ -1,6 +1,6 @@ package org.openedx.discovery.presentation.program -import org.openedx.core.UIMessage +import org.openedx.foundation.presentation.UIMessage import org.openedx.core.presentation.global.ErrorType sealed class ProgramUIState { diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramViewModel.kt b/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramViewModel.kt index 59a26cba5..2263861bf 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramViewModel.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramViewModel.kt @@ -6,20 +6,20 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow 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.extension.isInternetError import org.openedx.core.presentation.global.ErrorType import org.openedx.core.system.AppCookieManager -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.discovery.domain.interactor.DiscoveryInteractor import org.openedx.discovery.presentation.DiscoveryRouter +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager class ProgramViewModel( private val config: Config, diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/search/CourseSearchFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/search/CourseSearchFragment.kt index 72a5aa909..fc2af30a6 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/search/CourseSearchFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/search/CourseSearchFragment.kt @@ -63,24 +63,24 @@ import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel -import org.openedx.core.UIMessage import org.openedx.core.domain.model.Media import org.openedx.core.ui.AuthButtonsPanel import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.SearchBar -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType -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.appTypography -import org.openedx.core.ui.windowSizeValue import org.openedx.discovery.domain.model.Course import org.openedx.discovery.presentation.DiscoveryRouter import org.openedx.discovery.presentation.ui.DiscoveryCourseItem +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue import org.openedx.discovery.R as discoveryR class CourseSearchFragment : Fragment() { diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/search/CourseSearchViewModel.kt b/discovery/src/main/java/org/openedx/discovery/presentation/search/CourseSearchViewModel.kt index 8acbe0e1c..d1ae276d8 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/search/CourseSearchViewModel.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/search/CourseSearchViewModel.kt @@ -8,17 +8,17 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.launch -import org.openedx.core.BaseViewModel import org.openedx.core.R -import org.openedx.core.SingleEventLiveData -import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences -import org.openedx.core.extension.isInternetError -import org.openedx.core.system.ResourceManager import org.openedx.discovery.domain.interactor.DiscoveryInteractor import org.openedx.discovery.domain.model.Course import org.openedx.discovery.presentation.DiscoveryAnalytics +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.SingleEventLiveData +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager class CourseSearchViewModel( private val config: Config, diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/ui/DiscoveryUI.kt b/discovery/src/main/java/org/openedx/discovery/presentation/ui/DiscoveryUI.kt index 4ce446e31..5413543c9 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/ui/DiscoveryUI.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/ui/DiscoveryUI.kt @@ -39,20 +39,18 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import coil.request.ImageRequest -import org.openedx.core.extension.isLinkValid -import org.openedx.core.extension.toImageLink -import org.openedx.core.ui.WindowSize -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.ui.windowSizeValue import org.openedx.discovery.R import org.openedx.discovery.domain.model.Course +import org.openedx.foundation.extension.toImageLink +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue import org.openedx.core.R as CoreR - @Composable fun ImageHeader( modifier: Modifier, diff --git a/discovery/src/test/java/org/openedx/discovery/presentation/NativeDiscoveryViewModelTest.kt b/discovery/src/test/java/org/openedx/discovery/presentation/NativeDiscoveryViewModelTest.kt index 7360ef131..83550dc42 100644 --- a/discovery/src/test/java/org/openedx/discovery/presentation/NativeDiscoveryViewModelTest.kt +++ b/discovery/src/test/java/org/openedx/discovery/presentation/NativeDiscoveryViewModelTest.kt @@ -21,15 +21,15 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule import org.openedx.core.R -import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Pagination -import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.discovery.domain.interactor.DiscoveryInteractor import org.openedx.discovery.domain.model.CourseList +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException @OptIn(ExperimentalCoroutinesApi::class) diff --git a/discovery/src/test/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModelTest.kt b/discovery/src/test/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModelTest.kt index e62cd0b38..3e0f4906f 100644 --- a/discovery/src/test/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModelTest.kt +++ b/discovery/src/test/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModelTest.kt @@ -22,11 +22,9 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule import org.openedx.core.R -import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Media -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 @@ -35,6 +33,8 @@ import org.openedx.discovery.domain.interactor.DiscoveryInteractor import org.openedx.discovery.domain.model.Course import org.openedx.discovery.presentation.DiscoveryAnalytics import org.openedx.discovery.presentation.DiscoveryAnalyticsEvent +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException @OptIn(ExperimentalCoroutinesApi::class) diff --git a/discovery/src/test/java/org/openedx/discovery/presentation/search/CourseSearchViewModelTest.kt b/discovery/src/test/java/org/openedx/discovery/presentation/search/CourseSearchViewModelTest.kt index 40e44e73c..8b5a1bab4 100644 --- a/discovery/src/test/java/org/openedx/discovery/presentation/search/CourseSearchViewModelTest.kt +++ b/discovery/src/test/java/org/openedx/discovery/presentation/search/CourseSearchViewModelTest.kt @@ -20,16 +20,16 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule import org.openedx.core.R -import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Media import org.openedx.core.domain.model.Pagination -import org.openedx.core.system.ResourceManager import org.openedx.discovery.domain.interactor.DiscoveryInteractor import org.openedx.discovery.domain.model.Course import org.openedx.discovery.domain.model.CourseList import org.openedx.discovery.presentation.DiscoveryAnalytics +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException @OptIn(ExperimentalCoroutinesApi::class) diff --git a/discussion/build.gradle b/discussion/build.gradle index 70ed3c39f..5442a57b2 100644 --- a/discussion/build.gradle +++ b/discussion/build.gradle @@ -2,6 +2,7 @@ plugins { id 'com.android.library' id 'org.jetbrains.kotlin.android' id 'kotlin-parcelize' + id "org.jetbrains.kotlin.plugin.compose" } android { @@ -35,9 +36,6 @@ android { viewBinding true compose true } - composeOptions { - kotlinCompilerExtensionVersion = "$compose_compiler_version" - } flavorDimensions += "env" productFlavors { @@ -56,11 +54,10 @@ android { dependencies { implementation project(path: ':core') - androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + androidTestImplementation 'androidx.test.ext:junit:1.2.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' testImplementation "junit:junit:$junit_version" testImplementation "io.mockk:mockk:$mockk_version" testImplementation "io.mockk:mockk-android:$mockk_version" - testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version" testImplementation "androidx.arch.core:core-testing:$android_arch_version" } \ No newline at end of file diff --git a/discussion/src/main/java/org/openedx/discussion/data/repository/DiscussionRepository.kt b/discussion/src/main/java/org/openedx/discussion/data/repository/DiscussionRepository.kt index 95c603cf5..ac07087cd 100644 --- a/discussion/src/main/java/org/openedx/discussion/data/repository/DiscussionRepository.kt +++ b/discussion/src/main/java/org/openedx/discussion/data/repository/DiscussionRepository.kt @@ -2,7 +2,6 @@ package org.openedx.discussion.data.repository import org.openedx.core.data.model.BlocksCompletionBody import org.openedx.core.data.storage.CorePreferences -import org.openedx.core.system.ResourceManager import org.openedx.discussion.R import org.openedx.discussion.data.api.DiscussionApi import org.openedx.discussion.data.model.request.CommentBody @@ -15,6 +14,7 @@ import org.openedx.discussion.domain.model.CommentsData import org.openedx.discussion.domain.model.DiscussionComment import org.openedx.discussion.domain.model.ThreadsData import org.openedx.discussion.domain.model.Topic +import org.openedx.foundation.system.ResourceManager class DiscussionRepository( private val api: DiscussionApi, diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/comments/DiscussionCommentsFragment.kt b/discussion/src/main/java/org/openedx/discussion/presentation/comments/DiscussionCommentsFragment.kt index 02f950bb6..2c7f03bd0 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/comments/DiscussionCommentsFragment.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/comments/DiscussionCommentsFragment.kt @@ -9,18 +9,45 @@ import android.view.ViewGroup import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.* +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.* +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Divider +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.TextFieldDefaults import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState -import androidx.compose.runtime.* +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +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 @@ -41,24 +68,32 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.core.os.bundleOf import androidx.fragment.app.Fragment -import org.openedx.core.UIMessage +import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf import org.openedx.core.domain.model.ProfileImage import org.openedx.core.extension.TextConverter -import org.openedx.core.extension.parcelable -import org.openedx.core.ui.* +import org.openedx.core.ui.BackBtn +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.shouldLoadMore +import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography +import org.openedx.discussion.R import org.openedx.discussion.domain.model.DiscussionComment import org.openedx.discussion.domain.model.DiscussionType import org.openedx.discussion.presentation.DiscussionRouter import org.openedx.discussion.presentation.ui.CommentItem import org.openedx.discussion.presentation.ui.ThreadMainItem -import org.koin.android.ext.android.inject -import org.koin.androidx.viewmodel.ext.android.viewModel -import org.koin.core.parameter.parametersOf -import org.openedx.discussion.R +import org.openedx.foundation.extension.parcelable +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue class DiscussionCommentsFragment : Fragment() { diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/comments/DiscussionCommentsViewModel.kt b/discussion/src/main/java/org/openedx/discussion/presentation/comments/DiscussionCommentsViewModel.kt index bcf54a92e..33e858b6b 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/comments/DiscussionCommentsViewModel.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/comments/DiscussionCommentsViewModel.kt @@ -4,12 +4,8 @@ import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope -import org.openedx.core.BaseViewModel +import kotlinx.coroutines.launch import org.openedx.core.R -import org.openedx.core.SingleEventLiveData -import org.openedx.core.UIMessage -import org.openedx.core.extension.isInternetError -import org.openedx.core.system.ResourceManager import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.domain.model.DiscussionComment import org.openedx.discussion.domain.model.DiscussionType @@ -17,7 +13,11 @@ import org.openedx.discussion.system.notifier.DiscussionCommentAdded import org.openedx.discussion.system.notifier.DiscussionCommentDataChanged import org.openedx.discussion.system.notifier.DiscussionNotifier import org.openedx.discussion.system.notifier.DiscussionThreadDataChanged -import kotlinx.coroutines.launch +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.SingleEventLiveData +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager class DiscussionCommentsViewModel( private val interactor: DiscussionInteractor, diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/responses/DiscussionResponsesFragment.kt b/discussion/src/main/java/org/openedx/discussion/presentation/responses/DiscussionResponsesFragment.kt index b3d5a0d82..ebf0fb1b9 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/responses/DiscussionResponsesFragment.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/responses/DiscussionResponsesFragment.kt @@ -9,18 +9,48 @@ import android.view.ViewGroup import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.* +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Divider +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.TextFieldDefaults import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState -import androidx.compose.runtime.* +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +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 @@ -41,22 +71,30 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.core.os.bundleOf import androidx.fragment.app.Fragment +import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf -import org.koin.android.ext.android.inject -import org.openedx.core.UIMessage import org.openedx.core.domain.model.ProfileImage import org.openedx.core.extension.TextConverter -import org.openedx.core.extension.parcelable -import org.openedx.core.ui.* +import org.openedx.core.ui.BackBtn +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.shouldLoadMore +import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography import org.openedx.discussion.domain.model.DiscussionComment +import org.openedx.discussion.presentation.DiscussionRouter import org.openedx.discussion.presentation.comments.DiscussionCommentsFragment import org.openedx.discussion.presentation.ui.CommentMainItem -import org.openedx.discussion.presentation.DiscussionRouter +import org.openedx.foundation.extension.parcelable +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue import org.openedx.discussion.R as discussionR class DiscussionResponsesFragment : Fragment() { diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/responses/DiscussionResponsesViewModel.kt b/discussion/src/main/java/org/openedx/discussion/presentation/responses/DiscussionResponsesViewModel.kt index 3f9b75e60..ed8390c44 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/responses/DiscussionResponsesViewModel.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/responses/DiscussionResponsesViewModel.kt @@ -3,17 +3,17 @@ package org.openedx.discussion.presentation.responses import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope -import org.openedx.core.BaseViewModel +import kotlinx.coroutines.launch import org.openedx.core.R -import org.openedx.core.SingleEventLiveData -import org.openedx.core.UIMessage -import org.openedx.core.extension.isInternetError -import org.openedx.core.system.ResourceManager import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.domain.model.DiscussionComment import org.openedx.discussion.system.notifier.DiscussionCommentDataChanged import org.openedx.discussion.system.notifier.DiscussionNotifier -import kotlinx.coroutines.launch +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.SingleEventLiveData +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager class DiscussionResponsesViewModel( private val interactor: DiscussionInteractor, diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadFragment.kt b/discussion/src/main/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadFragment.kt index 05fceeffc..76c645e37 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadFragment.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadFragment.kt @@ -4,19 +4,41 @@ import android.content.res.Configuration import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.* +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Divider +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.Text import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState -import androidx.compose.runtime.* +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +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.platform.ComposeView import androidx.compose.ui.platform.LocalFocusManager @@ -34,20 +56,27 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import androidx.core.os.bundleOf import androidx.fragment.app.Fragment +import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf import org.openedx.core.R -import org.openedx.core.UIMessage import org.openedx.core.extension.TextConverter -import org.openedx.core.ui.* +import org.openedx.core.ui.BackBtn +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.SearchBar +import org.openedx.core.ui.shouldLoadMore +import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography import org.openedx.discussion.domain.model.DiscussionType import org.openedx.discussion.presentation.DiscussionRouter import org.openedx.discussion.presentation.ui.ThreadItem -import org.koin.android.ext.android.inject -import org.koin.androidx.viewmodel.ext.android.viewModel -import org.koin.core.parameter.parametersOf - +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue import org.openedx.discussion.R as discussionR class DiscussionSearchThreadFragment : Fragment() { @@ -121,7 +150,7 @@ class DiscussionSearchThreadFragment : Fragment() { } -@OptIn(ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class) +@OptIn(ExperimentalMaterialApi::class) @Composable private fun DiscussionSearchThreadScreen( windowSize: WindowSize, diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadViewModel.kt b/discussion/src/main/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadViewModel.kt index a0c5c5c62..c101ae7b9 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadViewModel.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadViewModel.kt @@ -15,15 +15,15 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch -import org.openedx.core.BaseViewModel import org.openedx.core.R -import org.openedx.core.SingleEventLiveData -import org.openedx.core.UIMessage -import org.openedx.core.extension.isInternetError -import org.openedx.core.system.ResourceManager import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.system.notifier.DiscussionNotifier import org.openedx.discussion.system.notifier.DiscussionThreadDataChanged +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.SingleEventLiveData +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager class DiscussionSearchThreadViewModel( private val interactor: DiscussionInteractor, diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadFragment.kt b/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadFragment.kt index a211e10b3..82ad75a17 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadFragment.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadFragment.kt @@ -73,25 +73,25 @@ import androidx.fragment.app.Fragment import kotlinx.coroutines.launch import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf -import org.openedx.core.UIMessage import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.OpenEdXOutlinedTextField import org.openedx.core.ui.SheetContent -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.isImeVisibleState import org.openedx.core.ui.noRippleClickable -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.appShapes import org.openedx.core.ui.theme.appTypography -import org.openedx.core.ui.windowSizeValue import org.openedx.discussion.domain.model.DiscussionType +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue import org.openedx.discussion.R as discussionR class DiscussionAddThreadFragment : Fragment() { diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadViewModel.kt b/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadViewModel.kt index 278f6b4f0..3ff75bd9b 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadViewModel.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadViewModel.kt @@ -3,16 +3,16 @@ package org.openedx.discussion.presentation.threads import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope -import org.openedx.core.BaseViewModel +import kotlinx.coroutines.launch import org.openedx.core.R -import org.openedx.core.SingleEventLiveData -import org.openedx.core.UIMessage -import org.openedx.core.extension.isInternetError -import org.openedx.core.system.ResourceManager import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.system.notifier.DiscussionNotifier import org.openedx.discussion.system.notifier.DiscussionThreadAdded -import kotlinx.coroutines.launch +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.SingleEventLiveData +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager class DiscussionAddThreadViewModel( private val interactor: DiscussionInteractor, diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsFragment.kt b/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsFragment.kt index 34a08ebb2..35884fec0 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsFragment.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsFragment.kt @@ -6,20 +6,50 @@ import android.view.LayoutInflater import android.view.ViewGroup import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material.* +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Divider +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.ModalBottomSheetLayout +import androidx.compose.material.ModalBottomSheetValue +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.Text import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState -import androidx.compose.runtime.* +import androidx.compose.material.rememberModalBottomSheetState +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -44,13 +74,29 @@ import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.core.FragmentViewType -import org.openedx.core.UIMessage import org.openedx.core.extension.TextConverter -import org.openedx.core.ui.* -import org.openedx.core.ui.theme.* +import org.openedx.core.ui.BackBtn +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.IconText +import org.openedx.core.ui.OpenEdXOutlinedButton +import org.openedx.core.ui.SheetContent +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.isImeVisibleState +import org.openedx.core.ui.noRippleClickable +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.discussion.domain.model.DiscussionType import org.openedx.discussion.presentation.DiscussionRouter import org.openedx.discussion.presentation.ui.ThreadItem +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue import org.openedx.discussion.R as discussionR class DiscussionThreadsFragment : Fragment() { diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsViewModel.kt b/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsViewModel.kt index 2dbbd9af6..944152606 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsViewModel.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsViewModel.kt @@ -5,17 +5,17 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.launch -import org.openedx.core.BaseViewModel import org.openedx.core.R -import org.openedx.core.SingleEventLiveData -import org.openedx.core.UIMessage -import org.openedx.core.extension.isInternetError -import org.openedx.core.system.ResourceManager import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.presentation.topics.DiscussionTopicsViewModel import org.openedx.discussion.system.notifier.DiscussionNotifier import org.openedx.discussion.system.notifier.DiscussionThreadAdded import org.openedx.discussion.system.notifier.DiscussionThreadDataChanged +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.SingleEventLiveData +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager class DiscussionThreadsViewModel( private val interactor: DiscussionInteractor, diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsScreen.kt b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsScreen.kt index 990e14260..75bbe2eaa 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsScreen.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsScreen.kt @@ -39,23 +39,23 @@ import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentManager import org.openedx.core.FragmentViewType import org.openedx.core.NoContentScreenType -import org.openedx.core.UIMessage import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.NoContentScreen import org.openedx.core.ui.StaticSearchBar -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography -import org.openedx.core.ui.windowSizeValue import org.openedx.discussion.R import org.openedx.discussion.domain.model.Topic import org.openedx.discussion.presentation.ui.ThreadItemCategory import org.openedx.discussion.presentation.ui.TopicItem +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.windowSizeValue @Composable fun DiscussionTopicsScreen( 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 516ee50f8..16572da7c 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 @@ -7,17 +7,17 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch -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.system.ResourceManager import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier 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 +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager class DiscussionTopicsViewModel( val courseId: String, diff --git a/discussion/src/test/java/org/openedx/discussion/presentation/comments/DiscussionCommentsViewModelTest.kt b/discussion/src/test/java/org/openedx/discussion/presentation/comments/DiscussionCommentsViewModelTest.kt index 8e55f7cd2..933a3bd5b 100644 --- a/discussion/src/test/java/org/openedx/discussion/presentation/comments/DiscussionCommentsViewModelTest.kt +++ b/discussion/src/test/java/org/openedx/discussion/presentation/comments/DiscussionCommentsViewModelTest.kt @@ -4,25 +4,40 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry +import io.mockk.clearAllMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule import org.openedx.core.R -import org.openedx.core.UIMessage +import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Pagination import org.openedx.core.extension.TextConverter -import org.openedx.core.system.ResourceManager import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.domain.model.CommentsData import org.openedx.discussion.domain.model.DiscussionComment import org.openedx.discussion.domain.model.DiscussionType -import org.openedx.discussion.system.notifier.* -import io.mockk.* -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.test.* -import org.junit.* -import org.junit.rules.TestRule -import org.openedx.core.data.storage.CorePreferences +import org.openedx.discussion.system.notifier.DiscussionCommentAdded +import org.openedx.discussion.system.notifier.DiscussionCommentDataChanged +import org.openedx.discussion.system.notifier.DiscussionNotifier +import org.openedx.discussion.system.notifier.DiscussionThreadDataChanged +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException @OptIn(ExperimentalCoroutinesApi::class) diff --git a/discussion/src/test/java/org/openedx/discussion/presentation/responses/DiscussionResponsesViewModelTest.kt b/discussion/src/test/java/org/openedx/discussion/presentation/responses/DiscussionResponsesViewModelTest.kt index 61fa44df7..e3e0aa8ca 100644 --- a/discussion/src/test/java/org/openedx/discussion/presentation/responses/DiscussionResponsesViewModelTest.kt +++ b/discussion/src/test/java/org/openedx/discussion/presentation/responses/DiscussionResponsesViewModelTest.kt @@ -1,24 +1,35 @@ package org.openedx.discussion.presentation.responses import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import io.mockk.clearAllMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule import org.openedx.core.R -import org.openedx.core.UIMessage +import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Pagination import org.openedx.core.extension.LinkedImageText -import org.openedx.core.system.ResourceManager import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.domain.model.CommentsData import org.openedx.discussion.domain.model.DiscussionComment import org.openedx.discussion.domain.model.DiscussionType import org.openedx.discussion.system.notifier.DiscussionNotifier -import io.mockk.* -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.* -import org.junit.* -import org.junit.Assert.* -import org.junit.rules.TestRule -import org.openedx.core.data.storage.CorePreferences +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException @OptIn(ExperimentalCoroutinesApi::class) diff --git a/discussion/src/test/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadViewModelTest.kt b/discussion/src/test/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadViewModelTest.kt index 57d35df20..8cf079a35 100644 --- a/discussion/src/test/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadViewModelTest.kt +++ b/discussion/src/test/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadViewModelTest.kt @@ -4,16 +4,6 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry -import org.openedx.core.R -import org.openedx.core.UIMessage -import org.openedx.core.domain.model.Pagination -import org.openedx.core.extension.TextConverter -import org.openedx.core.system.ResourceManager -import org.openedx.discussion.domain.interactor.DiscussionInteractor -import org.openedx.discussion.domain.model.DiscussionType -import org.openedx.discussion.domain.model.ThreadsData -import org.openedx.discussion.system.notifier.DiscussionNotifier -import org.openedx.discussion.system.notifier.DiscussionThreadDataChanged import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -22,13 +12,26 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.test.* +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain import org.junit.After -import org.junit.Assert.* import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule +import org.openedx.core.R +import org.openedx.core.domain.model.Pagination +import org.openedx.core.extension.TextConverter +import org.openedx.discussion.domain.interactor.DiscussionInteractor +import org.openedx.discussion.domain.model.DiscussionType +import org.openedx.discussion.domain.model.ThreadsData +import org.openedx.discussion.system.notifier.DiscussionNotifier +import org.openedx.discussion.system.notifier.DiscussionThreadDataChanged +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException @OptIn(ExperimentalCoroutinesApi::class) diff --git a/discussion/src/test/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadViewModelTest.kt b/discussion/src/test/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadViewModelTest.kt index 6944e33c4..27c74ae00 100644 --- a/discussion/src/test/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadViewModelTest.kt +++ b/discussion/src/test/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadViewModelTest.kt @@ -1,11 +1,27 @@ package org.openedx.discussion.presentation.threads import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import io.mockk.clearAllMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule import org.openedx.core.R -import org.openedx.core.UIMessage +import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.ProfileImage import org.openedx.core.extension.TextConverter -import org.openedx.core.system.ResourceManager import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.domain.model.DiscussionComment import org.openedx.discussion.domain.model.DiscussionProfile @@ -13,16 +29,8 @@ import org.openedx.discussion.domain.model.DiscussionType import org.openedx.discussion.domain.model.Topic import org.openedx.discussion.system.notifier.DiscussionNotifier import org.openedx.discussion.system.notifier.DiscussionThreadAdded -import io.mockk.* -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.* -import org.junit.After -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.rules.TestRule -import org.openedx.core.data.storage.CorePreferences +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException @OptIn(ExperimentalCoroutinesApi::class) diff --git a/discussion/src/test/java/org/openedx/discussion/presentation/threads/DiscussionThreadsViewModelTest.kt b/discussion/src/test/java/org/openedx/discussion/presentation/threads/DiscussionThreadsViewModelTest.kt index 92e5cd2fa..435981520 100644 --- a/discussion/src/test/java/org/openedx/discussion/presentation/threads/DiscussionThreadsViewModelTest.kt +++ b/discussion/src/test/java/org/openedx/discussion/presentation/threads/DiscussionThreadsViewModelTest.kt @@ -25,10 +25,8 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule import org.openedx.core.R -import org.openedx.core.UIMessage import org.openedx.core.domain.model.Pagination import org.openedx.core.extension.TextConverter -import org.openedx.core.system.ResourceManager import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.domain.model.DiscussionType import org.openedx.discussion.domain.model.ThreadsData @@ -36,6 +34,8 @@ import org.openedx.discussion.presentation.topics.DiscussionTopicsViewModel import org.openedx.discussion.system.notifier.DiscussionNotifier import org.openedx.discussion.system.notifier.DiscussionThreadAdded import org.openedx.discussion.system.notifier.DiscussionThreadDataChanged +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException @OptIn(ExperimentalCoroutinesApi::class) diff --git a/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt b/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt index 96e3c49f4..676595929 100644 --- a/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt +++ b/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt @@ -24,14 +24,14 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule import org.openedx.core.R -import org.openedx.core.UIMessage -import org.openedx.core.system.ResourceManager import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.domain.model.Topic import org.openedx.discussion.presentation.DiscussionAnalytics import org.openedx.discussion.presentation.DiscussionRouter +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException @OptIn(ExperimentalCoroutinesApi::class) diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 35c31a92e..ec34fd6a7 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Fri May 03 13:24:00 EEST 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/profile/build.gradle b/profile/build.gradle index 2ccd98e63..a1b894421 100644 --- a/profile/build.gradle +++ b/profile/build.gradle @@ -2,6 +2,7 @@ plugins { id 'com.android.library' id 'org.jetbrains.kotlin.android' id 'kotlin-parcelize' + id "org.jetbrains.kotlin.plugin.compose" } android { @@ -36,9 +37,6 @@ android { viewBinding true compose true } - composeOptions { - kotlinCompilerExtensionVersion = "$compose_compiler_version" - } flavorDimensions += "env" productFlavors { @@ -57,11 +55,10 @@ android { dependencies { implementation project(path: ":core") - androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + androidTestImplementation 'androidx.test.ext:junit:1.2.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' testImplementation "junit:junit:$junit_version" testImplementation "io.mockk:mockk:$mockk_version" testImplementation "io.mockk:mockk-android:$mockk_version" - testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version" testImplementation "androidx.arch.core:core-testing:$android_arch_version" } \ No newline at end of file diff --git a/profile/src/main/java/org/openedx/profile/presentation/anothersaccount/AnothersProfileFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/anothersaccount/AnothersProfileFragment.kt index db330faa3..8404bbae1 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/anothersaccount/AnothersProfileFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/anothersaccount/AnothersProfileFragment.kt @@ -41,18 +41,18 @@ import androidx.fragment.app.Fragment import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.core.R -import org.openedx.core.UIMessage import org.openedx.core.domain.model.ProfileImage import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType -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.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue import org.openedx.profile.domain.model.Account import org.openedx.profile.presentation.ui.ProfileInfoSection import org.openedx.profile.presentation.ui.ProfileTopic diff --git a/profile/src/main/java/org/openedx/profile/presentation/anothersaccount/AnothersProfileViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/anothersaccount/AnothersProfileViewModel.kt index baabdb360..82b906207 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/anothersaccount/AnothersProfileViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/anothersaccount/AnothersProfileViewModel.kt @@ -4,11 +4,11 @@ import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.viewModelScope import kotlinx.coroutines.launch -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.system.ResourceManager +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import org.openedx.profile.domain.interactor.ProfileInteractor class AnothersProfileViewModel( diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarFragment.kt index fcc6db153..eaf43cf56 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarFragment.kt @@ -11,9 +11,9 @@ import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.fragment.app.Fragment import org.koin.androidx.compose.koinViewModel -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.rememberWindowSize class CalendarFragment : Fragment() { diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarSetUpView.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarSetUpView.kt index 7309a42f9..363dc70eb 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarSetUpView.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarSetUpView.kt @@ -39,8 +39,6 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.Toolbar -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.settingsHeaderBackground import org.openedx.core.ui.statusBarsInset @@ -48,7 +46,9 @@ 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.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.windowSizeValue import org.openedx.profile.R @OptIn(ExperimentalComposeUiApi::class) diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarSettingsView.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarSettingsView.kt index d8c2e9a55..8a78c6f12 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarSettingsView.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarSettingsView.kt @@ -48,8 +48,6 @@ import org.openedx.core.domain.model.CalendarData import org.openedx.core.presentation.settings.calendarsync.CalendarSyncState import org.openedx.core.ui.OpenEdXOutlinedButton import org.openedx.core.ui.Toolbar -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.settingsHeaderBackground import org.openedx.core.ui.statusBarsInset @@ -57,7 +55,9 @@ 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.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.windowSizeValue import org.openedx.profile.R import org.openedx.profile.presentation.ui.SettingsItem diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarViewModel.kt index c50bf587c..85f217446 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarViewModel.kt @@ -8,7 +8,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import org.openedx.core.BaseViewModel import org.openedx.core.data.storage.CalendarPreferences import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.interactor.CalendarInteractor @@ -23,6 +22,7 @@ import org.openedx.core.system.notifier.calendar.CalendarSyncOffline import org.openedx.core.system.notifier.calendar.CalendarSynced import org.openedx.core.system.notifier.calendar.CalendarSyncing import org.openedx.core.worker.CalendarSyncScheduler +import org.openedx.foundation.presentation.BaseViewModel import org.openedx.profile.presentation.ProfileRouter class CalendarViewModel( diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CoursesToSyncFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CoursesToSyncFragment.kt index 39e767e1b..fed719696 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/calendar/CoursesToSyncFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CoursesToSyncFragment.kt @@ -58,12 +58,9 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.fragment.app.Fragment import org.koin.androidx.compose.koinViewModel -import org.openedx.core.UIMessage import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.Toolbar -import org.openedx.core.ui.WindowSize import org.openedx.core.ui.displayCutoutForLandscape -import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.settingsHeaderBackground import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme @@ -71,7 +68,10 @@ 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.theme.fontFamily -import org.openedx.core.ui.windowSizeValue +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue import org.openedx.profile.R import org.openedx.core.R as coreR diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CoursesToSyncViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CoursesToSyncViewModel.kt index 5e54363e6..cf7f8b24d 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/calendar/CoursesToSyncViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CoursesToSyncViewModel.kt @@ -9,14 +9,14 @@ 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.data.storage.CalendarPreferences import org.openedx.core.domain.interactor.CalendarInteractor -import org.openedx.core.extension.isInternetError -import org.openedx.core.system.ResourceManager import org.openedx.core.worker.CalendarSyncScheduler +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager class CoursesToSyncViewModel( private val calendarInteractor: CalendarInteractor, diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/DisableCalendarSyncDialogFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/DisableCalendarSyncDialogFragment.kt index e6a196a8c..8a71410b1 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/calendar/DisableCalendarSyncDialogFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/DisableCalendarSyncDialogFragment.kt @@ -35,7 +35,6 @@ import androidx.core.os.bundleOf import androidx.fragment.app.DialogFragment import org.koin.androidx.compose.koinViewModel import org.openedx.core.domain.model.CalendarData -import org.openedx.core.extension.parcelable import org.openedx.core.presentation.dialog.DefaultDialogBox import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.OpenEdXOutlinedButton @@ -43,6 +42,7 @@ 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.foundation.extension.parcelable import org.openedx.profile.R import androidx.compose.ui.graphics.Color as ComposeColor import org.openedx.core.R as coreR diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/DisableCalendarSyncDialogViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/DisableCalendarSyncDialogViewModel.kt index 303dd2a40..b29c3394c 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/calendar/DisableCalendarSyncDialogViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/DisableCalendarSyncDialogViewModel.kt @@ -2,12 +2,12 @@ package org.openedx.profile.presentation.calendar import androidx.lifecycle.viewModelScope import kotlinx.coroutines.launch -import org.openedx.core.BaseViewModel import org.openedx.core.data.storage.CalendarPreferences import org.openedx.core.domain.interactor.CalendarInteractor import org.openedx.core.system.CalendarManager import org.openedx.core.system.notifier.calendar.CalendarNotifier import org.openedx.core.system.notifier.calendar.CalendarSyncDisabled +import org.openedx.foundation.presentation.BaseViewModel class DisableCalendarSyncDialogViewModel( private val calendarNotifier: CalendarNotifier, 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 af09b3ea3..361bd965e 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 @@ -64,8 +64,6 @@ import androidx.compose.ui.unit.dp import androidx.core.os.bundleOf import androidx.fragment.app.DialogFragment import org.koin.androidx.compose.koinViewModel -import org.openedx.core.extension.parcelable -import org.openedx.core.extension.toastMessage import org.openedx.core.presentation.dialog.DefaultDialogBox import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.OpenEdXOutlinedButton @@ -74,6 +72,8 @@ 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.foundation.extension.parcelable +import org.openedx.foundation.extension.toastMessage import org.openedx.profile.R import androidx.compose.ui.graphics.Color as ComposeColor import org.openedx.core.R as CoreR diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogViewModel.kt index e43f1b989..20fbdbf23 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogViewModel.kt @@ -5,15 +5,15 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch -import org.openedx.core.BaseViewModel import org.openedx.core.R import org.openedx.core.data.storage.CalendarPreferences import org.openedx.core.domain.interactor.CalendarInteractor import org.openedx.core.system.CalendarManager -import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.calendar.CalendarCreated import org.openedx.core.system.notifier.calendar.CalendarNotifier +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.system.ResourceManager class NewCalendarDialogViewModel( private val calendarManager: CalendarManager, diff --git a/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileFragment.kt index f92ca3c3e..f9d481466 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileFragment.kt @@ -56,22 +56,22 @@ import androidx.fragment.app.Fragment import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.openedx.core.R -import org.openedx.core.UIMessage import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.OpenEdXOutlinedTextField import org.openedx.core.ui.Toolbar -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType import org.openedx.core.ui.displayCutoutForLandscape -import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.settingsHeaderBackground 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.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue import org.openedx.profile.presentation.ProfileRouter import org.openedx.profile.presentation.settings.SettingsViewModel import org.openedx.profile.R as profileR diff --git a/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileViewModel.kt index 79fff00d1..d29e70ab3 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileViewModel.kt @@ -4,13 +4,13 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.launch -import org.openedx.core.BaseViewModel import org.openedx.core.R -import org.openedx.core.UIMessage import org.openedx.core.Validator -import org.openedx.core.extension.isInternetError import org.openedx.core.system.EdxError -import org.openedx.core.system.ResourceManager +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import org.openedx.profile.domain.interactor.ProfileInteractor import org.openedx.profile.presentation.ProfileAnalytics import org.openedx.profile.presentation.ProfileAnalyticsEvent diff --git a/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileFragment.kt index 21317c3fe..4005d1191 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileFragment.kt @@ -42,7 +42,6 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.Card import androidx.compose.material.Divider -import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.ModalBottomSheetLayout @@ -108,13 +107,9 @@ import kotlinx.coroutines.launch import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.core.AppDataConstants.DEFAULT_MIME_TYPE -import org.openedx.core.UIMessage import org.openedx.core.domain.model.LanguageProficiency import org.openedx.core.domain.model.ProfileImage import org.openedx.core.domain.model.RegistrationField -import org.openedx.core.extension.getFileName -import org.openedx.core.extension.parcelable -import org.openedx.core.extension.tagId import org.openedx.core.ui.AutoSizeText import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage @@ -122,20 +117,24 @@ import org.openedx.core.ui.IconText import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.OpenEdXOutlinedButton import org.openedx.core.ui.SheetContent -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.isImeVisibleState import org.openedx.core.ui.noRippleClickable import org.openedx.core.ui.rememberSaveableMap -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.appShapes import org.openedx.core.ui.theme.appTypography -import org.openedx.core.ui.windowSizeValue import org.openedx.core.utils.LocaleUtils +import org.openedx.foundation.extension.getFileName +import org.openedx.foundation.extension.parcelable +import org.openedx.foundation.extension.tagId +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue import org.openedx.profile.R import org.openedx.profile.domain.model.Account import java.io.ByteArrayOutputStream @@ -304,10 +303,7 @@ class EditProfileFragment : Fragment() { } -@OptIn( - ExperimentalMaterialApi::class, - ExperimentalComposeUiApi::class -) +@OptIn(ExperimentalComposeUiApi::class) @Composable private fun EditProfileScreen( windowSize: WindowSize, diff --git a/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileViewModel.kt index bc4c77dd1..33e804d28 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileViewModel.kt @@ -5,11 +5,11 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.launch -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.system.ResourceManager +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import org.openedx.profile.domain.interactor.ProfileInteractor import org.openedx.profile.domain.model.Account import org.openedx.profile.presentation.ProfileAnalytics diff --git a/profile/src/main/java/org/openedx/profile/presentation/manageaccount/ManageAccountFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/manageaccount/ManageAccountFragment.kt index 084d544aa..0616c9e84 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/manageaccount/ManageAccountFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/manageaccount/ManageAccountFragment.kt @@ -9,8 +9,8 @@ import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.fragment.app.Fragment import org.koin.androidx.viewmodel.ext.android.viewModel -import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.foundation.presentation.rememberWindowSize import org.openedx.profile.presentation.manageaccount.compose.ManageAccountView import org.openedx.profile.presentation.manageaccount.compose.ManageAccountViewAction diff --git a/profile/src/main/java/org/openedx/profile/presentation/manageaccount/ManageAccountViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/manageaccount/ManageAccountViewModel.kt index 972426d2e..3e25214a1 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/manageaccount/ManageAccountViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/manageaccount/ManageAccountViewModel.kt @@ -10,11 +10,11 @@ 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.extension.isInternetError -import org.openedx.core.system.ResourceManager +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import org.openedx.profile.domain.interactor.ProfileInteractor import org.openedx.profile.presentation.ProfileAnalytics import org.openedx.profile.presentation.ProfileAnalyticsEvent diff --git a/profile/src/main/java/org/openedx/profile/presentation/manageaccount/compose/ManageAccountView.kt b/profile/src/main/java/org/openedx/profile/presentation/manageaccount/compose/ManageAccountView.kt index 970ff2f91..016f1e90c 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/manageaccount/compose/ManageAccountView.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/manageaccount/compose/ManageAccountView.kt @@ -38,13 +38,10 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import org.openedx.core.R -import org.openedx.core.UIMessage import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.IconText import org.openedx.core.ui.OpenEdXOutlinedButton import org.openedx.core.ui.Toolbar -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.settingsHeaderBackground import org.openedx.core.ui.statusBarsInset @@ -52,7 +49,10 @@ 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.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.windowSizeValue import org.openedx.profile.presentation.manageaccount.ManageAccountUIState import org.openedx.profile.presentation.ui.ProfileTopic import org.openedx.profile.presentation.ui.mockAccount diff --git a/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileFragment.kt index cdf190e6a..581bdc63f 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileFragment.kt @@ -10,8 +10,8 @@ import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.fragment.app.Fragment import org.koin.androidx.viewmodel.ext.android.viewModel -import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.foundation.presentation.rememberWindowSize import org.openedx.profile.presentation.profile.compose.ProfileView import org.openedx.profile.presentation.profile.compose.ProfileViewAction diff --git a/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileViewModel.kt index f02e09c22..b2d4ccb4e 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileViewModel.kt @@ -9,11 +9,11 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow 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.extension.isInternetError -import org.openedx.core.system.ResourceManager +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import org.openedx.profile.domain.interactor.ProfileInteractor import org.openedx.profile.presentation.ProfileAnalytics import org.openedx.profile.presentation.ProfileAnalyticsEvent diff --git a/profile/src/main/java/org/openedx/profile/presentation/profile/compose/ProfileView.kt b/profile/src/main/java/org/openedx/profile/presentation/profile/compose/ProfileView.kt index 411ac156d..7a0d90b16 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/profile/compose/ProfileView.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/profile/compose/ProfileView.kt @@ -37,17 +37,17 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import org.openedx.core.R -import org.openedx.core.UIMessage import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OpenEdXOutlinedButton import org.openedx.core.ui.Toolbar -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors -import org.openedx.core.ui.windowSizeValue +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.windowSizeValue import org.openedx.profile.presentation.profile.ProfileUIState import org.openedx.profile.presentation.ui.ProfileInfoSection import org.openedx.profile.presentation.ui.ProfileTopic diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsFragment.kt index 7ac402330..1746fa167 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsFragment.kt @@ -10,8 +10,8 @@ import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.fragment.app.Fragment import org.koin.androidx.viewmodel.ext.android.viewModel -import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.foundation.presentation.rememberWindowSize class SettingsFragment : Fragment() { diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt index 5e044ca46..21d0fe1fb 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt @@ -56,8 +56,6 @@ import org.openedx.core.presentation.global.AppData import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.Toolbar -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.settingsHeaderBackground import org.openedx.core.ui.statusBarsInset @@ -65,7 +63,9 @@ 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.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.windowSizeValue import org.openedx.profile.domain.model.Configuration import org.openedx.profile.presentation.ui.SettingsDivider import org.openedx.profile.presentation.ui.SettingsItem diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt index 64145b063..a483d0b91 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt @@ -14,20 +14,20 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.openedx.core.AppUpdateState -import org.openedx.core.BaseViewModel import org.openedx.core.CalendarRouter import org.openedx.core.R -import org.openedx.core.UIMessage import org.openedx.core.config.Config -import org.openedx.core.extension.isInternetError import org.openedx.core.module.DownloadWorkerController import org.openedx.core.presentation.global.AppData import org.openedx.core.system.AppCookieManager -import org.openedx.core.system.ResourceManager import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.core.system.notifier.app.LogoutEvent import org.openedx.core.utils.EmailUtil +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import org.openedx.profile.domain.interactor.ProfileInteractor import org.openedx.profile.domain.model.Configuration import org.openedx.profile.presentation.ProfileAnalytics diff --git a/profile/src/main/java/org/openedx/profile/presentation/ui/SettingsUI.kt b/profile/src/main/java/org/openedx/profile/presentation/ui/SettingsUI.kt index df6c719ca..f4811135a 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/ui/SettingsUI.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/ui/SettingsUI.kt @@ -19,9 +19,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import org.openedx.core.extension.tagId import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography +import org.openedx.foundation.extension.tagId @Composable fun SettingsItem( diff --git a/profile/src/main/java/org/openedx/profile/presentation/video/VideoSettingsFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/video/VideoSettingsFragment.kt index 5de93fdad..2213b083d 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/video/VideoSettingsFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/video/VideoSettingsFragment.kt @@ -51,18 +51,18 @@ import androidx.fragment.app.Fragment import org.koin.androidx.viewmodel.ext.android.viewModel import org.openedx.core.domain.model.VideoSettings import org.openedx.core.ui.Toolbar -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.noRippleClickable -import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.settingsHeaderBackground 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.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue import org.openedx.profile.R import org.openedx.core.R as CoreR diff --git a/profile/src/main/java/org/openedx/profile/presentation/video/VideoSettingsViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/video/VideoSettingsViewModel.kt index f0ed7622a..f2fd90c61 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/video/VideoSettingsViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/video/VideoSettingsViewModel.kt @@ -7,12 +7,12 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import org.openedx.core.BaseViewModel import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.VideoSettings import org.openedx.core.presentation.settings.video.VideoQualityType import org.openedx.core.system.notifier.VideoNotifier import org.openedx.core.system.notifier.VideoQualityChanged +import org.openedx.foundation.presentation.BaseViewModel import org.openedx.profile.presentation.ProfileAnalytics import org.openedx.profile.presentation.ProfileAnalyticsEvent import org.openedx.profile.presentation.ProfileAnalyticsKey diff --git a/profile/src/test/java/org/openedx/profile/presentation/edit/EditProfileViewModelTest.kt b/profile/src/test/java/org/openedx/profile/presentation/edit/EditProfileViewModelTest.kt index e8f1c13ef..9cc8c79ff 100644 --- a/profile/src/test/java/org/openedx/profile/presentation/edit/EditProfileViewModelTest.kt +++ b/profile/src/test/java/org/openedx/profile/presentation/edit/EditProfileViewModelTest.kt @@ -20,9 +20,9 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule import org.openedx.core.R -import org.openedx.core.UIMessage import org.openedx.core.domain.model.ProfileImage -import org.openedx.core.system.ResourceManager +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import org.openedx.profile.domain.interactor.ProfileInteractor import org.openedx.profile.domain.model.Account import org.openedx.profile.presentation.ProfileAnalytics diff --git a/profile/src/test/java/org/openedx/profile/presentation/profile/AnothersProfileViewModelTest.kt b/profile/src/test/java/org/openedx/profile/presentation/profile/AnothersProfileViewModelTest.kt index 0e299e82a..6bdd07b82 100644 --- a/profile/src/test/java/org/openedx/profile/presentation/profile/AnothersProfileViewModelTest.kt +++ b/profile/src/test/java/org/openedx/profile/presentation/profile/AnothersProfileViewModelTest.kt @@ -19,9 +19,9 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule import org.openedx.core.R -import org.openedx.core.UIMessage import org.openedx.core.domain.model.ProfileImage -import org.openedx.core.system.ResourceManager +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import org.openedx.profile.domain.interactor.ProfileInteractor import org.openedx.profile.presentation.anothersaccount.AnothersProfileUIState import org.openedx.profile.presentation.anothersaccount.AnothersProfileViewModel diff --git a/profile/src/test/java/org/openedx/profile/presentation/profile/ProfileViewModelTest.kt b/profile/src/test/java/org/openedx/profile/presentation/profile/ProfileViewModelTest.kt index d33f24fb9..0d11e9c8a 100644 --- a/profile/src/test/java/org/openedx/profile/presentation/profile/ProfileViewModelTest.kt +++ b/profile/src/test/java/org/openedx/profile/presentation/profile/ProfileViewModelTest.kt @@ -24,11 +24,11 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule import org.openedx.core.R -import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.domain.model.AgreementUrls import org.openedx.core.domain.model.ProfileImage -import org.openedx.core.system.ResourceManager +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import org.openedx.profile.domain.interactor.ProfileInteractor import org.openedx.profile.presentation.ProfileAnalytics import org.openedx.profile.presentation.ProfileRouter diff --git a/settings.gradle b/settings.gradle index 2e2262fff..40beee473 100644 --- a/settings.gradle +++ b/settings.gradle @@ -3,7 +3,6 @@ pluginManagement { gradlePluginPortal() google() mavenCentral() - maven { url "https://maven.fullstory.com" } } buildscript { repositories { @@ -11,11 +10,9 @@ pluginManagement { maven { url = uri("https://storage.googleapis.com/r8-releases/raw") } - maven { url "https://maven.fullstory.com" } } dependencies { - classpath("com.android.tools:r8:8.3.37") - classpath 'com.fullstory:gradle-plugin-local:1.47.0' + classpath("com.android.tools:r8:8.5.35") } } } @@ -24,16 +21,14 @@ dependencyResolutionManagement { repositories { google() mavenCentral() - maven { url "https://maven.fullstory.com" } maven { url "http://appboy.github.io/appboy-android-sdk/sdk" allowInsecureProtocol = true } + maven { url "https://pkgs.dev.azure.com/MicrosoftDeviceSDK/DuoSDK-Public/_packaging/Duo-SDK-Feed/maven/v1" } maven { - url "https://appboy.github.io/appboy-segment-android/sdk" - allowInsecureProtocol = true + url = uri("https://jitpack.io") } - maven { url "https://pkgs.dev.azure.com/MicrosoftDeviceSDK/DuoSDK-Public/_packaging/Duo-SDK-Feed/maven/v1" } } } //Workaround for AS Iguana https://github.com/gradle/gradle/issues/28407 diff --git a/whatsnew/build.gradle b/whatsnew/build.gradle index cd6778d05..59a5e14cc 100644 --- a/whatsnew/build.gradle +++ b/whatsnew/build.gradle @@ -2,6 +2,7 @@ plugins { id 'com.android.library' id 'org.jetbrains.kotlin.android' id 'kotlin-parcelize' + id "org.jetbrains.kotlin.plugin.compose" } android { @@ -38,10 +39,6 @@ android { compose true } - composeOptions { - kotlinCompilerExtensionVersion = "$compose_compiler_version" - } - flavorDimensions += "env" productFlavors { prod { @@ -59,11 +56,10 @@ android { dependencies { implementation project(path: ":core") - androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + androidTestImplementation 'androidx.test.ext:junit:1.2.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' testImplementation "junit:junit:$junit_version" testImplementation "io.mockk:mockk:$mockk_version" testImplementation "io.mockk:mockk-android:$mockk_version" - testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version" testImplementation "androidx.arch.core:core-testing:$android_arch_version" } \ No newline at end of file diff --git a/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewFragment.kt b/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewFragment.kt index 541877ee2..8b9523eaa 100644 --- a/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewFragment.kt +++ b/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewFragment.kt @@ -57,14 +57,14 @@ import androidx.fragment.app.Fragment import kotlinx.coroutines.launch import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf -import org.openedx.core.ui.WindowSize import org.openedx.core.ui.calculateCurrentOffsetForPage -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.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue import org.openedx.whatsnew.domain.model.WhatsNewItem import org.openedx.whatsnew.domain.model.WhatsNewMessage import org.openedx.whatsnew.presentation.ui.NavigationUnitsButtons @@ -121,7 +121,7 @@ class WhatsNewFragment : Fragment() { } } -@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class) +@OptIn(ExperimentalComposeUiApi::class) @Composable fun WhatsNewScreen( windowSize: WindowSize, @@ -176,7 +176,6 @@ fun WhatsNewScreen( } } -@OptIn(ExperimentalFoundationApi::class) @Composable private fun WhatsNewTopBar( windowSize: WindowSize, diff --git a/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewViewModel.kt b/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewViewModel.kt index 534a54f13..dbbbdda2f 100644 --- a/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewViewModel.kt +++ b/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewViewModel.kt @@ -3,8 +3,8 @@ package org.openedx.whatsnew.presentation.whatsnew import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf import androidx.fragment.app.FragmentManager -import org.openedx.core.BaseViewModel import org.openedx.core.presentation.global.AppData +import org.openedx.foundation.presentation.BaseViewModel import org.openedx.whatsnew.WhatsNewManager import org.openedx.whatsnew.WhatsNewRouter import org.openedx.whatsnew.data.storage.WhatsNewPreferences From 773dac5ed0d8956b93faecfcefafdf06231f4807 Mon Sep 17 00:00:00 2001 From: Volodymyr Chekyrta Date: Fri, 8 Nov 2024 12:21:02 +0200 Subject: [PATCH 55/56] build: update action versions (#401) --- .github/workflows/unit_tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index fe5aa5869..3d93935a0 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -42,7 +42,7 @@ jobs: run: ./gradlew testProdDebugUnitTest $CI_GRADLE_ARG_PROPERTIES - name: Upload reports - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: failure() with: name: failures From 88dfc54ef4db0b3b45cfdf64da4747db5c03d1a6 Mon Sep 17 00:00:00 2001 From: Igor Date: Fri, 8 Nov 2024 11:27:31 +0100 Subject: [PATCH 56/56] feat: added ability to handle course errors (#397) * fix: bug when unable to see downloaded html content * feat: added ability to handle course errors --------- Co-authored-by: Omer Habib <30689349+omerhabib26@users.noreply.github.com> --- .../main/java/org/openedx/app/AppRouter.kt | 12 +- .../openedx/app/deeplink/DeepLinkRouter.kt | 5 - .../main/java/org/openedx/app/di/AppModule.kt | 1 + .../java/org/openedx/app/di/ScreenModule.kt | 5 +- .../org/openedx/core/data/api/CourseApi.kt | 6 + .../core/data/model/CourseAccessDetails.kt | 36 ++ .../data/model/CourseEnrollmentDetails.kt | 36 ++ .../core/data/model/CourseInfoOverview.kt | 44 ++ .../core/data/model/CourseStructureModel.kt | 8 +- .../core/data/model/EnrollmentDetails.kt | 36 ++ .../room/discovery/EnrolledCourseEntity.kt | 43 ++ .../core/domain/model/CourseAccessDetails.kt | 14 + .../domain/model/CourseEnrollmentDetails.kt | 30 ++ .../core/domain/model/CourseInfoOverview.kt | 23 + .../core/domain/model/EnrollmentDetails.kt | 17 + .../org/openedx/core/extension/BooleanExt.kt | 9 + .../org/openedx/core/extension/ObjectExt.kt | 9 + .../java/org/openedx/core/utils/TimeUtils.kt | 9 + .../data/repository/CourseRepository.kt | 5 + .../domain/interactor/CourseInteractor.kt | 5 + .../course/presentation/CourseRouter.kt | 2 + .../container/CollapsingLayout.kt | 46 +- .../container/CourseContainerFragment.kt | 408 ++++++++++++------ .../container/CourseContainerViewModel.kt | 87 ++-- .../outline/CourseOutlineScreen.kt | 6 +- .../outline/CourseOutlineViewModel.kt | 23 +- .../course/presentation/ui/CourseVideosUI.kt | 2 +- .../main/res/drawable/course_ic_calendar.xml | 9 + .../drawable/course_ic_circled_arrow_up.xml | 32 ++ course/src/main/res/values/strings.xml | 5 + .../container/CourseContainerViewModelTest.kt | 213 ++++++--- .../dates/CourseDatesViewModelTest.kt | 20 + .../outline/CourseOutlineViewModelTest.kt | 5 +- .../CourseUnitContainerViewModelTest.kt | 2 +- .../presentation/MyCoursesScreenTest.kt | 10 +- .../presentation/AllEnrolledCoursesView.kt | 1 - .../AllEnrolledCoursesViewModel.kt | 8 +- .../presentation/DashboardGalleryViewModel.kt | 1 - .../presentation/DashboardListFragment.kt | 7 +- .../dashboard/presentation/DashboardRouter.kt | 1 - default_config/dev/config.yaml | 2 +- .../discovery/presentation/DiscoveryRouter.kt | 1 - .../detail/CourseDetailsFragment.kt | 1 - .../presentation/info/CourseInfoViewModel.kt | 1 - .../presentation/program/ProgramViewModel.kt | 1 - 45 files changed, 956 insertions(+), 291 deletions(-) create mode 100644 core/src/main/java/org/openedx/core/data/model/CourseAccessDetails.kt create mode 100644 core/src/main/java/org/openedx/core/data/model/CourseEnrollmentDetails.kt create mode 100644 core/src/main/java/org/openedx/core/data/model/CourseInfoOverview.kt create mode 100644 core/src/main/java/org/openedx/core/data/model/EnrollmentDetails.kt create mode 100644 core/src/main/java/org/openedx/core/domain/model/CourseAccessDetails.kt create mode 100644 core/src/main/java/org/openedx/core/domain/model/CourseEnrollmentDetails.kt create mode 100644 core/src/main/java/org/openedx/core/domain/model/CourseInfoOverview.kt create mode 100644 core/src/main/java/org/openedx/core/domain/model/EnrollmentDetails.kt create mode 100644 core/src/main/java/org/openedx/core/extension/BooleanExt.kt create mode 100644 core/src/main/java/org/openedx/core/extension/ObjectExt.kt create mode 100644 course/src/main/res/drawable/course_ic_calendar.xml create mode 100644 course/src/main/res/drawable/course_ic_circled_arrow_up.xml diff --git a/app/src/main/java/org/openedx/app/AppRouter.kt b/app/src/main/java/org/openedx/app/AppRouter.kt index 09903f99e..4d4d38182 100644 --- a/app/src/main/java/org/openedx/app/AppRouter.kt +++ b/app/src/main/java/org/openedx/app/AppRouter.kt @@ -3,6 +3,7 @@ package org.openedx.app import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentTransaction +import org.openedx.app.deeplink.HomeTab import org.openedx.auth.presentation.AuthRouter import org.openedx.auth.presentation.logistration.LogistrationFragment import org.openedx.auth.presentation.restore.RestorePasswordFragment @@ -163,11 +164,10 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di fm: FragmentManager, courseId: String, courseTitle: String, - enrollmentMode: String, ) { replaceFragmentWithBackStack( fm, - CourseContainerFragment.newInstance(courseId, courseTitle, enrollmentMode) + CourseContainerFragment.newInstance(courseId, courseTitle) ) } //endregion @@ -178,7 +178,6 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di fm: FragmentManager, courseId: String, courseTitle: String, - enrollmentMode: String, openTab: String, resumeBlockId: String, ) { @@ -187,7 +186,6 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di CourseContainerFragment.newInstance( courseId, courseTitle, - enrollmentMode, openTab, resumeBlockId ) @@ -411,6 +409,12 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di replaceFragmentWithBackStack(fm, VideoQualityFragment.newInstance(videoQualityType.name)) } + override fun navigateToDiscover(fm: FragmentManager) { + fm.beginTransaction() + .replace(R.id.container, MainFragment.newInstance("", "", HomeTab.DISCOVER.name)) + .commit() + } + override fun navigateToWebContent(fm: FragmentManager, title: String, url: String) { replaceFragmentWithBackStack( fm, diff --git a/app/src/main/java/org/openedx/app/deeplink/DeepLinkRouter.kt b/app/src/main/java/org/openedx/app/deeplink/DeepLinkRouter.kt index 31564edf7..a55d45ff6 100644 --- a/app/src/main/java/org/openedx/app/deeplink/DeepLinkRouter.kt +++ b/app/src/main/java/org/openedx/app/deeplink/DeepLinkRouter.kt @@ -300,7 +300,6 @@ class DeepLinkRouter( fm = fm, courseId = courseId, courseTitle = courseTitle, - enrollmentMode = "" ) } } @@ -311,7 +310,6 @@ class DeepLinkRouter( fm = fm, courseId = courseId, courseTitle = "", - enrollmentMode = "", openTab = "VIDEOS" ) } @@ -323,7 +321,6 @@ class DeepLinkRouter( fm = fm, courseId = courseId, courseTitle = "", - enrollmentMode = "", openTab = "DATES" ) } @@ -335,7 +332,6 @@ class DeepLinkRouter( fm = fm, courseId = courseId, courseTitle = "", - enrollmentMode = "", openTab = "DISCUSSIONS" ) } @@ -347,7 +343,6 @@ class DeepLinkRouter( fm = fm, courseId = courseId, courseTitle = "", - enrollmentMode = "", openTab = "MORE" ) } 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 79d70208f..05d68cc49 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -30,6 +30,7 @@ import org.openedx.auth.presentation.sso.OAuthHelper import org.openedx.core.CalendarRouter import org.openedx.core.R import org.openedx.core.config.Config +import org.openedx.core.data.model.CourseEnrollmentDetails import org.openedx.core.data.model.CourseEnrollments import org.openedx.core.data.storage.CalendarPreferences import org.openedx.core.data.storage.CorePreferences 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 86d9b3dfe..31ebf741e 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -242,12 +242,11 @@ val screenModule = module { get(), ) } - viewModel { (courseId: String, courseTitle: String, enrollmentMode: String, resumeBlockId: String) -> + viewModel { (courseId: String, courseTitle: String, resumeBlockId: String) -> CourseContainerViewModel( courseId, courseTitle, resumeBlockId, - enrollmentMode, get(), get(), get(), @@ -257,7 +256,7 @@ val screenModule = module { get(), get(), get(), - get() + get(), ) } viewModel { (courseId: String, courseTitle: String) -> 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 4822a3762..32d401f7b 100644 --- a/core/src/main/java/org/openedx/core/data/api/CourseApi.kt +++ b/core/src/main/java/org/openedx/core/data/api/CourseApi.kt @@ -6,6 +6,7 @@ import org.openedx.core.data.model.BlocksCompletionBody import org.openedx.core.data.model.CourseComponentStatus import org.openedx.core.data.model.CourseDates import org.openedx.core.data.model.CourseDatesBannerInfo +import org.openedx.core.data.model.CourseEnrollmentDetails import org.openedx.core.data.model.CourseEnrollments import org.openedx.core.data.model.CourseStructureModel import org.openedx.core.data.model.EnrollmentStatus @@ -93,4 +94,9 @@ interface CourseApi { suspend fun getEnrollmentsStatus( @Path("username") username: String ): List + + @GET("/api/mobile/v1/course_info/{course_id}/enrollment_details") + suspend fun getEnrollmentDetails( + @Path("course_id") courseId: String, + ): CourseEnrollmentDetails } diff --git a/core/src/main/java/org/openedx/core/data/model/CourseAccessDetails.kt b/core/src/main/java/org/openedx/core/data/model/CourseAccessDetails.kt new file mode 100644 index 000000000..c69b092ed --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseAccessDetails.kt @@ -0,0 +1,36 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.data.model.room.discovery.CourseAccessDetailsDb +import org.openedx.core.utils.TimeUtils +import org.openedx.core.domain.model.CourseAccessDetails as DomainCourseAccessDetails + +data class CourseAccessDetails( + @SerializedName("has_unmet_prerequisites") + val hasUnmetPrerequisites: Boolean, + @SerializedName("is_too_early") + val isTooEarly: Boolean, + @SerializedName("is_staff") + val isStaff: Boolean, + @SerializedName("audit_access_expires") + val auditAccessExpires: String?, + @SerializedName("courseware_access") + var coursewareAccess: CoursewareAccess?, +) { + fun mapToDomain() = DomainCourseAccessDetails( + hasUnmetPrerequisites = hasUnmetPrerequisites, + isTooEarly = isTooEarly, + isStaff = isStaff, + auditAccessExpires = TimeUtils.iso8601ToDate(auditAccessExpires ?: ""), + coursewareAccess = coursewareAccess?.mapToDomain(), + ) + + fun mapToRoomEntity(): CourseAccessDetailsDb = + CourseAccessDetailsDb( + hasUnmetPrerequisites = hasUnmetPrerequisites, + isTooEarly = isTooEarly, + isStaff = isStaff, + auditAccessExpires = auditAccessExpires, + coursewareAccess = coursewareAccess?.mapToRoomEntity() + ) +} diff --git a/core/src/main/java/org/openedx/core/data/model/CourseEnrollmentDetails.kt b/core/src/main/java/org/openedx/core/data/model/CourseEnrollmentDetails.kt new file mode 100644 index 000000000..b27057eac --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseEnrollmentDetails.kt @@ -0,0 +1,36 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.domain.model.CourseEnrollmentDetails as DomainCourseEnrollmentDetails + +data class CourseEnrollmentDetails( + @SerializedName("id") + val id: String, + @SerializedName("course_updates") + val courseUpdates: String?, + @SerializedName("course_handouts") + val courseHandouts: String?, + @SerializedName("discussion_url") + val discussionUrl: String?, + @SerializedName("course_access_details") + val courseAccessDetails: CourseAccessDetails, + @SerializedName("certificate") + val certificate: Certificate?, + @SerializedName("enrollment_details") + val enrollmentDetails: EnrollmentDetails, + @SerializedName("course_info_overview") + val courseInfoOverview: CourseInfoOverview, +) { + fun mapToDomain(): DomainCourseEnrollmentDetails { + return DomainCourseEnrollmentDetails( + id = id, + courseUpdates = courseUpdates ?: "", + courseHandouts = courseHandouts ?: "", + discussionUrl = discussionUrl ?: "", + courseAccessDetails = courseAccessDetails.mapToDomain(), + certificate = certificate?.mapToDomain(), + enrollmentDetails = enrollmentDetails.mapToDomain(), + courseInfoOverview = courseInfoOverview.mapToDomain(), + ) + } +} diff --git a/core/src/main/java/org/openedx/core/data/model/CourseInfoOverview.kt b/core/src/main/java/org/openedx/core/data/model/CourseInfoOverview.kt new file mode 100644 index 000000000..57faedd2a --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseInfoOverview.kt @@ -0,0 +1,44 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.utils.TimeUtils +import org.openedx.core.domain.model.CourseInfoOverview as DomainCourseInfoOverview + +data class CourseInfoOverview( + @SerializedName("name") + val name: String, + @SerializedName("number") + val number: String, + @SerializedName("org") + val org: String, + @SerializedName("start") + val start: String?, + @SerializedName("start_display") + val startDisplay: String, + @SerializedName("start_type") + val startType: String, + @SerializedName("end") + val end: String?, + @SerializedName("is_self_paced") + val isSelfPaced: Boolean, + @SerializedName("media") + var media: Media?, + @SerializedName("course_sharing_utm_parameters") + val courseSharingUtmParameters: CourseSharingUtmParameters, + @SerializedName("course_about") + val courseAbout: String, +) { + fun mapToDomain() = DomainCourseInfoOverview( + name = name, + number = number, + org = org, + start = TimeUtils.iso8601ToDate(start ?: ""), + startDisplay = startDisplay, + startType = startType, + end = TimeUtils.iso8601ToDate(end ?: ""), + isSelfPaced = isSelfPaced, + media = media?.mapToDomain(), + courseSharingUtmParameters = courseSharingUtmParameters.mapToDomain(), + courseAbout = courseAbout, + ) +} diff --git a/core/src/main/java/org/openedx/core/data/model/CourseStructureModel.kt b/core/src/main/java/org/openedx/core/data/model/CourseStructureModel.kt index d09411d14..a21492dc7 100644 --- a/core/src/main/java/org/openedx/core/data/model/CourseStructureModel.kt +++ b/core/src/main/java/org/openedx/core/data/model/CourseStructureModel.kt @@ -33,8 +33,12 @@ data class CourseStructureModel( var coursewareAccess: CoursewareAccess?, @SerializedName("media") var media: Media?, + @SerializedName("course_access_details") + val courseAccessDetails: CourseAccessDetails, @SerializedName("certificate") val certificate: Certificate?, + @SerializedName("enrollment_details") + val enrollmentDetails: EnrollmentDetails, @SerializedName("is_self_paced") var isSelfPaced: Boolean?, @SerializedName("course_progress") @@ -58,7 +62,7 @@ data class CourseStructureModel( media = media?.mapToDomain(), certificate = certificate?.mapToDomain(), isSelfPaced = isSelfPaced ?: false, - progress = progress?.mapToDomain() + progress = progress?.mapToDomain(), ) } @@ -78,7 +82,7 @@ data class CourseStructureModel( media = MediaDb.createFrom(media), certificate = certificate?.mapToRoomEntity(), isSelfPaced = isSelfPaced ?: false, - progress = progress?.mapToRoomEntity() ?: ProgressDb.DEFAULT_PROGRESS + progress = progress?.mapToRoomEntity() ?: ProgressDb.DEFAULT_PROGRESS, ) } } diff --git a/core/src/main/java/org/openedx/core/data/model/EnrollmentDetails.kt b/core/src/main/java/org/openedx/core/data/model/EnrollmentDetails.kt new file mode 100644 index 000000000..668e97f07 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/EnrollmentDetails.kt @@ -0,0 +1,36 @@ +package org.openedx.core.data.model + +import android.os.Parcelable +import com.google.gson.annotations.SerializedName +import kotlinx.parcelize.Parcelize +import org.openedx.core.data.model.room.discovery.EnrollmentDetailsDB +import org.openedx.core.utils.TimeUtils + +import org.openedx.core.domain.model.EnrollmentDetails as DomainEnrollmentDetails + +data class EnrollmentDetails( + @SerializedName("created") + var created: String?, + @SerializedName("date") + val date: String?, + @SerializedName("mode") + val mode: String?, + @SerializedName("is_active") + val isActive: Boolean = false, + @SerializedName("upgrade_deadline") + val upgradeDeadline: String?, +) { + fun mapToDomain() = DomainEnrollmentDetails( + created = TimeUtils.iso8601ToDate(date ?: ""), + mode = mode, + isActive = isActive, + upgradeDeadline = TimeUtils.iso8601ToDate(upgradeDeadline ?: ""), + ) + + fun mapToRoomEntity() = EnrollmentDetailsDB( + created = created, + mode = mode, + isActive = isActive, + upgradeDeadline = upgradeDeadline, + ) +} 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 e019f6300..59de42e53 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 @@ -7,6 +7,7 @@ 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.CourseAccessDetails import org.openedx.core.domain.model.CourseAssignments import org.openedx.core.domain.model.CourseDateBlock import org.openedx.core.domain.model.CourseSharingUtmParameters @@ -14,6 +15,7 @@ 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.EnrollmentDetails import org.openedx.core.domain.model.Progress import org.openedx.core.utils.TimeUtils import java.util.Date @@ -244,3 +246,44 @@ data class CourseDateBlockDb( assignmentType = assignmentType ) } + +data class EnrollmentDetailsDB( + @ColumnInfo("created") + var created: String?, + @ColumnInfo("mode") + var mode: String?, + @ColumnInfo("isActive") + var isActive: Boolean, + @ColumnInfo("upgradeDeadline") + var upgradeDeadline: String?, +) { + fun mapToDomain() = EnrollmentDetails( + TimeUtils.iso8601ToDate(created ?: ""), + mode, + isActive, + TimeUtils.iso8601ToDate(upgradeDeadline ?: "") + ) +} + +data class CourseAccessDetailsDb( + @ColumnInfo("hasUnmetPrerequisites") + val hasUnmetPrerequisites: Boolean, + @ColumnInfo("isTooEarly") + val isTooEarly: Boolean, + @ColumnInfo("isStaff") + val isStaff: Boolean, + @ColumnInfo("auditAccessExpires") + var auditAccessExpires: String?, + @Embedded + val coursewareAccess: CoursewareAccessDb?, +) { + fun mapToDomain(): CourseAccessDetails { + return CourseAccessDetails( + hasUnmetPrerequisites = hasUnmetPrerequisites, + isTooEarly = isTooEarly, + isStaff = isStaff, + auditAccessExpires = TimeUtils.iso8601ToDate(auditAccessExpires ?: ""), + coursewareAccess = coursewareAccess?.mapToDomain() + ) + } +} diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseAccessDetails.kt b/core/src/main/java/org/openedx/core/domain/model/CourseAccessDetails.kt new file mode 100644 index 000000000..fac674e66 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseAccessDetails.kt @@ -0,0 +1,14 @@ +package org.openedx.core.domain.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import java.util.Date + +@Parcelize +data class CourseAccessDetails( + val hasUnmetPrerequisites: Boolean, + val isTooEarly: Boolean, + val isStaff: Boolean, + val auditAccessExpires: Date?, + val coursewareAccess: CoursewareAccess?, +) : Parcelable diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseEnrollmentDetails.kt b/core/src/main/java/org/openedx/core/domain/model/CourseEnrollmentDetails.kt new file mode 100644 index 000000000..5c61fee60 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseEnrollmentDetails.kt @@ -0,0 +1,30 @@ +package org.openedx.core.domain.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import org.openedx.core.extension.isNotNull +import java.util.Date + +@Parcelize +data class CourseEnrollmentDetails( + val id: String, + val courseUpdates: String, + val courseHandouts: String, + val discussionUrl: String, + val courseAccessDetails: CourseAccessDetails, + val certificate: Certificate?, + val enrollmentDetails: EnrollmentDetails, + val courseInfoOverview: CourseInfoOverview, +) : Parcelable { + + val hasAccess: Boolean + get() = courseAccessDetails.coursewareAccess?.hasAccess ?: false + + val isAuditAccessExpired: Boolean + get() = courseAccessDetails.auditAccessExpires.isNotNull() && + Date().after(courseAccessDetails.auditAccessExpires) +} + +enum class CourseAccessError { + NONE, AUDIT_EXPIRED_NOT_UPGRADABLE, NOT_YET_STARTED, UNKNOWN +} diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseInfoOverview.kt b/core/src/main/java/org/openedx/core/domain/model/CourseInfoOverview.kt new file mode 100644 index 000000000..4d02f10b9 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseInfoOverview.kt @@ -0,0 +1,23 @@ +package org.openedx.core.domain.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import java.util.Date + +@Parcelize +data class CourseInfoOverview( + val name: String, + val number: String, + val org: String, + val start: Date?, + val startDisplay: String, + val startType: String, + val end: Date?, + val isSelfPaced: Boolean, + var media: Media?, + val courseSharingUtmParameters: CourseSharingUtmParameters, + val courseAbout: String, +) : Parcelable { + val isStarted: Boolean + get() = start?.before(Date()) ?: false +} diff --git a/core/src/main/java/org/openedx/core/domain/model/EnrollmentDetails.kt b/core/src/main/java/org/openedx/core/domain/model/EnrollmentDetails.kt new file mode 100644 index 000000000..01882167b --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/EnrollmentDetails.kt @@ -0,0 +1,17 @@ +package org.openedx.core.domain.model + +import android.os.Parcelable +import androidx.room.ColumnInfo +import kotlinx.parcelize.Parcelize +import org.openedx.core.data.model.EnrollmentDetails +import org.openedx.core.extension.isNotNull +import java.util.Date + +@Parcelize +data class EnrollmentDetails( + val created: Date?, + val mode: String?, + val isActive: Boolean, + val upgradeDeadline: Date?, +) : Parcelable + diff --git a/core/src/main/java/org/openedx/core/extension/BooleanExt.kt b/core/src/main/java/org/openedx/core/extension/BooleanExt.kt new file mode 100644 index 000000000..4e9f69a0c --- /dev/null +++ b/core/src/main/java/org/openedx/core/extension/BooleanExt.kt @@ -0,0 +1,9 @@ +package org.openedx.core.extension + +fun Boolean?.isTrue(): Boolean { + return this == true +} + +fun Boolean?.isFalse(): Boolean { + return this == false +} diff --git a/core/src/main/java/org/openedx/core/extension/ObjectExt.kt b/core/src/main/java/org/openedx/core/extension/ObjectExt.kt new file mode 100644 index 000000000..c7a6c4db5 --- /dev/null +++ b/core/src/main/java/org/openedx/core/extension/ObjectExt.kt @@ -0,0 +1,9 @@ +package org.openedx.core.extension + +fun T?.isNotNull(): Boolean { + return this != null +} + +fun T?.isNull(): Boolean { + return this == null +} 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 f39b9369a..a2fb3cfc7 100644 --- a/core/src/main/java/org/openedx/core/utils/TimeUtils.kt +++ b/core/src/main/java/org/openedx/core/utils/TimeUtils.kt @@ -13,6 +13,7 @@ import java.text.SimpleDateFormat import java.util.Calendar import java.util.Date import java.util.Locale +import java.util.concurrent.TimeUnit object TimeUtils { @@ -224,6 +225,14 @@ object TimeUtils { } return formattedDate } + + /** + * Returns a formatted date string for the given date using context. + */ + fun getCourseAccessFormattedDate(context: Context, date: Date): String { + val resourceManager = ResourceManager(context) + return dateToCourseDate(resourceManager, date) + } } /** diff --git a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt index 8eaafe721..f79e46066 100644 --- a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt +++ b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt @@ -9,6 +9,7 @@ import org.openedx.core.data.model.room.OfflineXBlockProgress import org.openedx.core.data.model.room.XBlockProgressData import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.CourseComponentStatus +import org.openedx.core.domain.model.CourseEnrollmentDetails import org.openedx.core.domain.model.CourseStructure import org.openedx.core.exception.NoCachedDataException import org.openedx.core.module.db.DownloadDao @@ -70,6 +71,10 @@ class CourseRepository( return courseStructure[courseId]!! } + suspend fun getEnrollmentDetails(courseId: String): CourseEnrollmentDetails { + return api.getEnrollmentDetails(courseId = courseId).mapToDomain() + } + suspend fun getCourseStatus(courseId: String): CourseComponentStatus { val username = preferencesManager.user?.username ?: "" return api.getCourseStatus(username, courseId).mapToDomain() diff --git a/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt b/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt index 22248d57d..e91b309c3 100644 --- a/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt +++ b/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt @@ -2,6 +2,7 @@ package org.openedx.course.domain.interactor import org.openedx.core.BlockType import org.openedx.core.domain.model.Block +import org.openedx.core.domain.model.CourseEnrollmentDetails import org.openedx.core.domain.model.CourseStructure import org.openedx.course.data.repository.CourseRepository @@ -20,6 +21,10 @@ class CourseInteractor( return repository.getCourseStructureFromCache(courseId) } + suspend fun getEnrollmentDetails(courseId: String): CourseEnrollmentDetails { + return repository.getEnrollmentDetails(courseId = courseId) + } + suspend fun getCourseStructureForVideos( courseId: String, isNeedRefresh: Boolean = false diff --git a/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt b/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt index 3b59be61d..65ce5f012 100644 --- a/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt +++ b/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt @@ -62,4 +62,6 @@ interface CourseRouter { fun navigateToDownloadQueue(fm: FragmentManager, descendants: List = arrayListOf()) fun navigateToVideoQuality(fm: FragmentManager, videoQualityType: VideoQualityType) + + fun navigateToDiscover(fm: FragmentManager) } 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 b40387266..c4d1bd844 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 @@ -79,6 +79,7 @@ internal fun CollapsingLayout( modifier: Modifier = Modifier, courseImage: Bitmap, imageHeight: Int, + isEnabled: Boolean, expandedTop: @Composable BoxScope.() -> Unit, collapsedTop: @Composable BoxScope.() -> Unit, navigation: @Composable BoxScope.() -> Unit, @@ -166,10 +167,15 @@ internal fun CollapsingLayout( } } + val collapsingModifier = if (isEnabled) { + modifier + .nestedScroll(nestedScrollConnection) + } else { + modifier + } Box( - modifier = modifier + modifier = collapsingModifier .fillMaxSize() - .nestedScroll(nestedScrollConnection) .pointerInput(Unit) { var yStart = 0f coroutineScope { @@ -221,6 +227,7 @@ internal fun CollapsingLayout( backBtnStartPadding = backBtnStartPadding, courseImage = courseImage, imageHeight = imageHeight, + isEnabled = isEnabled, onBackClick = onBackClick, expandedTop = expandedTop, navigation = navigation, @@ -244,6 +251,7 @@ internal fun CollapsingLayout( courseImage = courseImage, imageHeight = imageHeight, toolbarBackgroundOffset = toolbarBackgroundOffset, + isEnabled = isEnabled, onBackClick = onBackClick, expandedTop = expandedTop, collapsedTop = collapsedTop, @@ -265,6 +273,7 @@ private fun CollapsingLayoutTablet( backBtnStartPadding: Dp, courseImage: Bitmap, imageHeight: Int, + isEnabled: Boolean, onBackClick: () -> Unit, expandedTop: @Composable BoxScope.() -> Unit, navigation: @Composable BoxScope.() -> Unit, @@ -408,15 +417,22 @@ private fun CollapsingLayoutTablet( content = navigation, ) - Box( - modifier = Modifier + val bodyPadding = expandedTopHeight.value + backgroundImageHeight.value + navigationHeight.value + val bodyModifier = if (isEnabled) { + Modifier .offset { IntOffset( x = 0, - y = (expandedTopHeight.value + backgroundImageHeight.value + navigationHeight.value).roundToInt() + y = bodyPadding.roundToInt() ) } - .padding(bottom = with(localDensity) { (expandedTopHeight.value + navigationHeight.value + backgroundImageHeight.value).toDp() }), + .padding(bottom = with(localDensity) { bodyPadding.toDp() }) + } else { + Modifier + .padding(top = with(localDensity) { if (bodyPadding < 0) 0.toDp() else bodyPadding.toDp() }) + } + Box( + modifier = bodyModifier, content = bodyContent, ) } @@ -439,6 +455,7 @@ private fun CollapsingLayoutMobile( courseImage: Bitmap, imageHeight: Int, toolbarBackgroundOffset: Int, + isEnabled: Boolean, onBackClick: () -> Unit, expandedTop: @Composable BoxScope.() -> Unit, collapsedTop: @Composable BoxScope.() -> Unit, @@ -712,15 +729,23 @@ private fun CollapsingLayoutMobile( content = navigation, ) - Box( - modifier = Modifier + val bodyPadding = + expandedTopHeight.value + offset.value + backgroundImageHeight.value + navigationHeight.value - blurImagePaddingPx * factor + val bodyModifier = if (isEnabled) { + Modifier .offset { IntOffset( x = 0, - y = (expandedTopHeight.value + offset.value + backgroundImageHeight.value + navigationHeight.value - blurImagePaddingPx * factor).roundToInt() + y = bodyPadding.roundToInt() ) } - .padding(bottom = with(localDensity) { (collapsedTopHeight.value + navigationHeight.value).toDp() }), + .padding(bottom = with(localDensity) { (collapsedTopHeight.value + navigationHeight.value).toDp() }) + } else { + Modifier + .padding(top = with(localDensity) { if (bodyPadding < 0) 0.toDp() else bodyPadding.toDp() }) + } + Box( + modifier = bodyModifier, content = bodyContent, ) } @@ -764,6 +789,7 @@ private fun CollapsingLayoutPreview() { pagerState = rememberPagerState(pageCount = { CourseContainerTab.entries.size }) ) }, + isEnabled = true, onBackClick = {}, bodyContent = {} ) 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 856b40c4f..c6f452c10 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 @@ -1,23 +1,29 @@ package org.openedx.course.presentation.container +import android.content.res.Configuration import android.os.Build import android.os.Bundle import android.util.Log import android.view.View import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Spacer 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.padding +import androidx.compose.foundation.layout.size 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.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold @@ -25,6 +31,7 @@ import androidx.compose.material.SnackbarData import androidx.compose.material.SnackbarDuration import androidx.compose.material.SnackbarHost import androidx.compose.material.SnackbarHostState +import androidx.compose.material.Text import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState @@ -41,12 +48,20 @@ 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.ColorFilter +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.os.bundleOf import androidx.core.view.isVisible import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentManager import androidx.lifecycle.lifecycleScope import com.google.android.material.snackbar.Snackbar @@ -55,12 +70,18 @@ import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf +import org.openedx.core.domain.model.CourseAccessError +import org.openedx.core.extension.isFalse import org.openedx.core.presentation.global.viewBinding import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OfflineModeDialog +import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.RoundTabsBar +import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appTypography +import org.openedx.core.utils.TimeUtils import org.openedx.course.DatesShiftedSnackBar import org.openedx.course.R import org.openedx.course.databinding.FragmentCourseContainerBinding @@ -75,6 +96,7 @@ import org.openedx.discussion.presentation.topics.DiscussionTopicsScreen import org.openedx.foundation.extension.takeIfNotEmpty import org.openedx.foundation.presentation.WindowSize import org.openedx.foundation.presentation.rememberWindowSize +import java.util.Date class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { @@ -84,7 +106,6 @@ 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_RESUME_BLOCK, "") ) } @@ -97,7 +118,7 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - viewModel.preloadCourseStructure() + viewModel.fetchCourseDetails() } private var snackBar: Snackbar? = null @@ -113,7 +134,9 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { override fun onResume() { super.onResume() - viewModel.updateData() + if (viewModel.courseAccessStatus.value == CourseAccessError.NONE) { + viewModel.updateData() + } } override fun onDestroyView() { @@ -123,12 +146,16 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { private fun observe() { viewModel.dataReady.observe(viewLifecycleOwner) { isReady -> - if (isReady == false) { + if (isReady.isFalse()) { viewModel.courseRouter.navigateToNoAccess( requireActivity().supportFragmentManager, viewModel.courseName ) } else { + if (viewModel.calendarSyncUIState.value.isCalendarSyncEnabled) { + setUpCourseCalendar() + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { pushNotificationPermissionLauncher.launch( android.Manifest.permission.POST_NOTIFICATIONS @@ -139,7 +166,7 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { viewModel.errorMessage.observe(viewLifecycleOwner) { snackBar = Snackbar.make(binding.root, it, Snackbar.LENGTH_INDEFINITE) .setAction(org.openedx.core.R.string.core_error_try_again) { - viewModel.preloadCourseStructure() + viewModel.fetchCourseDetails() } snackBar?.show() @@ -152,18 +179,21 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { } private fun onRefresh(currentPage: Int) { - viewModel.onRefresh(CourseContainerTab.entries[currentPage]) + if (viewModel.courseAccessStatus.value == CourseAccessError.NONE) { + viewModel.onRefresh(CourseContainerTab.entries[currentPage]) + } } private fun initCourseView() { binding.composeCollapsingLayout.setContent { val isNavigationEnabled by viewModel.isNavigationEnabled.collectAsState() + val fm = requireActivity().supportFragmentManager CourseDashboard( viewModel = viewModel, isNavigationEnabled = isNavigationEnabled, isResumed = isResumed, - fragmentManager = requireActivity().supportFragmentManager, - bundle = requireArguments(), + openTab = requireArguments().getString(ARG_OPEN_TAB, CourseContainerTab.HOME.name), + fragmentManager = fm, onRefresh = { page -> onRefresh(page) } @@ -192,21 +222,18 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { companion object { 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 = "" + resumeBlockId: String = "", ): CourseContainerFragment { val fragment = CourseContainerFragment() fragment.arguments = bundleOf( ARG_COURSE_ID to courseId, ARG_TITLE to courseTitle, - ARG_ENROLLMENT_MODE to enrollmentMode, ARG_OPEN_TAB to openTab, ARG_RESUME_BLOCK to resumeBlockId ) @@ -219,11 +246,11 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { @Composable fun CourseDashboard( viewModel: CourseContainerViewModel, - onRefresh: (page: Int) -> Unit, isNavigationEnabled: Boolean, isResumed: Boolean, + openTab: String, fragmentManager: FragmentManager, - bundle: Bundle + onRefresh: (page: Int) -> Unit, ) { OpenEdXTheme { val windowSize = rememberWindowSize() @@ -239,7 +266,6 @@ fun CourseDashboard( val refreshing by viewModel.refreshing.collectAsState(true) val courseImage by viewModel.courseImage.collectAsState() val uiMessage by viewModel.uiMessage.collectAsState(null) - 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 @@ -253,7 +279,7 @@ fun CourseDashboard( initialPage = CourseContainerTab.entries.indexOf(requiredTab), pageCount = { CourseContainerTab.entries.size } ) - val dataReady = viewModel.dataReady.observeAsState() + val accessStatus = viewModel.courseAccessStatus.observeAsState() val tabState = rememberLazyListState() val snackState = remember { SnackbarHostState() } val pullRefreshState = rememberPullRefreshState( @@ -275,108 +301,131 @@ fun CourseDashboard( tabState.animateScrollToItem(pagerState.currentPage) } - Box { - CollapsingLayout( - modifier = Modifier - .fillMaxWidth() - .padding(paddingValues) - .pullRefresh(pullRefreshState), - courseImage = courseImage, - imageHeight = 200, - expandedTop = { - ExpandedHeaderContent( - courseTitle = viewModel.courseName, - org = viewModel.organization - ) - }, - collapsedTop = { - CollapsedHeaderContent( - courseTitle = viewModel.courseName - ) - }, - navigation = { - if (isNavigationEnabled) { - RoundTabsBar( - items = CourseContainerTab.entries, - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 16.dp), - rowState = tabState, - pagerState = pagerState, - withPager = true, - onTabClicked = viewModel::courseContainerTabClickedEvent + Column( + modifier = Modifier.fillMaxSize() + ) { + Box( + modifier = Modifier.weight(1f) + ) { + CollapsingLayout( + modifier = Modifier + .fillMaxWidth() + .padding(paddingValues) + .pullRefresh(pullRefreshState), + courseImage = courseImage, + imageHeight = 200, + expandedTop = { + ExpandedHeaderContent( + courseTitle = viewModel.courseName, + org = viewModel.courseDetails?.courseInfoOverview?.org ?: "" ) - } else { - Spacer(modifier = Modifier.height(52.dp)) - } - }, - onBackClick = { - fragmentManager.popBackStack() - }, - bodyContent = { - if (dataReady.value == true) { - DashboardPager( - windowSize = windowSize, - viewModel = viewModel, - pagerState = pagerState, - isNavigationEnabled = isNavigationEnabled, - isResumed = isResumed, - fragmentManager = fragmentManager, - bundle = bundle + }, + collapsedTop = { + CollapsedHeaderContent( + courseTitle = viewModel.courseName ) - } - } - ) - PullRefreshIndicator( - refreshing, - pullRefreshState, - Modifier.align(Alignment.TopCenter) - ) - - var isInternetConnectionShown by rememberSaveable { - mutableStateOf(false) - } - if (!isInternetConnectionShown && !viewModel.hasInternetConnection) { - OfflineModeDialog( - Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter), - onDismissCLick = { - isInternetConnectionShown = true }, - onReloadClick = { - isInternetConnectionShown = true - onRefresh(pagerState.currentPage) - } - ) - } - - SnackbarHost( - modifier = Modifier.align(Alignment.BottomStart), - hostState = snackState - ) { snackbarData: SnackbarData -> - DatesShiftedSnackBar( - showAction = CourseContainerTab.entries[pagerState.currentPage] != CourseContainerTab.DATES, - onViewDates = { - scrollToDates(scope, pagerState) + navigation = { + if (isNavigationEnabled) { + RoundTabsBar( + items = CourseContainerTab.entries, + contentPadding = PaddingValues( + horizontal = 12.dp, + vertical = 16.dp + ), + rowState = tabState, + pagerState = pagerState, + withPager = true, + onTabClicked = viewModel::courseContainerTabClickedEvent + ) + } + }, + isEnabled = CourseAccessError.NONE == accessStatus.value, + onBackClick = { + fragmentManager.popBackStack() }, - onClose = { - snackbarData.dismiss() + bodyContent = { + when (accessStatus.value) { + CourseAccessError.AUDIT_EXPIRED_NOT_UPGRADABLE, + CourseAccessError.NOT_YET_STARTED, + CourseAccessError.UNKNOWN, + -> { + CourseAccessErrorView( + viewModel = viewModel, + accessError = accessStatus.value, + fragmentManager = fragmentManager, + ) + } + + CourseAccessError.NONE -> { + DashboardPager( + windowSize = windowSize, + viewModel = viewModel, + pagerState = pagerState, + isNavigationEnabled = isNavigationEnabled, + isResumed = isResumed, + fragmentManager = fragmentManager, + ) + } + + else -> { + } + } } ) + PullRefreshIndicator( + refreshing, + pullRefreshState, + Modifier.align(Alignment.TopCenter) + ) + + var isInternetConnectionShown by rememberSaveable { + mutableStateOf(false) + } + if (!isInternetConnectionShown && !viewModel.hasInternetConnection) { + OfflineModeDialog( + Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + onDismissCLick = { + isInternetConnectionShown = true + }, + onReloadClick = { + isInternetConnectionShown = true + onRefresh(pagerState.currentPage) + } + ) + } + + SnackbarHost( + modifier = Modifier.align(Alignment.BottomStart), + hostState = snackState + ) { snackbarData: SnackbarData -> + DatesShiftedSnackBar( + showAction = CourseContainerTab.entries[pagerState.currentPage] != CourseContainerTab.DATES, + onViewDates = { + scrollToDates(scope, pagerState) + }, + onClose = { + snackbarData.dismiss() + } + ) + } } } } } } +@OptIn(ExperimentalFoundationApi::class) @Composable -fun DashboardPager( +private fun DashboardPager( windowSize: WindowSize, viewModel: CourseContainerViewModel, pagerState: PagerState, isNavigationEnabled: Boolean, isResumed: Boolean, fragmentManager: FragmentManager, - bundle: Bundle, ) { HorizontalPager( state = pagerState, @@ -388,12 +437,7 @@ fun DashboardPager( CourseOutlineScreen( windowSize = windowSize, viewModel = koinViewModel( - parameters = { - parametersOf( - bundle.getString(CourseContainerFragment.ARG_COURSE_ID, ""), - bundle.getString(CourseContainerFragment.ARG_TITLE, "") - ) - } + parameters = { parametersOf(viewModel.courseId, viewModel.courseName) } ), fragmentManager = fragmentManager, onResetDatesClick = { @@ -406,12 +450,7 @@ fun DashboardPager( CourseVideosScreen( windowSize = windowSize, viewModel = koinViewModel( - parameters = { - parametersOf( - bundle.getString(CourseContainerFragment.ARG_COURSE_ID, ""), - bundle.getString(CourseContainerFragment.ARG_TITLE, "") - ) - } + parameters = { parametersOf(viewModel.courseId, viewModel.courseName) } ), fragmentManager = fragmentManager ) @@ -422,9 +461,9 @@ fun DashboardPager( viewModel = koinViewModel( parameters = { parametersOf( - bundle.getString(CourseContainerFragment.ARG_COURSE_ID, ""), - bundle.getString(CourseContainerFragment.ARG_TITLE, ""), - bundle.getString(CourseContainerFragment.ARG_ENROLLMENT_MODE, "") + viewModel.courseId, + viewModel.courseName, + viewModel.courseDetails?.enrollmentDetails?.mode ?: "" ) } ), @@ -441,12 +480,7 @@ fun DashboardPager( CourseOfflineScreen( windowSize = windowSize, viewModel = koinViewModel( - parameters = { - parametersOf( - bundle.getString(CourseContainerFragment.ARG_COURSE_ID, ""), - bundle.getString(CourseContainerFragment.ARG_TITLE, "") - ) - } + parameters = { parametersOf(viewModel.courseId, viewModel.courseName) } ), fragmentManager = fragmentManager, ) @@ -455,12 +489,7 @@ fun DashboardPager( CourseContainerTab.DISCUSSIONS -> { DiscussionTopicsScreen( discussionTopicsViewModel = koinViewModel( - parameters = { - parametersOf( - bundle.getString(CourseContainerFragment.ARG_COURSE_ID, ""), - bundle.getString(CourseContainerFragment.ARG_TITLE, ""), - ) - } + parameters = { parametersOf(viewModel.courseId, viewModel.courseName) } ), windowSize = windowSize, fragmentManager = fragmentManager @@ -473,14 +502,14 @@ fun DashboardPager( onHandoutsClick = { viewModel.courseRouter.navigateToHandoutsWebView( fragmentManager, - bundle.getString(CourseContainerFragment.ARG_COURSE_ID, ""), + viewModel.courseId, HandoutsType.Handouts ) }, onAnnouncementsClick = { viewModel.courseRouter.navigateToHandoutsWebView( fragmentManager, - bundle.getString(CourseContainerFragment.ARG_COURSE_ID, ""), + viewModel.courseId, HandoutsType.Announcements ) }) @@ -489,9 +518,128 @@ fun DashboardPager( } } +@Composable +private fun CourseAccessErrorView( + viewModel: CourseContainerViewModel?, + accessError: CourseAccessError?, + fragmentManager: FragmentManager, +) { + var icon: Painter = painterResource(id = R.drawable.course_ic_circled_arrow_up) + var message = "" + when (accessError) { + CourseAccessError.AUDIT_EXPIRED_NOT_UPGRADABLE -> { + message = stringResource( + R.string.course_error_expired_not_upgradeable_title, + TimeUtils.getCourseAccessFormattedDate( + LocalContext.current, + viewModel?.courseDetails?.courseAccessDetails?.auditAccessExpires ?: Date() + ) + ) + } + + CourseAccessError.NOT_YET_STARTED -> { + icon = painterResource(id = R.drawable.course_ic_calendar) + message = stringResource( + R.string.course_error_not_started_title, + viewModel?.courseDetails?.courseInfoOverview?.startDisplay ?: "" + ) + } + + CourseAccessError.UNKNOWN -> { + icon = painterResource(id = R.drawable.course_ic_not_supported_block) + message = stringResource(R.string.course_an_error_occurred) + } + + else -> {} + } + + + Box( + modifier = Modifier + .fillMaxSize() + .statusBarsInset() + .background(MaterialTheme.appColors.background), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + if (LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT) { + Image( + modifier = Modifier + .size(96.dp) + .padding(bottom = 12.dp), + painter = icon, + contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.appColors.progressBarBackgroundColor), + ) + } + Text( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp), + textAlign = TextAlign.Center, + text = message, + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.textDark + ) + } + SetupCourseAccessErrorButtons( + accessError = accessError, + fragmentManager = fragmentManager, + ) + } + } +} + +@Composable +private fun SetupCourseAccessErrorButtons( + accessError: CourseAccessError?, + fragmentManager: FragmentManager, +) { + when (accessError) { + CourseAccessError.AUDIT_EXPIRED_NOT_UPGRADABLE, + CourseAccessError.NOT_YET_STARTED, + CourseAccessError.UNKNOWN, + -> { + OpenEdXButton( + text = stringResource(R.string.course_label_back), + onClick = { fragmentManager.popBackStack() }, + ) + } + + else -> {} + } +} + @OptIn(ExperimentalFoundationApi::class) private fun scrollToDates(scope: CoroutineScope, pagerState: PagerState) { scope.launch { pagerState.animateScrollToPage(CourseContainerTab.entries.indexOf(CourseContainerTab.DATES)) } } + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun CourseAccessErrorViewPreview() { + val context = LocalContext.current + OpenEdXTheme { + CourseAccessErrorView( + viewModel = null, + accessError = CourseAccessError.AUDIT_EXPIRED_NOT_UPGRADABLE, + fragmentManager = (context as? FragmentActivity)?.supportFragmentManager!! + ) + } +} 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 f27227b8f..a743730ec 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 @@ -6,6 +6,9 @@ import android.os.Build import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -17,7 +20,11 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.CourseAccessError +import org.openedx.core.domain.model.CourseEnrollmentDetails import org.openedx.core.exception.NoCachedDataException +import org.openedx.core.extension.isFalse +import org.openedx.core.extension.isTrue import org.openedx.core.presentation.settings.calendarsync.CalendarSyncDialogType import org.openedx.core.presentation.settings.calendarsync.CalendarSyncUIState import org.openedx.core.system.connection.NetworkConnection @@ -45,7 +52,6 @@ import org.openedx.foundation.presentation.BaseViewModel import org.openedx.foundation.presentation.SingleEventLiveData import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager -import java.util.Date import java.util.concurrent.atomic.AtomicReference import org.openedx.core.R as CoreR @@ -53,7 +59,6 @@ class CourseContainerViewModel( val courseId: String, var courseName: String, private var resumeBlockId: String, - private val enrollmentMode: String, private val config: Config, private val interactor: CourseInteractor, private val resourceManager: ResourceManager, @@ -70,6 +75,10 @@ class CourseContainerViewModel( val dataReady: LiveData get() = _dataReady + private val _courseAccessStatus = MutableLiveData() + val courseAccessStatus: LiveData + get() = _courseAccessStatus + private val _errorMessage = SingleEventLiveData() val errorMessage: LiveData get() = _errorMessage @@ -90,13 +99,9 @@ class CourseContainerViewModel( val uiMessage: SharedFlow get() = _uiMessage.asSharedFlow() - private var _isSelfPaced: Boolean = true - val isSelfPaced: Boolean - get() = _isSelfPaced - - private var _organization: String = "" - val organization: String - get() = _organization + private var _courseDetails: CourseEnrollmentDetails? = null + val courseDetails: CourseEnrollmentDetails? + get() = _courseDetails private val _calendarSyncUIState = MutableStateFlow( CalendarSyncUIState( @@ -155,7 +160,7 @@ class CourseContainerViewModel( } } - fun preloadCourseStructure() { + fun fetchCourseDetails() { courseDashboardViewed() if (_dataReady.value != null) { return @@ -164,30 +169,51 @@ class CourseContainerViewModel( _showProgress.value = true viewModelScope.launch { try { - val courseStructure = interactor.getCourseStructure(courseId, true) - courseName = courseStructure.name - _organization = courseStructure.org - _isSelfPaced = courseStructure.isSelfPaced - loadCourseImage(courseStructure.media?.image?.large) - _dataReady.value = courseStructure.start?.let { start -> - val isReady = start < Date() - if (isReady) { + val deferredCourse = async(SupervisorJob()) { + interactor.getCourseStructure(courseId, isNeedRefresh = true) + } + val deferredEnrollment = async(SupervisorJob()) { + interactor.getEnrollmentDetails(courseId) + } + val (_, enrollment) = awaitAll(deferredCourse, deferredEnrollment) + _courseDetails = enrollment as? CourseEnrollmentDetails + _showProgress.value = false + _courseDetails?.let { courseDetails -> + courseName = courseDetails.courseInfoOverview.name + loadCourseImage(courseDetails.courseInfoOverview.media?.image?.large) + if (courseDetails.hasAccess.isFalse()) { + _dataReady.value = false + if (courseDetails.isAuditAccessExpired) { + _courseAccessStatus.value = + CourseAccessError.AUDIT_EXPIRED_NOT_UPGRADABLE + } else if (courseDetails.courseInfoOverview.isStarted.not()) { + _courseAccessStatus.value = CourseAccessError.NOT_YET_STARTED + } else { + _courseAccessStatus.value = CourseAccessError.UNKNOWN + } + } else { + _courseAccessStatus.value = CourseAccessError.NONE _isNavigationEnabled.value = true + _calendarSyncUIState.update { state -> + state.copy(isCalendarSyncEnabled = isCalendarSyncEnabled()) + } + if (resumeBlockId.isNotEmpty()) { + delay(500L) + courseNotifier.send(CourseOpenBlock(resumeBlockId)) + } } - isReady - } - if (_dataReady.value == true && resumeBlockId.isNotEmpty()) { - delay(500L) - courseNotifier.send(CourseOpenBlock(resumeBlockId)) + } ?: run { + _courseAccessStatus.value = CourseAccessError.UNKNOWN } } catch (e: Exception) { + e.printStackTrace() if (e.isInternetError() || e is NoCachedDataException) { _errorMessage.value = resourceManager.getString(CoreR.string.core_error_no_connection) } else { - _errorMessage.value = - resourceManager.getString(CoreR.string.core_error_unknown_error) + _courseAccessStatus.value = CourseAccessError.UNKNOWN } + _showProgress.value = false } } } @@ -280,8 +306,8 @@ class CourseContainerViewModel( private fun isCalendarSyncEnabled(): Boolean { val calendarSync = corePreferences.appConfig.courseDatesCalendarSync - return calendarSync.isEnabled && ((calendarSync.isSelfPacedEnabled && isSelfPaced) || - (calendarSync.isInstructorPacedEnabled && !isSelfPaced)) + return calendarSync.isEnabled && ((calendarSync.isSelfPacedEnabled && _courseDetails?.courseInfoOverview?.isSelfPaced.isTrue()) || + (calendarSync.isInstructorPacedEnabled && _courseDetails?.courseInfoOverview?.isSelfPaced.isFalse())) } private fun courseDashboardViewed() { @@ -335,10 +361,13 @@ class CourseContainerViewModel( params = buildMap { put(CourseAnalyticsKey.NAME.key, event.biValue) put(CourseAnalyticsKey.COURSE_ID.key, courseId) - put(CourseAnalyticsKey.ENROLLMENT_MODE.key, enrollmentMode) + put( + CourseAnalyticsKey.ENROLLMENT_MODE.key, + _courseDetails?.enrollmentDetails?.mode ?: "" + ) put( CourseAnalyticsKey.PACING.key, - if (isSelfPaced) CourseAnalyticsKey.SELF_PACED.key + if (_courseDetails?.courseInfoOverview?.isSelfPaced.isTrue()) CourseAnalyticsKey.SELF_PACED.key else CourseAnalyticsKey.INSTRUCTOR_PACED.key ) putAll(param) 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 6d6b10af7..f1b9119ff 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 @@ -143,7 +143,7 @@ fun CourseOutlineScreen( onDownloadClick = { blocksIds -> viewModel.downloadBlocks( blocksIds = blocksIds, - fragmentManager = fragmentManager + fragmentManager = fragmentManager, ) }, onResetDatesClick = { @@ -630,7 +630,7 @@ private val mockSequentialBlock = Block( containsGatedContent = false, assignmentProgress = mockAssignmentProgress, due = Date(), - offlineDownload = OfflineDownload("fileUrl", "", 1) + offlineDownload = OfflineDownload("fileUrl", "", 1), ) private val mockCourseStructure = CourseStructure( @@ -655,5 +655,5 @@ private val mockCourseStructure = CourseStructure( media = null, certificate = null, isSelfPaced = false, - progress = Progress(1, 3) + progress = Progress(1, 3), ) 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 b613bea49..9e997ed5f 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 @@ -40,6 +40,7 @@ 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 import org.openedx.course.presentation.download.DownloadDialogManager import org.openedx.foundation.extension.isInternetError import org.openedx.foundation.presentation.UIMessage @@ -102,7 +103,7 @@ class CourseOutlineViewModel( when (event) { is CourseStructureUpdated -> { if (event.courseId == courseId) { - updateCourseData() + getCourseData() } } @@ -135,14 +136,21 @@ class CourseOutlineViewModel( getCourseData() } - fun updateCourseData() { - getCourseDataInternal() + override fun saveDownloadModels(folder: String, id: String) { + if (preferencesManager.videoSettings.wifiDownloadOnly) { + if (networkConnection.isWifiConnected()) { + super.saveDownloadModels(folder, id) + } else { + viewModelScope.launch { + _uiMessage.emit(UIMessage.ToastMessage(resourceManager.getString(courseR.string.course_can_download_only_with_wifi))) + } + } + } else { + super.saveDownloadModels(folder, id) + } } fun getCourseData() { - viewModelScope.launch { - courseNotifier.send(CourseLoading(true)) - } getCourseDataInternal() } @@ -222,7 +230,6 @@ class CourseOutlineViewModel( datesBannerInfo = datesBannerInfo, useRelativeDates = preferencesManager.isRelativeDatesEnabled ) - courseNotifier.send(CourseLoading(false)) } catch (e: Exception) { _uiState.value = CourseOutlineUIState.Error if (e.isInternetError()) { @@ -272,7 +279,7 @@ class CourseOutlineViewModel( viewModelScope.launch { try { interactor.resetCourseDates(courseId = courseId) - updateCourseData() + getCourseData() courseNotifier.send(CourseDatesShifted) onResetDates(true) } catch (e: Exception) { 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 f8bcd7355..72e37ee5b 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 @@ -764,5 +764,5 @@ private val mockCourseStructure = CourseStructure( media = null, certificate = null, isSelfPaced = false, - progress = Progress(1, 3) + progress = Progress(1, 3), ) diff --git a/course/src/main/res/drawable/course_ic_calendar.xml b/course/src/main/res/drawable/course_ic_calendar.xml new file mode 100644 index 000000000..c8f12ef7a --- /dev/null +++ b/course/src/main/res/drawable/course_ic_calendar.xml @@ -0,0 +1,9 @@ + + + diff --git a/course/src/main/res/drawable/course_ic_circled_arrow_up.xml b/course/src/main/res/drawable/course_ic_circled_arrow_up.xml new file mode 100644 index 000000000..aab47473e --- /dev/null +++ b/course/src/main/res/drawable/course_ic_circled_arrow_up.xml @@ -0,0 +1,32 @@ + + + + + + diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index c0b03e756..eefe590d8 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -99,4 +99,9 @@ %1$s of %2$s assignments complete + Back + Your free audit access to this course expired on %s. + Find a new course + This course will begin on %s. Come back then to start learning! + An error occurred while loading your course 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 5f9f19756..98cf58a8b 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 @@ -25,13 +25,18 @@ import org.junit.rules.TestRule import org.openedx.core.R import org.openedx.core.config.Config import org.openedx.core.data.api.CourseApi -import org.openedx.core.data.model.CourseStructureModel import org.openedx.core.data.model.User import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.AppConfig +import org.openedx.core.domain.model.CourseAccessDetails +import org.openedx.core.domain.model.CourseAccessError import org.openedx.core.domain.model.CourseDatesCalendarSync +import org.openedx.core.domain.model.CourseEnrollmentDetails +import org.openedx.core.domain.model.CourseInfoOverview +import org.openedx.core.domain.model.CourseSharingUtmParameters import org.openedx.core.domain.model.CourseStructure import org.openedx.core.domain.model.CoursewareAccess +import org.openedx.core.domain.model.EnrollmentDetails import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseStructureUpdated @@ -57,7 +62,7 @@ class CourseContainerViewModelTest { private val config = mockk() private val interactor = mockk() private val networkConnection = mockk() - private val notifier = spyk() + private val courseNotifier = spyk() private val analytics = mockk() private val corePreferences = mockk() private val mockBitmap = mockk() @@ -84,6 +89,35 @@ class CourseContainerViewModelTest { isDeepLinkEnabled = false, ) ) + private val courseDetails = CourseEnrollmentDetails( + id = "id", + courseUpdates = "", + courseHandouts = "", + discussionUrl = "", + courseAccessDetails = CourseAccessDetails( + false, + false, + false, + null, + coursewareAccess = CoursewareAccess( + false, "", "", "", + "", "" + + ) + ), + certificate = null, + enrollmentDetails = EnrollmentDetails( + null, "audit", false, Date() + ), + courseInfoOverview = CourseInfoOverview( + "Open edX Demo Course", "", "OpenedX", Date(), + "", "", null, false, null, + CourseSharingUtmParameters("", ""), + "", + ) + + ) + private val courseStructure = CourseStructure( root = "", blockData = listOf(), @@ -109,22 +143,31 @@ class CourseContainerViewModelTest { progress = null ) - private val courseStructureModel = CourseStructureModel( - root = "", - blockData = mapOf(), - id = "id", - name = "Course name", - number = "", - org = "Org", - start = "", - startDisplay = "", - startType = "", - end = null, - coursewareAccess = null, - media = null, + private val enrollmentDetails = CourseEnrollmentDetails( + id = "", + courseUpdates = "", + courseHandouts = "", + discussionUrl = "", + courseAccessDetails = CourseAccessDetails( + false, + false, + false, + null, + CoursewareAccess( + false, "", "", "", + "", "" + ) + ), certificate = null, - isSelfPaced = false, - progress = null + enrollmentDetails = EnrollmentDetails( + null, "", false, null + ), + courseInfoOverview = CourseInfoOverview( + "Open edX Demo Course", "", "OpenedX", null, + "", "", null, false, null, + CourseSharingUtmParameters("", ""), + "", + ) ) @Before @@ -135,8 +178,9 @@ class CourseContainerViewModelTest { every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong every { corePreferences.user } returns user every { corePreferences.appConfig } returns appConfig - every { notifier.notifier } returns emptyFlow() + every { courseNotifier.notifier } returns emptyFlow() every { config.getApiHostURL() } returns "baseUrl" + coEvery { interactor.getEnrollmentDetails(any()) } returns courseDetails every { imageProcessor.loadImage(any(), any(), any()) } returns Unit every { imageProcessor.applyBlur(any(), any()) } returns mockBitmap } @@ -147,16 +191,15 @@ class CourseContainerViewModelTest { } @Test - fun `getCourseStructure internet connection exception`() = runTest { + fun `getCourseEnrollmentDetails internet connection exception`() = runTest { val viewModel = CourseContainerViewModel( "", "", "", - "", config, interactor, resourceManager, - notifier, + courseNotifier, networkConnection, corePreferences, analytics, @@ -165,31 +208,41 @@ class CourseContainerViewModelTest { courseRouter, ) every { networkConnection.isOnline() } returns true - coEvery { interactor.getCourseStructure(any(), any()) } throws UnknownHostException() - every { analytics.logScreenEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } returns Unit - viewModel.preloadCourseStructure() + coEvery { interactor.getCourseStructure(any(), any()) } returns courseStructure + coEvery { interactor.getEnrollmentDetails(any()) } throws UnknownHostException() + every { + analytics.logScreenEvent( + CourseAnalyticsEvent.DASHBOARD.eventName, + any() + ) + } returns Unit + viewModel.fetchCourseDetails() advanceUntilIdle() - coVerify(exactly = 1) { interactor.getCourseStructure(any(), any()) } - verify(exactly = 1) { analytics.logScreenEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } + coVerify(exactly = 1) { interactor.getEnrollmentDetails(any()) } + verify(exactly = 1) { + analytics.logScreenEvent( + CourseAnalyticsEvent.DASHBOARD.eventName, + any() + ) + } val message = viewModel.errorMessage.value assertEquals(noInternet, message) assert(!viewModel.refreshing.value) - assert(viewModel.dataReady.value == null) + assert(viewModel.courseAccessStatus.value == null) } @Test - fun `getCourseStructure unknown exception`() = runTest { + fun `getCourseEnrollmentDetails unknown exception`() = runTest { val viewModel = CourseContainerViewModel( "", "", "", - "", config, interactor, resourceManager, - notifier, + courseNotifier, networkConnection, corePreferences, analytics, @@ -198,31 +251,38 @@ class CourseContainerViewModelTest { courseRouter ) every { networkConnection.isOnline() } returns true - coEvery { interactor.getCourseStructure(any(), any()) } throws Exception() - every { analytics.logScreenEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } returns Unit - viewModel.preloadCourseStructure() + coEvery { interactor.getCourseStructure(any(), any()) } returns courseStructure + coEvery { interactor.getEnrollmentDetails(any()) } throws Exception() + every { + analytics.logScreenEvent( + CourseAnalyticsEvent.DASHBOARD.eventName, + any() + ) + } returns Unit + viewModel.fetchCourseDetails() advanceUntilIdle() - coVerify(exactly = 1) { interactor.getCourseStructure(any(), any()) } - verify(exactly = 1) { analytics.logScreenEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } - - val message = viewModel.errorMessage.value - assertEquals(somethingWrong, message) + coVerify(exactly = 1) { interactor.getEnrollmentDetails(any()) } + verify(exactly = 1) { + analytics.logScreenEvent( + CourseAnalyticsEvent.DASHBOARD.eventName, + any() + ) + } assert(!viewModel.refreshing.value) - assert(viewModel.dataReady.value == null) + assert(viewModel.courseAccessStatus.value == CourseAccessError.UNKNOWN) } @Test - fun `getCourseStructure success with internet`() = runTest { + fun `getCourseEnrollmentDetails success with internet`() = runTest { val viewModel = CourseContainerViewModel( "", "", "", - "", config, interactor, resourceManager, - notifier, + courseNotifier, networkConnection, corePreferences, analytics, @@ -232,29 +292,38 @@ class CourseContainerViewModelTest { ) every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseStructure(any(), any()) } returns courseStructure - every { analytics.logScreenEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } returns Unit - viewModel.preloadCourseStructure() + coEvery { interactor.getEnrollmentDetails(any()) } returns enrollmentDetails + every { + analytics.logScreenEvent( + CourseAnalyticsEvent.DASHBOARD.eventName, + any() + ) + } returns Unit + viewModel.fetchCourseDetails() advanceUntilIdle() - coVerify(exactly = 1) { interactor.getCourseStructure(any(), any()) } - verify(exactly = 1) { analytics.logScreenEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } - + coVerify(exactly = 1) { interactor.getEnrollmentDetails(any()) } + verify(exactly = 1) { + analytics.logScreenEvent( + CourseAnalyticsEvent.DASHBOARD.eventName, + any() + ) + } assert(viewModel.errorMessage.value == null) assert(!viewModel.refreshing.value) - assert(viewModel.dataReady.value != null) + assert(viewModel.courseAccessStatus.value != null) } @Test - fun `getCourseStructure success without internet`() = runTest { + fun `getCourseEnrollmentDetails success without internet`() = runTest { val viewModel = CourseContainerViewModel( "", "", "", - "", config, interactor, resourceManager, - notifier, + courseNotifier, networkConnection, corePreferences, analytics, @@ -263,20 +332,26 @@ class CourseContainerViewModelTest { courseRouter ) every { networkConnection.isOnline() } returns false - coEvery { interactor.getCourseStructure(any(), any()) } returns courseStructure - every { analytics.logScreenEvent(any(), any()) } returns Unit - coEvery { - courseApi.getCourseStructure(any(), any(), any(), any()) - } returns courseStructureModel - viewModel.preloadCourseStructure() + coEvery { interactor.getEnrollmentDetails(any()) } returns enrollmentDetails + every { + analytics.logScreenEvent( + CourseAnalyticsEvent.DASHBOARD.eventName, + any() + ) + } returns Unit + viewModel.fetchCourseDetails() advanceUntilIdle() - - coVerify(exactly = 0) { courseApi.getCourseStructure(any(), any(), any(), any()) } - verify(exactly = 1) { analytics.logScreenEvent(any(), any()) } + coVerify(exactly = 0) { courseApi.getEnrollmentDetails(any()) } + verify(exactly = 1) { + analytics.logScreenEvent( + CourseAnalyticsEvent.DASHBOARD.eventName, + any() + ) + } assert(viewModel.errorMessage.value == null) assert(!viewModel.refreshing.value) - assert(viewModel.dataReady.value != null) + assert(viewModel.courseAccessStatus.value != null) } @Test @@ -285,11 +360,10 @@ class CourseContainerViewModelTest { "", "", "", - "", config, interactor, resourceManager, - notifier, + courseNotifier, networkConnection, corePreferences, analytics, @@ -298,7 +372,7 @@ class CourseContainerViewModelTest { courseRouter ) coEvery { interactor.getCourseStructure(any(), true) } throws UnknownHostException() - coEvery { notifier.send(CourseStructureUpdated("")) } returns Unit + coEvery { courseNotifier.send(CourseStructureUpdated("")) } returns Unit viewModel.updateData() advanceUntilIdle() @@ -315,11 +389,10 @@ class CourseContainerViewModelTest { "", "", "", - "", config, interactor, resourceManager, - notifier, + courseNotifier, networkConnection, corePreferences, analytics, @@ -328,7 +401,7 @@ class CourseContainerViewModelTest { courseRouter ) coEvery { interactor.getCourseStructure(any(), true) } throws Exception() - coEvery { notifier.send(CourseStructureUpdated("")) } returns Unit + coEvery { courseNotifier.send(CourseStructureUpdated("")) } returns Unit viewModel.updateData() advanceUntilIdle() @@ -345,11 +418,10 @@ class CourseContainerViewModelTest { "", "", "", - "", config, interactor, resourceManager, - notifier, + courseNotifier, networkConnection, corePreferences, analytics, @@ -357,8 +429,9 @@ class CourseContainerViewModelTest { calendarSyncScheduler, courseRouter ) + coEvery { interactor.getEnrollmentDetails(any()) } returns courseDetails coEvery { interactor.getCourseStructure(any(), true) } returns courseStructure - coEvery { notifier.send(CourseStructureUpdated("")) } returns Unit + coEvery { courseNotifier.send(CourseStructureUpdated("")) } returns Unit viewModel.updateData() advanceUntilIdle() 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 f9392df33..a8d4466dd 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 @@ -27,11 +27,14 @@ import org.openedx.core.CalendarRouter import org.openedx.core.R import org.openedx.core.config.Config import org.openedx.core.data.model.DateType +import org.openedx.core.data.model.User import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.interactor.CalendarInteractor +import org.openedx.core.domain.model.AppConfig import org.openedx.core.domain.model.CourseCalendarState import org.openedx.core.domain.model.CourseDateBlock import org.openedx.core.domain.model.CourseDatesBannerInfo +import org.openedx.core.domain.model.CourseDatesCalendarSync import org.openedx.core.domain.model.CourseDatesResult import org.openedx.core.domain.model.CourseStructure import org.openedx.core.domain.model.CoursewareAccess @@ -60,6 +63,7 @@ class CourseDatesViewModelTest { private val resourceManager = mockk() private val notifier = mockk() private val interactor = mockk() + private val corePreferences = mockk() private val analytics = mockk() private val config = mockk() private val courseRouter = mockk() @@ -72,6 +76,20 @@ class CourseDatesViewModelTest { private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" + private val user = User( + id = 0, + username = "", + email = "", + name = "", + ) + private val appConfig = AppConfig( + CourseDatesCalendarSync( + isEnabled = true, + isSelfPacedEnabled = true, + isInstructorPacedEnabled = true, + isDeepLinkEnabled = false, + ) + ) private val dateBlock = CourseDateBlock( complete = false, date = Date(), @@ -135,6 +153,8 @@ class CourseDatesViewModelTest { every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong coEvery { interactor.getCourseStructure(any()) } returns courseStructure + every { corePreferences.user } returns user + every { corePreferences.appConfig } returns appConfig every { notifier.notifier } returns flowOf(CourseLoading(false)) coEvery { notifier.send(any()) } returns Unit coEvery { notifier.send(any()) } returns Unit 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 a9ea6c5e9..58574b5bd 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 @@ -464,11 +464,10 @@ class CourseOutlineViewModelTest { } } viewModel.getCourseData() - viewModel.updateCourseData() advanceUntilIdle() - coVerify(exactly = 3) { interactor.getCourseStructure(any()) } - coVerify(exactly = 3) { interactor.getCourseStatus(any()) } + coVerify(exactly = 2) { interactor.getCourseStructure(any()) } + coVerify(exactly = 2) { interactor.getCourseStatus(any()) } assert(message.await() == null) assert(viewModel.uiState.value is CourseOutlineUIState.CourseData) diff --git a/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt index a63cbddf7..ffb1d124d 100644 --- a/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt @@ -331,4 +331,4 @@ class CourseUnitContainerViewModelTest { coVerify(exactly = 1) { interactor.getCourseStructureForVideos(any()) } } -} \ No newline at end of file +} 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 f3b6a5aee..910605415 100644 --- a/dashboard/src/androidTest/java/org/openedx/dashboard/presentation/MyCoursesScreenTest.kt +++ b/dashboard/src/androidTest/java/org/openedx/dashboard/presentation/MyCoursesScreenTest.kt @@ -17,6 +17,7 @@ import org.openedx.core.domain.model.CourseSharingUtmParameters 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.WindowSize import org.openedx.core.ui.WindowType import java.util.Date @@ -61,7 +62,10 @@ class MyCoursesScreenTest { discussionUrl = "", videoOutline = "", isSelfPaced = false - ) + ), + progress = Progress(0, 0), + courseStatus = null, + courseAssignments = null, ) //endregion @@ -81,7 +85,6 @@ class MyCoursesScreenTest { paginationCallback = {}, onItemClick = {}, appUpgradeParameters = AppUpdateState.AppUpgradeParameters(), - onSettingsClick = {} ) } @@ -114,7 +117,6 @@ class MyCoursesScreenTest { paginationCallback = {}, onItemClick = {}, appUpgradeParameters = AppUpdateState.AppUpgradeParameters(), - onSettingsClick = {} ) } @@ -140,7 +142,6 @@ class MyCoursesScreenTest { paginationCallback = {}, onItemClick = {}, appUpgradeParameters = AppUpdateState.AppUpgradeParameters(), - onSettingsClick = {} ) } @@ -162,5 +163,4 @@ class MyCoursesScreenTest { ) } } - } diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt index e6ca810a1..10fefe8f1 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt @@ -141,7 +141,6 @@ fun AllEnrolledCoursesView( fragmentManager, course.id, course.name, - mode ) } } diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesViewModel.kt b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesViewModel.kt index 9c6129623..ccba20242 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesViewModel.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesViewModel.kt @@ -168,14 +168,12 @@ class AllEnrolledCoursesViewModel( fragmentManager: FragmentManager, courseId: String, courseName: String, - mode: String ) { dashboardCourseClickedEvent(courseId, courseName) dashboardRouter.navigateToCourseOutline( - fragmentManager, - courseId, - courseName, - mode + fm = fragmentManager, + courseId = courseId, + courseTitle = courseName ) } } diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt index cf36699f1..b40e662f3 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt @@ -134,7 +134,6 @@ class DashboardGalleryViewModel( 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 ) diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt index 3ab2d7555..2e7669bb1 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt @@ -143,10 +143,9 @@ class DashboardListFragment : Fragment() { onItemClick = { viewModel.dashboardCourseClickedEvent(it.course.id, it.course.name) router.navigateToCourseOutline( - requireActivity().supportFragmentManager, - it.course.id, - it.course.name, - it.mode + fm = requireActivity().supportFragmentManager, + courseId = it.course.id, + courseTitle = it.course.name, ) }, onSwipeRefresh = { diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardRouter.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardRouter.kt index 2c712bad6..d96744ff1 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardRouter.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardRouter.kt @@ -9,7 +9,6 @@ interface DashboardRouter { fm: FragmentManager, courseId: String, courseTitle: String, - enrollmentMode: String, openTab: String = "", resumeBlockId: String = "" ) diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index df9d1f401..19e53ef73 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -81,4 +81,4 @@ REGISTRATION_ENABLED: true UI_COMPONENTS: COURSE_DROPDOWN_NAVIGATION_ENABLED: false COURSE_UNIT_PROGRESS_ENABLED: false - COURSE_DOWNLOAD_QUEUE_SCREEN: false \ No newline at end of file + COURSE_DOWNLOAD_QUEUE_SCREEN: false diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryRouter.kt b/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryRouter.kt index e1c4baa74..2e67af44a 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryRouter.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryRouter.kt @@ -8,7 +8,6 @@ interface DiscoveryRouter { fm: FragmentManager, courseId: String, courseTitle: String, - enrollmentMode: String ) fun navigateToLogistration(fm: FragmentManager, courseId: String?) diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt index 4bf51b23b..056ce8bae 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt @@ -165,7 +165,6 @@ class CourseDetailsFragment : Fragment() { requireActivity().supportFragmentManager, currentState.course.courseId, currentState.course.name, - enrollmentMode = "" ) } diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoViewModel.kt b/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoViewModel.kt index d5a935df3..fd88591ca 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoViewModel.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoViewModel.kt @@ -131,7 +131,6 @@ class CourseInfoViewModel( fm = fragmentManager, courseId = courseId, courseTitle = "", - enrollmentMode = "" ) } } diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramViewModel.kt b/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramViewModel.kt index 2263861bf..bacc9b3a1 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramViewModel.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramViewModel.kt @@ -90,7 +90,6 @@ class ProgramViewModel( fm = fragmentManager, courseId = courseId, courseTitle = "", - enrollmentMode = "" ) } viewModelScope.launch {