diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 879f8b83b..fe5aa5869 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -39,7 +39,7 @@ jobs: - name: Generate mock files run: ./gradlew generateMockedRawFile - name: Run unit tests - run: ./gradlew testProdReleaseUnitTest $CI_GRADLE_ARG_PROPERTIES + run: ./gradlew testProdDebugUnitTest $CI_GRADLE_ARG_PROPERTIES - name: Upload reports uses: actions/upload-artifact@v3 diff --git a/Documentation/ConfigurationManagement.md b/Documentation/ConfigurationManagement.md index 1aed9a5ab..b1e21a50b 100644 --- a/Documentation/ConfigurationManagement.md +++ b/Documentation/ConfigurationManagement.md @@ -61,7 +61,7 @@ MICROSOFT: CLIENT_ID: 'microsoftClientID' ``` -Also, all envirenment folders contain a `file_mappings.yaml` file that points to the config files to be parsed. +Also, all environment folders contain a `file_mappings.yaml` file that points to the config files to be parsed. By modifying `file_mappings.yaml`, you can achieve splitting of the base `config.yaml` or add additional configuration files. @@ -81,7 +81,7 @@ android: - **Microsoft:** Sign in and Sign up via Microsoft - **Facebook:** Sign in and Sign up via Facebook - **Branch:** Deeplinks -- **Braze:** Could Messaging +- **Braze:** Cloud Messaging - **SegmentIO:** Analytics ## Available Feature Flags @@ -89,8 +89,6 @@ 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_NESTED_LIST_ENABLED:** Enables an alternative visual representation for the course structure. -- **COURSE_BANNER_ENABLED:** Enables the display of the course image on the Course Home screen. -- **COURSE_TOP_TAB_BAR_ENABLED:** Enables an alternative navigation on the Course Home screen. - **COURSE_UNIT_PROGRESS_ENABLED:** Enables the display of the unit progress within the courseware. ## Future Support diff --git a/README.md b/README.md index 265d49683..c8453877a 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Modern vision of the mobile application for the Open edX platform from Raccoon G 3. Choose ``openedx-app-android``. -4. Configure `config_settings.yaml` inside `default_config` and `config.yaml` inside sub direcroties to point to your Open edX configuration. [Configuration Docuementation](./Documentation/ConfigurationManagement.md) +4. Configure `config_settings.yaml` inside `default_config` and `config.yaml` inside sub directories to point to your Open edX configuration. [Configuration Documentation](./Documentation/ConfigurationManagement.md) 5. Select the build variant ``develop``, ``stage``, or ``prod``. diff --git a/app/src/main/java/org/openedx/app/AppRouter.kt b/app/src/main/java/org/openedx/app/AppRouter.kt index 474f4a8e9..21f3b5aee 100644 --- a/app/src/main/java/org/openedx/app/AppRouter.kt +++ b/app/src/main/java/org/openedx/app/AppRouter.kt @@ -43,11 +43,13 @@ import org.openedx.discussion.presentation.threads.DiscussionAddThreadFragment 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.anothers_account.AnothersProfileFragment +import org.openedx.profile.presentation.anothersaccount.AnothersProfileFragment import org.openedx.profile.presentation.delete.DeleteProfileFragment import org.openedx.profile.presentation.edit.EditProfileFragment +import org.openedx.profile.presentation.manageaccount.ManageAccountFragment import org.openedx.profile.presentation.profile.ProfileFragment -import org.openedx.profile.presentation.settings.video.VideoSettingsFragment +import org.openedx.profile.presentation.settings.SettingsFragment +import org.openedx.profile.presentation.video.VideoSettingsFragment import org.openedx.whatsnew.WhatsNewRouter import org.openedx.whatsnew.presentation.whatsnew.WhatsNewFragment @@ -328,22 +330,14 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di replaceFragmentWithBackStack(fm, EditProfileFragment.newInstance(account)) } - override fun navigateToVideoSettings(fm: FragmentManager) { - replaceFragmentWithBackStack(fm, VideoSettingsFragment()) - } - - override fun navigateToVideoQuality(fm: FragmentManager, videoQualityType: VideoQualityType) { - replaceFragmentWithBackStack(fm, VideoQualityFragment.newInstance(videoQualityType.name)) - } - override fun navigateToDeleteAccount(fm: FragmentManager) { replaceFragmentWithBackStack(fm, DeleteProfileFragment()) } - override fun navigateToWebContent(fm: FragmentManager, title: String, url: String) { + override fun navigateToSettings(fm: FragmentManager) { replaceFragmentWithBackStack( fm, - WebContentFragment.newInstance(title = title, url = url) + SettingsFragment() ) } @@ -357,6 +351,25 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di } } } + + override fun navigateToVideoSettings(fm: FragmentManager) { + replaceFragmentWithBackStack(fm, VideoSettingsFragment()) + } + + override fun navigateToVideoQuality(fm: FragmentManager, videoQualityType: VideoQualityType) { + replaceFragmentWithBackStack(fm, VideoQualityFragment.newInstance(videoQualityType.name)) + } + + override fun navigateToWebContent(fm: FragmentManager, title: String, url: String) { + replaceFragmentWithBackStack( + fm, + WebContentFragment.newInstance(title = title, url = url) + ) + } + + override fun navigateToManageAccount(fm: FragmentManager) { + replaceFragmentWithBackStack(fm, ManageAccountFragment()) + } //endregion private fun replaceFragmentWithBackStack(fm: FragmentManager, fragment: Fragment) { @@ -384,4 +397,5 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di .replace(R.id.container, ProfileFragment()) .commit() } + //endregion } 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 dc0a70335..16a30c0c6 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -25,6 +25,7 @@ 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.ImageProcessor import org.openedx.core.config.Config import org.openedx.core.data.model.CourseEnrollments import org.openedx.core.data.storage.CorePreferences @@ -81,6 +82,8 @@ val appModule = module { single { ReviewManagerFactory.create(get()) } single { CalendarManager(get(), get(), get()) } + single { ImageProcessor(get()) } + single { GsonBuilder() .registerTypeAdapter(CourseEnrollments::class.java, CourseEnrollments.Deserializer()) 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 b4547a583..4efd1a19e 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -52,11 +52,13 @@ import org.openedx.discussion.presentation.topics.DiscussionTopicsViewModel 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.anothers_account.AnothersProfileViewModel +import org.openedx.profile.presentation.anothersaccount.AnothersProfileViewModel import org.openedx.profile.presentation.delete.DeleteProfileViewModel import org.openedx.profile.presentation.edit.EditProfileViewModel +import org.openedx.profile.presentation.manageaccount.ManageAccountViewModel import org.openedx.profile.presentation.profile.ProfileViewModel -import org.openedx.profile.presentation.settings.video.VideoSettingsViewModel +import org.openedx.profile.presentation.settings.SettingsViewModel +import org.openedx.profile.presentation.video.VideoSettingsViewModel import org.openedx.whatsnew.presentation.whatsnew.WhatsNewViewModel val screenModule = module { @@ -134,17 +136,11 @@ val screenModule = module { factory { ProfileInteractor(get()) } viewModel { ProfileViewModel( - appData = get(), - config = get(), interactor = get(), resourceManager = get(), notifier = get(), - dispatcher = get(named("IODispatcher")), - cookieManager = get(), - workerController = get(), analytics = get(), - appUpgradeNotifier = get(), - router = get(), + profileRouter = get(), ) } viewModel { (account: Account) -> EditProfileViewModel(get(), get(), get(), get(), account) } @@ -152,6 +148,8 @@ 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 { ManageAccountViewModel(get(), get(), get(), get(), get()) } single { CourseRepository(get(), get(), get(), get()) } factory { CourseInteractor(get()) } @@ -195,11 +193,14 @@ val screenModule = module { get(), get(), get(), + get(), + get() ) } - viewModel { (courseId: String) -> + viewModel { (courseId: String, courseTitle: String) -> CourseOutlineViewModel( courseId, + courseTitle, get(), get(), get(), @@ -236,9 +237,10 @@ val screenModule = module { get(), ) } - viewModel { (courseId: String) -> + viewModel { (courseId: String, courseTitle: String) -> CourseVideoViewModel( courseId, + courseTitle, get(), get(), get(), @@ -277,11 +279,8 @@ val screenModule = module { get(), ) } - viewModel { (courseId: String, courseName: String, isSelfPaced: Boolean, enrollmentMode: String) -> + viewModel { (enrollmentMode: String) -> CourseDatesViewModel( - courseId, - courseName, - isSelfPaced, enrollmentMode, get(), get(), @@ -290,7 +289,6 @@ val screenModule = module { get(), get(), get(), - get(), ) } viewModel { (courseId: String, handoutsType: String) -> @@ -307,13 +305,13 @@ val screenModule = module { single { DiscussionRepository(get(), get(), get()) } factory { DiscussionInteractor(get()) } - viewModel { (courseId: String) -> + viewModel { DiscussionTopicsViewModel( get(), get(), get(), get(), - courseId + get() ) } viewModel { (courseId: String, topicId: String, threadType: String) -> @@ -377,4 +375,5 @@ val screenModule = module { viewModel { HtmlUnitViewModel(get(), get(), get(), get()) } viewModel { ProgramViewModel(get(), get(), get(), get(), get(), get(), get()) } + } diff --git a/build.gradle b/build.gradle index 6ebb49a56..ef9ca662c 100644 --- a/build.gradle +++ b/build.gradle @@ -13,8 +13,8 @@ buildscript { } plugins { - id 'com.android.application' version '8.3.0' apply false - id 'com.android.library' version '8.3.0' apply false + 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 diff --git a/catalog-info.yaml b/catalog-info.yaml index 87c6bc3ad..16a164f20 100644 --- a/catalog-info.yaml +++ b/catalog-info.yaml @@ -10,6 +10,8 @@ metadata: - url: "https://github.com/openedx/openedx-app-android/tree/main/Documentation" title: "Documentation" icon: "PhoneAndroid" + annotations: + openedx.org/release: "main" spec: owner: group:openedx-mobile-maintainers type: 'mobile' diff --git a/core/build.gradle b/core/build.gradle index 8c4bdcc6f..f1f091823 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -139,6 +139,7 @@ dependencies { // 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" diff --git a/core/src/main/java/org/openedx/core/ImageProcessor.kt b/core/src/main/java/org/openedx/core/ImageProcessor.kt new file mode 100644 index 000000000..d3a6c4a4c --- /dev/null +++ b/core/src/main/java/org/openedx/core/ImageProcessor.kt @@ -0,0 +1,57 @@ +@file:Suppress("DEPRECATION") + +package org.openedx.core + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import android.renderscript.Allocation +import android.renderscript.RenderScript +import android.renderscript.ScriptIntrinsicBlur +import androidx.annotation.DrawableRes +import coil.ImageLoader +import coil.request.ImageRequest + +class ImageProcessor(private val context: Context) { + fun loadImage( + @DrawableRes + defaultImage: Int, + imageUrl: String, + onComplete: (result: Drawable) -> Unit + ) { + val loader = ImageLoader(context) + val request = ImageRequest.Builder(context) + .data(imageUrl) + .target { result -> + onComplete(result) + } + .error(defaultImage) + .placeholder(defaultImage) + .allowHardware(false) + .build() + loader.enqueue(request) + } + + fun applyBlur( + bitmap: Bitmap, + blurRadio: Float + ): Bitmap { + val renderScript = RenderScript.create(context) + val bitmapAlloc = Allocation.createFromBitmap(renderScript, bitmap) + ScriptIntrinsicBlur.create(renderScript, bitmapAlloc.element).apply { + setRadius(blurRadio) + setInput(bitmapAlloc) + repeat(3) { + forEach(bitmapAlloc) + } + } + val newBitmap: Bitmap = Bitmap.createBitmap( + bitmap.width, + bitmap.height, + Bitmap.Config.ARGB_8888 + ) + bitmapAlloc.copyTo(newBitmap) + renderScript.destroy() + return newBitmap + } +} 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 9f626cc2e..4b40fbc29 100644 --- a/core/src/main/java/org/openedx/core/config/Config.kt +++ b/core/src/main/java/org/openedx/core/config/Config.kt @@ -107,14 +107,6 @@ class Config(context: Context) { return getBoolean(COURSE_NESTED_LIST_ENABLED, false) } - fun isCourseBannerEnabled(): Boolean { - return getBoolean(COURSE_BANNER_ENABLED, true) - } - - fun isCourseTopTabBarEnabled(): Boolean { - return getBoolean(COURSE_TOP_TAB_BAR_ENABLED, false) - } - fun isCourseUnitProgressEnabled(): Boolean { return getBoolean(COURSE_UNIT_PROGRESS_ENABLED, false) } @@ -174,8 +166,6 @@ class Config(context: Context) { private const val PROGRAM = "PROGRAM" private const val BRANCH = "BRANCH" private const val COURSE_NESTED_LIST_ENABLED = "COURSE_NESTED_LIST_ENABLED" - private const val COURSE_BANNER_ENABLED = "COURSE_BANNER_ENABLED" - private const val COURSE_TOP_TAB_BAR_ENABLED = "COURSE_TOP_TAB_BAR_ENABLED" private const val COURSE_UNIT_PROGRESS_ENABLED = "COURSE_UNIT_PROGRESS_ENABLED" private const val PLATFORM_NAME = "PLATFORM_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 b6e8a33dc..40cc94e4d 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 @@ -1,6 +1,5 @@ package org.openedx.core.module.download -import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow @@ -41,8 +40,7 @@ abstract class BaseDownloadViewModel( private val _downloadingModelsFlow = MutableSharedFlow>() protected val downloadingModelsFlow = _downloadingModelsFlow.asSharedFlow() - override fun onCreate(owner: LifecycleOwner) { - super.onCreate(owner) + init { viewModelScope.launch { downloadDao.readAllData().map { list -> list.map { it.mapToDomain() } } .collect { downloadModels -> @@ -246,10 +244,7 @@ abstract class BaseDownloadViewModel( logEvent( CoreAnalyticsEvent.VIDEO_BULK_DOWNLOAD_TOGGLE, buildMap { - put( - CoreAnalyticsKey.ACTION.key, - if (toggle) CoreAnalyticsKey.TRUE.key else CoreAnalyticsKey.FALSE.key - ) + put(CoreAnalyticsKey.ACTION.key, toggle) } ) } diff --git a/core/src/main/java/org/openedx/core/presentation/CoreAnalytics.kt b/core/src/main/java/org/openedx/core/presentation/CoreAnalytics.kt index ed9ebd853..227fb6f4c 100644 --- a/core/src/main/java/org/openedx/core/presentation/CoreAnalytics.kt +++ b/core/src/main/java/org/openedx/core/presentation/CoreAnalytics.kt @@ -50,8 +50,6 @@ enum class CoreAnalyticsKey(val key: String) { OLD_VALUE("old_value"), COURSE_ID("course_id"), BLOCK_ID("block_id"), - TRUE("true"), - FALSE("false"), NUMBER_OF_VIDEOS("number_of_videos"), } diff --git a/core/src/main/java/org/openedx/core/presentation/course/CourseContainerTab.kt b/core/src/main/java/org/openedx/core/presentation/course/CourseContainerTab.kt new file mode 100644 index 000000000..51d235c36 --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/course/CourseContainerTab.kt @@ -0,0 +1,24 @@ +package org.openedx.core.presentation.course + +import androidx.annotation.StringRes +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Chat +import androidx.compose.material.icons.automirrored.filled.TextSnippet +import androidx.compose.material.icons.filled.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 + +enum class CourseContainerTab( + @StringRes + override val labelResId: Int, + 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) +} diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/appreview/AppReviewAnalytics.kt b/core/src/main/java/org/openedx/core/presentation/dialog/appreview/AppReviewAnalytics.kt index 975c62ce9..82027216e 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/appreview/AppReviewAnalytics.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/appreview/AppReviewAnalytics.kt @@ -19,7 +19,7 @@ enum class AppReviewAnalyticsKey(val key: String) { NAME("name"), CATEGORY("category"), RATING("rating"), - APP_REVIEW("app_review"), + APP_REVIEWS("app_reviews"), ACTION("action"), DISMISSED("dismissed"), NOT_NOW("not_now"), 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 dac71cf77..57dcdc233 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 @@ -26,7 +26,7 @@ open class BaseAppReviewDialogFragment : DialogFragment() { event = AppReviewAnalyticsEvent.RATING_DIALOG.eventName, params = buildMap { put(AppReviewAnalyticsKey.NAME.key, AppReviewAnalyticsEvent.RATING_DIALOG.biValue) - put(AppReviewAnalyticsKey.CATEGORY.key, AppReviewAnalyticsKey.APP_REVIEW.key) + put(AppReviewAnalyticsKey.CATEGORY.key, AppReviewAnalyticsKey.APP_REVIEWS.key) } ) } @@ -65,7 +65,7 @@ open class BaseAppReviewDialogFragment : DialogFragment() { AppReviewAnalyticsKey.NAME.key, AppReviewAnalyticsEvent.RATING_DIALOG_ACTION.biValue ) - put(AppReviewAnalyticsKey.CATEGORY.key, AppReviewAnalyticsKey.APP_REVIEW.key) + put(AppReviewAnalyticsKey.CATEGORY.key, AppReviewAnalyticsKey.APP_REVIEWS.key) put(AppReviewAnalyticsKey.ACTION.key, action) rating.nonZero()?.let { put(AppReviewAnalyticsKey.RATING.key, it) } } 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 new file mode 100644 index 000000000..0ad123d17 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/CourseDataReady.kt @@ -0,0 +1,5 @@ +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/CourseDatesShifted.kt b/core/src/main/java/org/openedx/core/system/notifier/CourseDatesShifted.kt new file mode 100644 index 000000000..fe3cf54c0 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/CourseDatesShifted.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier + +object CourseDatesShifted : CourseEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/CourseLoading.kt b/core/src/main/java/org/openedx/core/system/notifier/CourseLoading.kt new file mode 100644 index 000000000..bcd5d6231 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/CourseLoading.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier + +data class CourseLoading(val isLoading: Boolean) : 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 455d6e53c..63660b4de 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 @@ -16,4 +16,8 @@ class CourseNotifier { suspend fun send(event: CourseSectionChanged) = channel.emit(event) suspend fun send(event: CourseCompletionSet) = channel.emit(event) 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/core/src/main/java/org/openedx/core/system/notifier/CourseRefresh.kt b/core/src/main/java/org/openedx/core/system/notifier/CourseRefresh.kt new file mode 100644 index 000000000..c85fc595d --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/CourseRefresh.kt @@ -0,0 +1,5 @@ +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/CourseStructureUpdated.kt b/core/src/main/java/org/openedx/core/system/notifier/CourseStructureUpdated.kt index e89956d0e..0587f5eb4 100644 --- a/core/src/main/java/org/openedx/core/system/notifier/CourseStructureUpdated.kt +++ b/core/src/main/java/org/openedx/core/system/notifier/CourseStructureUpdated.kt @@ -1,6 +1,5 @@ package org.openedx.core.system.notifier class CourseStructureUpdated( - val courseId: String, - val withSwipeRefresh: Boolean, + val courseId: String ) : CourseEvent \ 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 8e71eebcd..3b97742f1 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt @@ -2,12 +2,15 @@ package org.openedx.core.ui import android.os.Build.VERSION.SDK_INT import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme 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.RowScope import androidx.compose.foundation.layout.Spacer @@ -21,7 +24,13 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState +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.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions @@ -37,6 +46,7 @@ import androidx.compose.material.ScaffoldState import androidx.compose.material.Text 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.Search import androidx.compose.runtime.Composable @@ -45,6 +55,7 @@ import androidx.compose.runtime.NonRestartableComposable 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 @@ -60,6 +71,7 @@ import androidx.compose.ui.focus.onFocusChanged 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.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager @@ -88,6 +100,7 @@ import coil.ImageLoader import coil.compose.AsyncImage import coil.decode.GifDecoder import coil.decode.ImageDecoderDecoder +import kotlinx.coroutines.launch import org.openedx.core.R import org.openedx.core.UIMessage import org.openedx.core.domain.model.RegistrationField @@ -146,7 +159,11 @@ fun Toolbar( modifier: Modifier = Modifier, label: String, canShowBackBtn: Boolean = false, + canShowSettingsIcon: Boolean = false, + labelTint: Color = MaterialTheme.appColors.textPrimary, + iconTint: Color = MaterialTheme.appColors.textPrimary, onBackClick: () -> Unit = {}, + onSettingsClick: () -> Unit = {}, ) { Box( modifier = modifier @@ -154,7 +171,10 @@ fun Toolbar( .height(48.dp), ) { if (canShowBackBtn) { - BackBtn(onBackClick = onBackClick) + BackBtn( + tint = iconTint, + onBackClick = onBackClick + ) } Text( @@ -164,12 +184,27 @@ fun Toolbar( .align(Alignment.Center) .padding(horizontal = 48.dp), text = label, - color = MaterialTheme.appColors.textPrimary, + color = labelTint, style = MaterialTheme.appTypography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center, ) + + if (canShowSettingsIcon) { + IconButton( + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = 12.dp), + onClick = { onSettingsClick() } + ) { + Icon( + painter = painterResource(id = R.drawable.core_ic_settings), + tint = MaterialTheme.appColors.primary, + contentDescription = stringResource(id = R.string.core_accessibility_settings) + ) + } + } } } @@ -1085,7 +1120,7 @@ fun BackBtn( onClick = { onBackClick() }) { Icon( painter = painterResource(id = R.drawable.core_ic_back), - contentDescription = "back", + contentDescription = stringResource(id = R.string.core_accessibility_btn_back), tint = tint ) } @@ -1161,6 +1196,80 @@ fun AuthButtonsPanel( } } +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun RoundTabsBar( + modifier: Modifier = Modifier, + items: List, + pagerState: PagerState, + rowState: LazyListState = rememberLazyListState(), + onPageChange: (Int) -> Unit +) { + 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), + ) { + itemsIndexed(items) { index, item -> + val isSelected = pagerState.currentPage == index + val backgroundColor = + if (isSelected) MaterialTheme.appColors.primary else MaterialTheme.appColors.tabUnselectedBtnBackground + val contentColor = + if (isSelected) MaterialTheme.appColors.tabSelectedBtnContent else MaterialTheme.appColors.tabUnselectedBtnContent + val border = if (!isSystemInDarkTheme()) Modifier.border( + 1.dp, + MaterialTheme.appColors.primary, + CircleShape + ) else Modifier + + RoundTab( + modifier = Modifier + .height(40.dp) + .clip(CircleShape) + .background(backgroundColor) + .then(border) + .clickable { + scope.launch { + pagerState.scrollToPage(index) + onPageChange(index) + } + } + .padding(horizontal = 12.dp), + item = item, + contentColor = contentColor + ) + } + } +} + +@Composable +private fun RoundTab( + modifier: Modifier = Modifier, + item: TabItem, + contentColor: Color +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon( + painter = rememberVectorPainter(item.icon), + tint = contentColor, + contentDescription = null + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = stringResource(item.labelResId), + color = contentColor + ) + } +} + @Preview @Composable private fun StaticSearchBarPreview() { @@ -1239,3 +1348,22 @@ private fun ConnectionErrorViewPreview() { ) } } + +val mockTab = object : TabItem { + override val labelResId: Int = R.string.app_name + override val icon: ImageVector = Icons.Default.AccountCircle +} + +@OptIn(ExperimentalFoundationApi::class) +@Preview +@Composable +private fun RoundTabsBarPreview() { + OpenEdXTheme { + RoundTabsBar( + items = listOf(mockTab, mockTab, mockTab), + rowState = rememberLazyListState(), + pagerState = rememberPagerState(pageCount = { 3 }), + onPageChange = { } + ) + } +} 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 0cfa9c57c..6cf198f53 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeExtensions.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeExtensions.kt @@ -20,22 +20,27 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.mapSaver import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.draw.paint import androidx.compose.ui.graphics.Color 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.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity 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 kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.launch +import org.openedx.core.R import org.openedx.core.presentation.global.InsetHolder inline val isPreview: Boolean @@ -87,7 +92,7 @@ fun Modifier.displayCutoutForLandscape(): Modifier = composed { } inline fun Modifier.noRippleClickable(crossinline onClick: () -> Unit): Modifier = composed { - clickable( + this then Modifier.clickable( indication = null, interactionSource = remember { MutableInteractionSource() }) { onClick() @@ -178,4 +183,13 @@ fun LazyListState.reEnableScrolling(scope: CoroutineScope) { @OptIn(ExperimentalFoundationApi::class) fun PagerState.calculateCurrentOffsetForPage(page: Int): Float { return (currentPage - page) + currentPageOffsetFraction +} + +fun Modifier.settingsHeaderBackground(): Modifier = composed { + return@composed this + .paint( + painter = painterResource(id = R.drawable.core_top_header), + contentScale = ContentScale.FillWidth, + alignment = Alignment.TopCenter + ) } \ No newline at end of file diff --git a/core/src/main/java/org/openedx/core/ui/TabItem.kt b/core/src/main/java/org/openedx/core/ui/TabItem.kt new file mode 100644 index 000000000..65a88861e --- /dev/null +++ b/core/src/main/java/org/openedx/core/ui/TabItem.kt @@ -0,0 +1,10 @@ +package org.openedx.core.ui + +import androidx.annotation.StringRes +import androidx.compose.ui.graphics.vector.ImageVector + +interface TabItem { + @get:StringRes + val labelResId: Int + val icon: ImageVector +} 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 9db8faa60..4b7a0ba10 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,14 @@ data class AppColors( val componentHorizontalProgressCompleted: Color, val componentHorizontalProgressSelected: Color, val componentHorizontalProgressDefault: Color, + + val tabUnselectedBtnBackground: Color, + val tabUnselectedBtnContent: Color, + val tabSelectedBtnContent: Color, + val courseHomeHeaderShade: Color, + val courseHomeBackBtnBackground: Color, + + val settingsTitleContent: 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 01ae95110..1ffa3c73d 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 @@ -71,6 +71,14 @@ private val DarkColorPalette = AppColors( componentHorizontalProgressCompleted = dark_component_horizontal_progress_completed, componentHorizontalProgressSelected = dark_component_horizontal_progress_selected, componentHorizontalProgressDefault = dark_component_horizontal_progress_default, + + tabUnselectedBtnBackground = dark_tab_unselected_btn_background, + tabUnselectedBtnContent = dark_tab_unselected_btn_content, + tabSelectedBtnContent = dark_tab_selected_btn_content, + courseHomeHeaderShade = dark_course_home_header_shade, + courseHomeBackBtnBackground = dark_course_home_back_btn_background, + + settingsTitleContent = dark_settings_title_content ) private val LightColorPalette = AppColors( @@ -134,6 +142,14 @@ private val LightColorPalette = AppColors( componentHorizontalProgressCompleted = light_component_horizontal_progress_completed, componentHorizontalProgressSelected = light_component_horizontal_progress_selected, componentHorizontalProgressDefault = light_component_horizontal_progress_default, + + tabUnselectedBtnBackground = light_tab_unselected_btn_background, + tabUnselectedBtnContent = light_tab_unselected_btn_content, + tabSelectedBtnContent = light_tab_selected_btn_content, + courseHomeHeaderShade = light_course_home_header_shade, + courseHomeBackBtnBackground = light_course_home_back_btn_background, + + settingsTitleContent = light_settings_title_content ) val MaterialTheme.appColors: AppColors diff --git a/core/src/main/res/drawable/core_ic_settings.xml b/core/src/main/res/drawable/core_ic_settings.xml new file mode 100644 index 000000000..a86316516 --- /dev/null +++ b/core/src/main/res/drawable/core_ic_settings.xml @@ -0,0 +1,20 @@ + + + + diff --git a/core/src/main/res/values-uk/strings.xml b/core/src/main/res/values-uk/strings.xml index dc6e2ffa2..f20cd28e1 100644 --- a/core/src/main/res/values-uk/strings.xml +++ b/core/src/main/res/values-uk/strings.xml @@ -67,4 +67,9 @@ Заглавне зображення для курсу %1$s Якість транслювання відео + + Курс + Відео + Обговорення + Матеріали diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index 40c288675..ed4b1d99d 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -7,11 +7,11 @@ Slow or no internet connection Something went wrong Try again - Privacy policy + Privacy Policy Cookie policy Do not sell my personal information View FAQ - Terms of use + Terms of Use Profile Cancel Search @@ -55,6 +55,7 @@ Update Deprecated App Version Account Settings + Settings App Update Required This version of the OpenEdX app is out-of-date. To continue learning and get the latest features and fixes, please upgrade to the latest version. Why do I need to update? @@ -118,6 +119,9 @@ %1$s profile image Header image for %1$s + Settings + Back + Expandable Arrow Download to device Downloading videos… @@ -126,4 +130,11 @@ Videos %d, %s Total Video streaming quality Video download quality + Manage Account + + Home + Videos + Discussions + More + Dates 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 35d695cc1..1cc4c3495 100644 --- a/core/src/openedx/org/openedx/core/ui/theme/Colors.kt +++ b/core/src/openedx/org/openedx/core/ui/theme/Colors.kt @@ -50,6 +50,12 @@ val light_component_horizontal_progress_completed_and_selected = Color(0xFF30a17 val light_component_horizontal_progress_completed = Color(0xFFbbe6d7) val light_component_horizontal_progress_selected = Color(0xFFF0CB00) val light_component_horizontal_progress_default = Color(0xFFD6D3D1) +val light_tab_unselected_btn_background = Color.White +val light_tab_unselected_btn_content = light_primary +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 dark_primary = Color(0xFF5478F9) @@ -100,3 +106,9 @@ val dark_component_horizontal_progress_completed_and_selected = Color(0xFF30a171 val dark_component_horizontal_progress_completed = Color(0xFFbbe6d7) val dark_component_horizontal_progress_selected = Color(0xFFF0CB00) val dark_component_horizontal_progress_default = Color(0xFFD6D3D1) +val dark_tab_unselected_btn_background = Color(0xFF273346) +val dark_tab_unselected_btn_content = Color.White +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 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 14de268f3..8151226c0 100644 --- a/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt +++ b/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt @@ -61,7 +61,7 @@ enum class CourseAnalyticsEvent(val eventName: String, val biValue: String) { "Course:Dates Tab", "edx.bi.app.course.dates_tab" ), - HANDOUTS_TAB( + MORE_TAB( "Course:Handouts Tab", "edx.bi.app.course.handouts_tab" ), @@ -129,12 +129,12 @@ enum class CourseAnalyticsEvent(val eventName: String, val biValue: String) { "PLS:Shift Button Clicked", "edx.bi.app.dates.pls_banner.shift_dates.clicked" ), - PLS_SHIFT_DATES( - "PLS:Shift Dates", - "edx.bi.app.dates.pls_banner.shift_dates" + PLS_SHIFT_DATES_SUCCESS( + "PLS:Shift Dates Success", + "edx.bi.app.dates.pls_banner.shift_dates.success" ), DATES_CALENDAR_SYNC_TOGGLE( - "Dates:CalendarSync Toggle", + "Dates:CalendarSync Toggle Clicked", "edx.bi.app.dates.calendar_sync.toggle" ), DATES_CALENDAR_SYNC_DIALOG_ACTION( @@ -154,7 +154,7 @@ enum class CourseAnalyticsKey(val key: String) { OPEN_IN_BROWSER("open_in_browser_url"), COMPONENT("component"), VIDEO_PLAYER("video_player"), - ENROLLMENT_MODE("mode"), + ENROLLMENT_MODE("enrollment_mode"), PACING("pacing"), SCREEN_NAME("screen_name"), BANNER_TYPE("banner_type"), @@ -177,8 +177,10 @@ enum class CourseAnalyticsKey(val key: String) { ACTION("action"), ON("on"), OFF("off"), - SNACKBAR("snackbar"), + SNACKBAR_TYPE("snackbar_type"), COURSE_DATES("course_dates"), + SELF_PACED("self"), + INSTRUCTOR_PACED("instructor"), } enum class CalendarSyncDialog( @@ -186,11 +188,11 @@ enum class CalendarSyncDialog( private val positiveAction: String, private val negativeAction: String, ) { - PERMISSION("permission", "allow", "donot_allow"), - ADD("add", "ok", "cancel"), - REMOVE("remove", "ok", "cancel"), - UPDATE("update", "update", "remove"), - CONFIRMED("confirmed", "view_event", "done"); + PERMISSION("device_permission", "allow", "donot_allow"), + ADD("add_calendar", "add", "cancel"), + REMOVE("remove_calendar", "remove", "cancel"), + UPDATE("update_calendar", "update", "remove"), + CONFIRMED("events_added", "view_event", "done"); fun getBuildMap(action: Boolean): Map { return buildMap { @@ -201,13 +203,13 @@ enum class CalendarSyncDialog( } enum class CalendarSyncSnackbar(private val snackbar: String) { - ADD("add"), - REMOVE("remove"), - UPDATE("update"); + ADDED("added"), + REMOVED("removed"), + UPDATED("updated"); fun getBuildMap(): Map { return buildMap { - put(CourseAnalyticsKey.SNACKBAR.key, snackbar) + put(CourseAnalyticsKey.SNACKBAR_TYPE.key, snackbar) } } } 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 new file mode 100644 index 000000000..b5d73adaf --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/container/CollapsingLayout.kt @@ -0,0 +1,793 @@ +package org.openedx.course.presentation.container + +import android.content.res.Configuration +import android.graphics.Bitmap +import android.os.Build +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +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.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.pager.rememberPagerState +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.automirrored.filled.ArrowBack +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.input.pointer.PointerInputScope +import androidx.compose.ui.input.pointer.pointerInput +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.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +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 +import org.openedx.core.ui.statusBarsInset +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import kotlin.math.roundToInt + +@Composable +internal fun CollapsingLayout( + modifier: Modifier = Modifier, + courseImage: Bitmap, + imageHeight: Int, + expandedTop: @Composable BoxScope.() -> Unit, + collapsedTop: @Composable BoxScope.() -> Unit, + navigation: @Composable BoxScope.() -> Unit, + bodyContent: @Composable BoxScope.() -> Unit, + onBackClick: () -> Unit, +) { + val localDensity = LocalDensity.current + val expandedTopHeight = remember { + mutableFloatStateOf(0f) + } + val collapsedTopHeight = remember { + mutableFloatStateOf(0f) + } + val navigationHeight = remember { + mutableFloatStateOf(0f) + } + val offset = remember { Animatable(0f) } + val backgroundImageHeight = remember { + mutableFloatStateOf(0f) + } + val windowSize = rememberWindowSize() + val coroutineScope = rememberCoroutineScope() + val configuration = LocalConfiguration.current + val rawFactor = (-imageHeight - offset.value) / -imageHeight + 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 imageStartY = (backgroundImageHeight.floatValue - blurImagePaddingPx) * 0.5f + val imageOffsetY = -(offset.value + imageStartY) + val toolbarBackgroundOffset = if (toolbarOffset >= 0) { + toolbarOffset + } else { + 0 + } + val blurImageAlignment = if (toolbarOffset >= 0f) { + imageOffsetY + } else { + imageStartY + } + val backBtnStartPadding = if (!windowSize.isTablet) { + 0.dp + } else { + 60.dp + } + + fun calculateOffset(delta: Float): Offset { + val oldOffset = offset.value + val maxValue = 0f + val minValue = + (-expandedTopHeight.floatValue - backgroundImageHeight.floatValue + collapsedTopHeight.floatValue).let { + if (it >= maxValue) { + 0f + } else { + it + } + } + val newOffset = (oldOffset + delta).coerceIn(minValue, maxValue) + coroutineScope.launch { + offset.snapTo(newOffset) + } + return Offset(0f, newOffset - oldOffset) + } + + val nestedScrollConnection = remember { + object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset = + when { + available.y >= 0 -> Offset.Zero + offset.value == -expandedTopHeight.floatValue -> Offset.Zero + else -> calculateOffset(available.y) + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource, + ): Offset = + when { + available.y <= 0 -> Offset.Zero + offset.value == 0f -> Offset.Zero + else -> calculateOffset(available.y) + } + } + } + + Box( + modifier = modifier + .fillMaxSize() + .nestedScroll(nestedScrollConnection) + .pointerInput(Unit) { + var yStart = 0f + coroutineScope { + routePointerChangesTo( + onDown = { change -> + yStart = change.position.y + }, + onUp = { change -> + val yEnd = change.position.y + val yDelta = yEnd - yStart + val scrollDown = yDelta > 0 + val collapsedOffset = + -expandedTopHeight.floatValue - backgroundImageHeight.floatValue + collapsedTopHeight.floatValue + val expandedOffset = 0f + + launch { + // Handle Fling, offset.animateTo does not work if the value changes faster than 10ms + if (change.uptimeMillis - change.previousUptimeMillis <= 50) { + delay(50) + } + + if (scrollDown) { + if (offset.value > -backgroundImageHeight.floatValue * 0.85) { + offset.animateTo(expandedOffset) + } else { + offset.animateTo(collapsedOffset) + } + } else { + if (offset.value < -backgroundImageHeight.floatValue * 0.15) { + offset.animateTo(collapsedOffset) + } else { + offset.animateTo(expandedOffset) + } + } + } + } + ) + } + }, + ) { + if (windowSize.isTablet) { + CollapsingLayoutTablet( + localDensity = localDensity, + navigationHeight = navigationHeight, + backgroundImageHeight = backgroundImageHeight, + expandedTopHeight = expandedTopHeight, + blurImagePaddingPx = blurImagePaddingPx, + blurImagePadding = blurImagePadding, + backBtnStartPadding = backBtnStartPadding, + courseImage = courseImage, + imageHeight = imageHeight, + onBackClick = onBackClick, + expandedTop = expandedTop, + navigation = navigation, + bodyContent = bodyContent + ) + } else { + CollapsingLayoutMobile( + configuration = configuration, + localDensity = localDensity, + collapsedTopHeight = collapsedTopHeight, + navigationHeight = navigationHeight, + backgroundImageHeight = backgroundImageHeight, + expandedTopHeight = expandedTopHeight, + rawFactor = rawFactor, + factor = factor, + offset = offset, + blurImagePaddingPx = blurImagePaddingPx, + blurImageAlignment = blurImageAlignment, + blurImagePadding = blurImagePadding, + backBtnStartPadding = backBtnStartPadding, + courseImage = courseImage, + imageHeight = imageHeight, + toolbarBackgroundOffset = toolbarBackgroundOffset, + onBackClick = onBackClick, + expandedTop = expandedTop, + collapsedTop = collapsedTop, + navigation = navigation, + bodyContent = bodyContent + ) + } + } +} + +@Composable +private fun CollapsingLayoutTablet( + localDensity: Density, + navigationHeight: MutableState, + backgroundImageHeight: MutableState, + expandedTopHeight: MutableState, + blurImagePaddingPx: Float, + blurImagePadding: Dp, + backBtnStartPadding: Dp, + courseImage: Bitmap, + imageHeight: Int, + onBackClick: () -> Unit, + expandedTop: @Composable BoxScope.() -> Unit, + navigation: @Composable BoxScope.() -> Unit, + bodyContent: @Composable BoxScope.() -> Unit, +) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + Image( + modifier = Modifier + .fillMaxWidth() + .height(imageHeight.dp) + .onSizeChanged { size -> + backgroundImageHeight.value = size.height.toFloat() + }, + bitmap = courseImage.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Crop + ) + Box( + modifier = Modifier + .offset { + IntOffset( + x = 0, + y = (backgroundImageHeight.value - blurImagePaddingPx).roundToInt() + ) + } + .background(Color.White) + .blur(100.dp) + ) { + Box( + modifier = Modifier + .background(MaterialTheme.appColors.surface) + .fillMaxWidth() + .height(with(localDensity) { (expandedTopHeight.value + navigationHeight.value).toDp() } + blurImagePadding) + .align(Alignment.Center) + ) + Image( + modifier = Modifier + .fillMaxWidth() + .height(blurImagePadding) + .align(Alignment.TopCenter), + bitmap = courseImage.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Crop, + ) + Box( + modifier = Modifier + .background(MaterialTheme.appColors.courseHomeHeaderShade) + .fillMaxWidth() + .height(with(localDensity) { (expandedTopHeight.value + navigationHeight.value).toDp() } * 0.1f) + .align(Alignment.BottomCenter) + ) + } + } else { + val backgroundColor = MaterialTheme.appColors.background + Image( + modifier = Modifier + .fillMaxWidth() + .height(imageHeight.dp) + .onSizeChanged { size -> + backgroundImageHeight.value = size.height.toFloat() + }, + bitmap = courseImage.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Crop + ) + Box( + modifier = Modifier + .fillMaxWidth() + .height(with(localDensity) { (expandedTopHeight.value + navigationHeight.value).toDp() }) + .offset { IntOffset(x = 0, y = backgroundImageHeight.value.roundToInt()) } + .background(backgroundColor) + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .height(imageHeight.dp) + .background( + brush = Brush.verticalGradient( + colors = listOf(backgroundColor, Color.Transparent), + startY = 500f, + endY = 400f + ) + ), + ) + Box( + modifier = Modifier + .fillMaxWidth() + .height(with(localDensity) { (expandedTopHeight.value + navigationHeight.value).toDp() } + blurImagePadding) + .offset { + IntOffset( + x = 0, + y = (backgroundImageHeight.value - blurImagePaddingPx).roundToInt() + ) + } + .background( + brush = Brush.verticalGradient( + colors = listOf(backgroundColor, Color.Transparent), + startY = 400f, + endY = 0f + ) + ), + ) + } + + Box( + modifier = Modifier + .onSizeChanged { size -> + expandedTopHeight.value = size.height.toFloat() + } + .offset { IntOffset(x = 0, y = backgroundImageHeight.value.roundToInt()) }, + content = expandedTop, + ) + + Icon( + modifier = Modifier + .statusBarsInset() + .padding(top = 12.dp, start = backBtnStartPadding + 12.dp) + .clip(CircleShape) + .background(MaterialTheme.appColors.courseHomeBackBtnBackground) + .clickable { + onBackClick() + }, + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + tint = MaterialTheme.appColors.textPrimary, + contentDescription = null + ) + + + Box( + modifier = Modifier + .offset { IntOffset(x = 0, y = (backgroundImageHeight.value + expandedTopHeight.value).roundToInt()) } + .onSizeChanged { size -> + navigationHeight.value = size.height.toFloat() + }, + content = navigation, + ) + + Box( + modifier = Modifier + .offset { + IntOffset( + x = 0, + y = (expandedTopHeight.value + backgroundImageHeight.value + navigationHeight.value).roundToInt() + ) + } + .padding(bottom = with(localDensity) { (expandedTopHeight.value + navigationHeight.value + backgroundImageHeight.value).toDp() }), + content = bodyContent, + ) +} + +@Composable +private fun CollapsingLayoutMobile( + configuration: Configuration, + localDensity: Density, + collapsedTopHeight: MutableState, + navigationHeight: MutableState, + backgroundImageHeight: MutableState, + expandedTopHeight: MutableState, + rawFactor: Float, + factor: Float, + offset: Animatable, + blurImagePaddingPx: Float, + blurImageAlignment: Float, + blurImagePadding: Dp, + backBtnStartPadding: Dp, + courseImage: Bitmap, + imageHeight: Int, + toolbarBackgroundOffset: Int, + onBackClick: () -> Unit, + expandedTop: @Composable BoxScope.() -> Unit, + collapsedTop: @Composable BoxScope.() -> Unit, + navigation: @Composable BoxScope.() -> Unit, + bodyContent: @Composable BoxScope.() -> Unit, +) { + if (configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + Box( + modifier = Modifier + .background(Color.White) + .blur(100.dp) + ) { + Box( + modifier = Modifier + .background(MaterialTheme.appColors.surface) + .fillMaxWidth() + .height(with(localDensity) { (collapsedTopHeight.value + navigationHeight.value).toDp() } + blurImagePadding) + .align(Alignment.Center) + ) + Image( + modifier = Modifier + .fillMaxWidth() + .height(blurImagePadding) + .align(Alignment.TopCenter), + bitmap = courseImage.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Crop, + alignment = Alignment.Center, + ) + Box( + modifier = Modifier + .background(MaterialTheme.appColors.courseHomeHeaderShade) + .fillMaxWidth() + .height(with(localDensity) { (collapsedTopHeight.value + navigationHeight.value).toDp() } * 0.1f) + .align(Alignment.BottomCenter) + ) + } + } else { + val backgroundColor = MaterialTheme.appColors.background + Image( + modifier = Modifier + .fillMaxWidth() + .height(imageHeight.dp) + .onSizeChanged { size -> + backgroundImageHeight.value = size.height.toFloat() + }, + bitmap = courseImage.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Crop + ) + Box( + modifier = Modifier + .fillMaxWidth() + .height(with(localDensity) { (collapsedTopHeight.value + navigationHeight.value).toDp() }) + .background( + brush = Brush.verticalGradient( + colors = listOf(backgroundColor, Color.Transparent), + startY = 400f, + endY = 0f + ) + ), + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .displayCutoutForLandscape() + .padding(horizontal = 12.dp) + .onSizeChanged { size -> + collapsedTopHeight.value = size.height.toFloat() + }, + verticalAlignment = Alignment.Bottom + ) { + Icon( + modifier = Modifier + .statusBarsInset() + .padding(top = 12.dp, start = backBtnStartPadding) + .clip(CircleShape) + .clickable { + onBackClick() + }, + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + tint = MaterialTheme.appColors.textPrimary, + contentDescription = null + ) + Spacer(modifier = Modifier.width(8.dp)) + Box( + content = collapsedTop, + ) + } + + + Box( + modifier = Modifier + .displayCutoutForLandscape() + .offset { IntOffset(x = 0, y = (collapsedTopHeight.value).roundToInt()) } + .onSizeChanged { size -> + navigationHeight.value = size.height.toFloat() + }, + content = navigation, + ) + + Box( + modifier = Modifier + .offset { + IntOffset( + x = 0, + y = (collapsedTopHeight.value + navigationHeight.value).roundToInt() + ) + } + .padding(bottom = with(localDensity) { (collapsedTopHeight.value + navigationHeight.value).toDp() }), + content = bodyContent, + ) + } else { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + Image( + modifier = Modifier + .fillMaxWidth() + .height(imageHeight.dp) + .onSizeChanged { size -> + backgroundImageHeight.value = size.height.toFloat() + }, + bitmap = courseImage.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Crop + ) + Box( + modifier = Modifier + .offset { IntOffset(x = 0, y = toolbarBackgroundOffset) } + .background(Color.White) + .blur(100.dp) + ) { + val adaptiveBlurImagePadding = blurImagePadding.value * (3 - rawFactor) + Box( + modifier = Modifier + .background(MaterialTheme.appColors.surface) + .fillMaxWidth() + .height(with(localDensity) { (expandedTopHeight.value + navigationHeight.value + adaptiveBlurImagePadding).toDp() }) + .align(Alignment.Center) + ) + Image( + modifier = Modifier + .fillMaxWidth() + .height(blurImagePadding) + .align(Alignment.TopCenter), + bitmap = courseImage.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Crop, + alignment = PixelAlignment(0f, blurImageAlignment), + ) + Box( + modifier = Modifier + .background(MaterialTheme.appColors.courseHomeHeaderShade) + .fillMaxWidth() + .height(with(localDensity) { (expandedTopHeight.value + navigationHeight.value).toDp() } * 0.1f) + .align(Alignment.BottomCenter) + ) + } + } else { + val backgroundColor = MaterialTheme.appColors.background + Image( + modifier = Modifier + .fillMaxWidth() + .height(imageHeight.dp) + .onSizeChanged { size -> + backgroundImageHeight.value = size.height.toFloat() + }, + bitmap = courseImage.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Crop + ) + Box( + modifier = Modifier + .fillMaxWidth() + .height(with(localDensity) { (expandedTopHeight.value + navigationHeight.value).toDp() }) + .offset { IntOffset(x = 0, y = backgroundImageHeight.value.roundToInt()) } + .background(backgroundColor) + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .height(imageHeight.dp) + .background( + brush = Brush.verticalGradient( + colors = listOf(backgroundColor, Color.Transparent), + startY = 500f, + endY = 400f + ) + ), + ) + Box( + modifier = Modifier + .fillMaxWidth() + .height(with(localDensity) { (expandedTopHeight.value + navigationHeight.value).toDp() } + blurImagePadding) + .offset { + IntOffset( + x = 0, + y = (offset.value + backgroundImageHeight.value - blurImagePaddingPx).roundToInt() + ) + } + .background( + brush = Brush.verticalGradient( + colors = listOf(backgroundColor, Color.Transparent), + startY = 400f, + endY = 0f + ) + ), + ) + } + + Box( + modifier = Modifier + .onSizeChanged { size -> + expandedTopHeight.value = size.height.toFloat() + } + .offset { + IntOffset( + x = 0, + y = (offset.value + backgroundImageHeight.value - blurImagePaddingPx).roundToInt() + ) + } + .alpha(factor), + content = expandedTop, + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + .onSizeChanged { size -> + collapsedTopHeight.value = size.height.toFloat() + }, + verticalAlignment = Alignment.Bottom + ) { + Icon( + modifier = Modifier + .statusBarsInset() + .padding(top = 12.dp, start = backBtnStartPadding) + .clip(CircleShape) + .background(MaterialTheme.appColors.courseHomeBackBtnBackground.copy(factor / 2)) + .clickable { + onBackClick() + }, + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + tint = MaterialTheme.appColors.textPrimary, + contentDescription = null + ) + Spacer(modifier = Modifier.width(8.dp)) + Box( + modifier = Modifier + .alpha(1 - factor), + content = collapsedTop, + ) + } + + val adaptiveImagePadding = blurImagePaddingPx * factor + Box( + modifier = Modifier + .offset { + IntOffset( + x = 0, + y = (offset.value + backgroundImageHeight.value + expandedTopHeight.value - adaptiveImagePadding).roundToInt() + ) + } + .onSizeChanged { size -> + navigationHeight.value = size.height.toFloat() + }, + content = navigation, + ) + + Box( + modifier = Modifier + .offset { + IntOffset( + x = 0, + y = (expandedTopHeight.value + offset.value + backgroundImageHeight.value + navigationHeight.value - blurImagePaddingPx * factor).roundToInt() + ) + } + .padding(bottom = with(localDensity) { (collapsedTopHeight.value + navigationHeight.value).toDp() }), + content = bodyContent, + ) + } +} + +@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(device = Devices.NEXUS_9, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(device = Devices.NEXUS_9, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun CollapsingLayoutPreview() { + OpenEdXTheme { + CollapsingLayout( + modifier = Modifier, + courseImage = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888), + imageHeight = 200, + expandedTop = { + ExpandedHeaderContent( + courseTitle = "courseName", + org = "organization" + ) + }, + collapsedTop = { + CollapsedHeaderContent( + courseTitle = "courseName" + ) + }, + navigation = { + RoundTabsBar( + items = CourseContainerTab.entries, + rowState = rememberLazyListState(), + pagerState = rememberPagerState(pageCount = { 5 }), + onPageChange = { } + ) + }, + onBackClick = {}, + bodyContent = {} + ) + } +} + +suspend fun PointerInputScope.routePointerChangesTo( + onDown: (PointerInputChange) -> Unit = {}, + onUp: (PointerInputChange) -> Unit = {} +) { + awaitEachGesture { + do { + val event = awaitPointerEvent() + event.changes.forEach { + when (event.type) { + PointerEventType.Press -> onDown(it) + PointerEventType.Release -> onUp(it) + } + } + } while (event.changes.any { it.pressed }) + } +} + +@Immutable +data class PixelAlignment( + val offsetX: Float, + val offsetY: Float +) : Alignment { + + override fun align(size: IntSize, space: IntSize, layoutDirection: LayoutDirection): IntOffset { + val centerX = (space.width - size.width).toFloat() / 2f + val centerY = (space.height - size.height).toFloat() / 2f + + val x = centerX + offsetX + val y = centerY + offsetY + + return IntOffset(x.roundToInt(), y.roundToInt()) + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerAdapter.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerAdapter.kt deleted file mode 100644 index defa6b8a7..000000000 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerAdapter.kt +++ /dev/null @@ -1,31 +0,0 @@ -package org.openedx.course.presentation.container - -import androidx.fragment.app.Fragment -import androidx.viewpager2.adapter.FragmentStateAdapter -import org.openedx.course.R - -class CourseContainerAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) { - - private val fragments = HashMap() - - override fun getItemCount(): Int = fragments.size - - override fun createFragment(position: Int): Fragment { - val tab = CourseContainerTab.values().find { it.ordinal == position } - return fragments[tab] ?: throw IllegalStateException("Fragment not found for tab $tab") - } - - fun addFragment(tab: CourseContainerTab, fragment: Fragment) { - fragments[tab] = fragment - } - - fun getFragment(tab: CourseContainerTab): Fragment? = fragments[tab] -} - -enum class CourseContainerTab(val itemId: Int, val titleResId: Int) { - COURSE(itemId = R.id.course, titleResId = R.string.course_navigation_course), - VIDEOS(itemId = R.id.videos, titleResId = R.string.course_navigation_videos), - DISCUSSION(itemId = R.id.discussions, titleResId = R.string.course_navigation_discussions), - DATES(itemId = R.id.dates, titleResId = R.string.course_navigation_dates), - HANDOUTS(itemId = R.id.resources, titleResId = R.string.course_navigation_more), -} 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 ee4cea674..c44733948 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 @@ -3,40 +3,81 @@ package org.openedx.course.presentation.container import android.os.Bundle 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.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.lazy.rememberLazyListState +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.SnackbarData +import androidx.compose.material.SnackbarDuration +import androidx.compose.material.SnackbarHost +import androidx.compose.material.SnackbarHostState +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.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.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.platform.ViewCompositionStrategy +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.core.os.bundleOf import androidx.core.view.isVisible import androidx.fragment.app.Fragment -import androidx.viewpager2.widget.ViewPager2 +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.lifecycleScope import com.google.android.material.snackbar.Snackbar -import com.google.android.material.tabs.TabLayout -import com.google.android.material.tabs.TabLayoutMediator -import org.koin.android.ext.android.inject +import kotlinx.coroutines.CoroutineScope +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.course.CourseContainerTab 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 import org.openedx.course.R import org.openedx.course.databinding.FragmentCourseContainerBinding -import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.calendarsync.CalendarSyncDialog import org.openedx.course.presentation.calendarsync.CalendarSyncDialogType -import org.openedx.course.presentation.container.CourseContainerTab -import org.openedx.course.presentation.dates.CourseDatesFragment -import org.openedx.course.presentation.handouts.HandoutsFragment -import org.openedx.course.presentation.outline.CourseOutlineFragment -import org.openedx.course.presentation.ui.CourseToolbar -import org.openedx.course.presentation.videos.CourseVideosFragment -import org.openedx.discussion.presentation.topics.DiscussionTopicsFragment -import org.openedx.course.presentation.container.CourseContainerTab as Tabs +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.outline.CourseOutlineScreen +import org.openedx.course.presentation.ui.CourseVideosScreen +import org.openedx.course.presentation.ui.DatesShiftedSnackBar +import org.openedx.discussion.presentation.topics.DiscussionTopicsScreen class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { private val binding by viewBinding(FragmentCourseContainerBinding::bind) + private val viewModel by viewModel { parametersOf( requireArguments().getString(ARG_COURSE_ID, ""), @@ -44,9 +85,6 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { requireArguments().getString(ARG_ENROLLMENT_MODE, "") ) } - private val router by inject() - - private var adapter: CourseContainerAdapter? = null private val permissionLauncher = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() @@ -66,7 +104,7 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - setupToolbar(viewModel.courseName) + initCourseView() if (viewModel.calendarSyncUIState.value.isCalendarSyncEnabled) { setUpCourseCalendar() } @@ -80,11 +118,8 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { private fun observe() { viewModel.dataReady.observe(viewLifecycleOwner) { isReady -> - if (isReady == true) { - setupToolbar(viewModel.courseName) - initViewPager() - } else { - router.navigateToNoAccess( + if (isReady == false) { + viewModel.courseRouter.navigateToNoAccess( requireActivity().supportFragmentManager, viewModel.courseName ) @@ -98,86 +133,31 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { snackBar?.show() } - viewModel.showProgress.observe(viewLifecycleOwner) { - binding.progressBar.isVisible = it + lifecycleScope.launch { + viewModel.showProgress.collect { + binding.progressBar.isVisible = it + } } } - private fun setupToolbar(courseName: String) { - binding.toolbar.setContent { - CourseToolbar( - title = courseName, - onBackClick = { - requireActivity().supportFragmentManager.popBackStack() - } - ) - } + private fun onRefresh(currentPage: Int) { + viewModel.onRefresh(CourseContainerTab.entries[currentPage]) } - private fun initViewPager() { - binding.viewPager.isVisible = true - binding.viewPager.orientation = ViewPager2.ORIENTATION_HORIZONTAL - adapter = CourseContainerAdapter(this).apply { - addFragment( - Tabs.COURSE, - CourseOutlineFragment.newInstance(viewModel.courseId, viewModel.courseName) - ) - addFragment( - Tabs.VIDEOS, - CourseVideosFragment.newInstance(viewModel.courseId, viewModel.courseName) - ) - addFragment( - Tabs.DISCUSSION, - DiscussionTopicsFragment.newInstance(viewModel.courseId, viewModel.courseName) - ) - addFragment( - Tabs.DATES, - CourseDatesFragment.newInstance( - viewModel.courseId, - viewModel.courseName, - viewModel.isSelfPaced, - viewModel.enrollmentMode, - ) - ) - addFragment( - Tabs.HANDOUTS, - HandoutsFragment.newInstance(viewModel.courseId) - ) - } - binding.viewPager.offscreenPageLimit = adapter?.itemCount ?: 1 - binding.viewPager.adapter = adapter - - if (viewModel.isCourseTopTabBarEnabled) { - TabLayoutMediator(binding.tabLayout, binding.viewPager) { tab, position -> - tab.text = getString( - Tabs.entries.find { it.ordinal == position }?.titleResId - ?: R.string.course_navigation_course - ) - }.attach() - binding.tabLayout.isVisible = true - binding.tabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { - override fun onTabSelected(tab: TabLayout.Tab?) { - tab?.let { - viewModel.courseContainerTabClickedEvent(Tabs.entries[it.position]) - } - } - - override fun onTabUnselected(p0: TabLayout.Tab?) {} - - override fun onTabReselected(p0: TabLayout.Tab?) {} - }) - } else { - binding.viewPager.isUserInputEnabled = false - binding.bottomNavView.setOnItemSelectedListener { menuItem -> - Tabs.entries.find { menuItem.itemId == it.itemId }?.let { tab -> - viewModel.courseContainerTabClickedEvent(tab) - binding.viewPager.setCurrentItem(tab.ordinal, false) + private fun initCourseView() { + binding.composeCollapsingLayout.setContent { + val isNavigationEnabled by viewModel.isNavigationEnabled.collectAsState() + CourseDashboard( + viewModel = viewModel, + isNavigationEnabled = isNavigationEnabled, + isResumed = isResumed, + fragmentManager = requireActivity().supportFragmentManager, + bundle = requireArguments(), + onRefresh = { page -> + onRefresh(page) } - true - } - binding.bottomNavView.isVisible = true + ) } - viewModel.courseContainerTabClickedEvent(Tabs.entries[binding.viewPager.currentItem]) } private fun setUpCourseCalendar() { @@ -270,26 +250,10 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { } } - fun updateCourseStructure(withSwipeRefresh: Boolean) { - viewModel.updateData(withSwipeRefresh) - } - - fun updateCourseDates() { - adapter?.getFragment(Tabs.DATES)?.let { - (it as CourseDatesFragment).updateData() - } - } - - fun navigateToTab(tab: CourseContainerTab) { - adapter?.getFragment(tab)?.let { - binding.viewPager.setCurrentItem(tab.ordinal, true) - } - } - companion object { - private const val ARG_COURSE_ID = "courseId" - private const val ARG_TITLE = "title" - private const val ARG_ENROLLMENT_MODE = "enrollmentMode" + const val ARG_COURSE_ID = "courseId" + const val ARG_TITLE = "title" + const val ARG_ENROLLMENT_MODE = "enrollmentMode" fun newInstance( courseId: String, courseTitle: String, @@ -306,3 +270,248 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { } } +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterialApi::class) +@Composable +fun CourseDashboard( + viewModel: CourseContainerViewModel, + onRefresh: (page: Int) -> Unit, + isNavigationEnabled: Boolean, + isResumed: Boolean, + fragmentManager: FragmentManager, + bundle: Bundle +) { + OpenEdXTheme { + val windowSize = rememberWindowSize() + val scope = rememberCoroutineScope() + val scaffoldState = rememberScaffoldState() + Scaffold( + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding(), + scaffoldState = scaffoldState, + backgroundColor = MaterialTheme.appColors.background + ) { paddingValues -> + val refreshing by viewModel.refreshing.collectAsState(true) + val courseImage by viewModel.courseImage.collectAsState() + val uiMessage by viewModel.uiMessage.collectAsState(null) + val pagerState = rememberPagerState(pageCount = { CourseContainerTab.entries.size }) + val tabState = rememberLazyListState() + val snackState = remember { SnackbarHostState() } + val pullRefreshState = rememberPullRefreshState( + refreshing = refreshing, + onRefresh = { onRefresh(pagerState.currentPage) } + ) + if (uiMessage is DatesShiftedSnackBar) { + val datesShiftedMessage = stringResource(id = R.string.course_dates_shifted_message) + LaunchedEffect(uiMessage) { + snackState.showSnackbar( + message = datesShiftedMessage, + duration = SnackbarDuration.Long + ) + } + } + HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) + + LaunchedEffect(pagerState.currentPage) { + 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, + rowState = tabState, + pagerState = pagerState, + onPageChange = viewModel::courseContainerTabClickedEvent + ) + } else { + Spacer(modifier = Modifier.height(52.dp)) + } + }, + onBackClick = { + fragmentManager.popBackStack() + }, + bodyContent = { + DashboardPager( + windowSize = windowSize, + viewModel = viewModel, + pagerState = pagerState, + isNavigationEnabled = isNavigationEnabled, + isResumed = isResumed, + fragmentManager = fragmentManager, + bundle = bundle + ) + } + ) + 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( + windowSize: WindowSize, + viewModel: CourseContainerViewModel, + pagerState: PagerState, + isNavigationEnabled: Boolean, + isResumed: Boolean, + fragmentManager: FragmentManager, + bundle: Bundle, +) { + HorizontalPager( + state = pagerState, + userScrollEnabled = isNavigationEnabled, + beyondBoundsPageCount = CourseContainerTab.entries.size + ) { page -> + when (CourseContainerTab.entries[page]) { + CourseContainerTab.HOME -> { + CourseOutlineScreen( + windowSize = windowSize, + courseOutlineViewModel = koinViewModel( + parameters = { + parametersOf( + bundle.getString(CourseContainerFragment.ARG_COURSE_ID, ""), + bundle.getString(CourseContainerFragment.ARG_TITLE, "") + ) + } + ), + courseRouter = viewModel.courseRouter, + fragmentManager = fragmentManager, + onResetDatesClick = { + viewModel.onRefresh(CourseContainerTab.DATES) + } + ) + } + + CourseContainerTab.VIDEOS -> { + CourseVideosScreen( + windowSize = windowSize, + courseVideoViewModel = koinViewModel( + parameters = { + parametersOf( + bundle.getString(CourseContainerFragment.ARG_COURSE_ID, ""), + bundle.getString(CourseContainerFragment.ARG_TITLE, "") + ) + } + ), + fragmentManager = fragmentManager, + courseRouter = viewModel.courseRouter, + ) + } + + CourseContainerTab.DATES -> { + CourseDatesScreen( + courseDatesViewModel = koinViewModel( + parameters = { + parametersOf( + bundle.getString(CourseContainerFragment.ARG_ENROLLMENT_MODE, "") + ) + } + ), + windowSize = windowSize, + courseRouter = viewModel.courseRouter, + fragmentManager = fragmentManager, + isFragmentResumed = isResumed, + updateCourseStructure = { + viewModel.updateData() + } + ) + } + + CourseContainerTab.DISCUSSIONS -> { + DiscussionTopicsScreen( + windowSize = windowSize, + fragmentManager = fragmentManager + ) + } + + 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 + ) + }, + onAnnouncementsClick = { + viewModel.courseRouter.navigateToHandoutsWebView( + fragmentManager, + bundle.getString(CourseContainerFragment.ARG_COURSE_ID, ""), + announcementsString, + HandoutsType.Announcements + ) + }) + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +private fun scrollToDates(scope: CoroutineScope, pagerState: PagerState) { + scope.launch { + pagerState.animateScrollToPage(CourseContainerTab.entries.indexOf(CourseContainerTab.DATES)) + } +} 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 cf78bb207..c61d7e165 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 @@ -1,30 +1,44 @@ 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 +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.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.presentation.course.CourseContainerTab 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.CourseDataReady +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.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 @@ -33,6 +47,7 @@ import org.openedx.course.presentation.CalendarSyncSnackbar 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 @@ -43,20 +58,20 @@ import org.openedx.core.R as CoreR class CourseContainerViewModel( val courseId: String, var courseName: String, - val enrollmentMode: String, + private val enrollmentMode: String, private val config: Config, private val interactor: CourseInteractor, private val calendarManager: CalendarManager, private val resourceManager: ResourceManager, - private val notifier: CourseNotifier, + private val courseNotifier: CourseNotifier, private val networkConnection: NetworkConnection, private val corePreferences: CorePreferences, private val coursePreferences: CoursePreferences, private val courseAnalytics: CourseAnalytics, + private val imageProcessor: ImageProcessor, + val courseRouter: CourseRouter ) : BaseViewModel() { - val isCourseTopTabBarEnabled get() = config.isCourseTopTabBarEnabled() - private val _dataReady = MutableLiveData() val dataReady: LiveData get() = _dataReady @@ -65,14 +80,30 @@ class CourseContainerViewModel( val errorMessage: LiveData get() = _errorMessage - private val _showProgress = MutableLiveData() - val showProgress: LiveData - get() = _showProgress + private val _showProgress = MutableStateFlow(true) + val showProgress: StateFlow = + _showProgress.asStateFlow() + + private val _refreshing = MutableStateFlow(false) + val refreshing: StateFlow = + _refreshing.asStateFlow() + + private val _isNavigationEnabled = MutableStateFlow(false) + val isNavigationEnabled: StateFlow = + _isNavigationEnabled.asStateFlow() + + private val _uiMessage = MutableSharedFlow() + 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 + val calendarPermissions: Array get() = calendarManager.permissions @@ -89,21 +120,40 @@ class CourseContainerViewModel( val calendarSyncUIState: StateFlow = _calendarSyncUIState.asStateFlow() + private var _courseImage = MutableStateFlow(Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)) + val courseImage: StateFlow = _courseImage.asStateFlow() + + val hasInternetConnection: Boolean + get() = networkConnection.isOnline() + init { viewModelScope.launch { - notifier.notifier.collect { event -> - if (event is CourseCompletionSet) { - updateData(false) - } + courseNotifier.notifier.collect { event -> + when (event) { + is CourseCompletionSet -> { + updateData() + } - if (event is CreateCalendarSyncEvent) { - _calendarSyncUIState.update { - val dialogType = CalendarSyncDialogType.valueOf(event.dialogType) - it.copy( - courseDates = event.courseDates, - dialogType = dialogType, - checkForOutOfSync = AtomicReference(event.checkOutOfSync) - ) + is CreateCalendarSyncEvent -> { + _calendarSyncUIState.update { + val dialogType = CalendarSyncDialogType.valueOf(event.dialogType) + it.copy( + courseDates = event.courseDates, + dialogType = dialogType, + checkForOutOfSync = AtomicReference(event.checkOutOfSync) + ) + } + } + + is CourseDatesShifted -> { + _uiMessage.emit(DatesShiftedSnackBar()) + } + + is CourseLoading -> { + _showProgress.value = event.isLoading + if (!event.isLoading) { + _refreshing.value = false + } } } } @@ -126,9 +176,16 @@ class CourseContainerViewModel( } val courseStructure = interactor.getCourseStructureFromCache() courseName = courseStructure.name + _organization = courseStructure.org _isSelfPaced = courseStructure.isSelfPaced + loadCourseImage(courseStructure.media?.image?.large) _dataReady.value = courseStructure.start?.let { start -> - start < Date() + val isReady = start < Date() + if (isReady) { + _isNavigationEnabled.value = true + courseNotifier.send(CourseDataReady(courseStructure)) + } + isReady } } catch (e: Exception) { if (e.isInternetError() || e is NoCachedDataException) { @@ -139,12 +196,56 @@ class CourseContainerViewModel( resourceManager.getString(CoreR.string.core_error_unknown_error) } } - _showProgress.value = false } } - fun updateData(withSwipeRefresh: Boolean) { - _showProgress.value = true + private fun loadCourseImage(imageUrl: String?) { + imageProcessor.loadImage( + imageUrl = config.getApiHostURL() + imageUrl, + defaultImage = CoreR.drawable.core_no_image_course, + onComplete = { drawable -> + val bitmap = (drawable as BitmapDrawable).bitmap.apply { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + imageProcessor.applyBlur(this@apply, 10f) + } + } + viewModelScope.launch { + _courseImage.emit(bitmap) + } + } + ) + } + + fun onRefresh(courseContainerTab: CourseContainerTab) { + _refreshing.value = true + when (courseContainerTab) { + CourseContainerTab.HOME -> { + updateData() + } + + CourseContainerTab.VIDEOS -> { + updateData() + } + + CourseContainerTab.DATES -> { + viewModelScope.launch { + courseNotifier.send(CourseRefresh(courseContainerTab)) + } + } + + CourseContainerTab.DISCUSSIONS -> { + viewModelScope.launch { + courseNotifier.send(CourseRefresh(courseContainerTab)) + } + } + + else -> { + _refreshing.value = false + } + } + } + + fun updateData() { viewModelScope.launch { try { interactor.preloadCourseStructure(courseId) @@ -157,21 +258,22 @@ class CourseContainerViewModel( resourceManager.getString(CoreR.string.core_error_unknown_error) } } - _showProgress.value = false - notifier.send(CourseStructureUpdated(courseId, withSwipeRefresh)) + _refreshing.value = false + courseNotifier.send(CourseStructureUpdated(courseId)) } } - fun courseContainerTabClickedEvent(tab: CourseContainerTab) { - when (tab) { - CourseContainerTab.COURSE -> courseTabClickedEvent() + fun courseContainerTabClickedEvent(index: Int) { + when (CourseContainerTab.entries[index]) { + CourseContainerTab.HOME -> courseTabClickedEvent() CourseContainerTab.VIDEOS -> videoTabClickedEvent() - CourseContainerTab.DISCUSSION -> discussionTabClickedEvent() + CourseContainerTab.DISCUSSIONS -> discussionTabClickedEvent() CourseContainerTab.DATES -> datesTabClickedEvent() - CourseContainerTab.HANDOUTS -> handoutsTabClickedEvent() + CourseContainerTab.MORE -> moreTabClickedEvent() } } + fun setCalendarSyncDialogType(dialogType: CalendarSyncDialogType) { val currentState = _calendarSyncUIState.value if (currentState.dialogType != dialogType) { @@ -218,10 +320,10 @@ class CourseContainerViewModel( updateCalendarSyncState() if (updatedEvent) { - logCalendarSyncSnackbar(CalendarSyncSnackbar.UPDATE) + logCalendarSyncSnackbar(CalendarSyncSnackbar.UPDATED) setUiMessage(R.string.course_snackbar_course_calendar_updated) } else if (coursePreferences.isCalendarSyncEventsDialogShown(courseName)) { - logCalendarSyncSnackbar(CalendarSyncSnackbar.ADD) + logCalendarSyncSnackbar(CalendarSyncSnackbar.ADDED) setUiMessage(R.string.course_snackbar_course_calendar_added) } else { coursePreferences.setCalendarSyncEventsDialogShown(courseName) @@ -235,7 +337,7 @@ class CourseContainerViewModel( val isCalendarSynced = calendarManager.isCalendarExists( calendarTitle = _calendarSyncUIState.value.calendarTitle ) - notifier.send(CheckCalendarSyncEvent(isSynced = isCalendarSynced)) + courseNotifier.send(CheckCalendarSyncEvent(isSynced = isCalendarSynced)) } } @@ -265,7 +367,7 @@ class CourseContainerViewModel( updateCalendarSyncState() } - logCalendarSyncSnackbar(CalendarSyncSnackbar.REMOVE) + logCalendarSyncSnackbar(CalendarSyncSnackbar.REMOVED) setUiMessage(R.string.course_snackbar_course_calendar_removed) } } @@ -312,8 +414,8 @@ class CourseContainerViewModel( logCourseContainerEvent(CourseAnalyticsEvent.DATES_TAB) } - private fun handoutsTabClickedEvent() { - logCourseContainerEvent(CourseAnalyticsEvent.HANDOUTS_TAB) + private fun moreTabClickedEvent() { + logCourseContainerEvent(CourseAnalyticsEvent.MORE_TAB) } private fun logCourseContainerEvent(event: CourseAnalyticsEvent) { @@ -379,7 +481,11 @@ class CourseContainerViewModel( put(CourseAnalyticsKey.NAME.key, event.biValue) put(CourseAnalyticsKey.COURSE_ID.key, courseId) put(CourseAnalyticsKey.ENROLLMENT_MODE.key, enrollmentMode) - put(CourseAnalyticsKey.PACING.key, isSelfPaced) + put( + CourseAnalyticsKey.PACING.key, + if (isSelfPaced) CourseAnalyticsKey.SELF_PACED.key + else CourseAnalyticsKey.INSTRUCTOR_PACED.key + ) putAll(param) } ) 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 new file mode 100644 index 000000000..a2070eb66 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/container/HeaderContent.kt @@ -0,0 +1,94 @@ +package org.openedx.course.presentation.container + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +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 + +@Composable +internal fun ExpandedHeaderContent( + modifier: Modifier = Modifier, + org: String, + courseTitle: String +) { + val windowSize = rememberWindowSize() + val horizontalPadding = if (!windowSize.isTablet) { + 24.dp + } else { + 98.dp + } + Column( + modifier + .fillMaxWidth() + .padding(horizontal = horizontalPadding) + .padding(top = 16.dp) + ) { + Text( + modifier = Modifier + .fillMaxWidth(), + color = MaterialTheme.appColors.textDark, + text = org, + style = MaterialTheme.appTypography.labelLarge + ) + Text( + modifier = Modifier + .fillMaxWidth(), + color = MaterialTheme.appColors.textDark, + text = courseTitle, + style = MaterialTheme.appTypography.titleLarge, + overflow = TextOverflow.Ellipsis, + maxLines = 3 + ) + } +} + +@Composable +internal fun CollapsedHeaderContent( + modifier: Modifier = Modifier, + courseTitle: String +) { + Text( + modifier = modifier + .fillMaxWidth() + .padding(bottom = 3.dp), + text = courseTitle, + color = MaterialTheme.appColors.textDark, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.appTypography.titleSmall, + maxLines = 1 + ) +} + +@Preview(showBackground = true, device = Devices.PIXEL) +@Composable +private fun ExpandedHeaderContentPreview() { + OpenEdXTheme { + ExpandedHeaderContent( + modifier = Modifier.fillMaxWidth(), + org = "organization", + courseTitle = "Course title" + ) + } +} + +@Preview(showBackground = true, device = Devices.PIXEL) +@Composable +private fun CollapsedHeaderContentPreview() { + OpenEdXTheme { + CollapsedHeaderContent( + modifier = Modifier.fillMaxWidth(), + courseTitle = "Course title" + ) + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesFragment.kt b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt similarity index 72% rename from course/src/main/java/org/openedx/course/presentation/dates/CourseDatesFragment.kt rename to course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt index b65532f0b..715584497 100644 --- a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt @@ -1,9 +1,6 @@ package org.openedx.course.presentation.dates import android.content.res.Configuration -import android.os.Bundle -import android.view.LayoutInflater -import android.view.ViewGroup import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.tween @@ -32,15 +29,9 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material.CircularProgressIndicator -import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold -import androidx.compose.material.SnackbarData -import androidx.compose.material.SnackbarDuration -import androidx.compose.material.SnackbarHost -import androidx.compose.material.SnackbarHostState import androidx.compose.material.Surface import androidx.compose.material.Switch import androidx.compose.material.SwitchDefaults @@ -48,27 +39,20 @@ import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.KeyboardArrowUp -import androidx.compose.material.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.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.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.graphics.Color -import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource @@ -78,11 +62,7 @@ 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.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 androidx.fragment.app.FragmentManager import org.openedx.core.UIMessage import org.openedx.core.data.model.DateType import org.openedx.core.domain.model.CourseDateBlock @@ -94,11 +74,9 @@ 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.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.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes @@ -106,181 +84,110 @@ import org.openedx.core.ui.theme.appTypography import org.openedx.core.ui.windowSizeValue import org.openedx.core.utils.TimeUtils import org.openedx.core.utils.clearTime -import org.openedx.course.DatesShiftedSnackBar import org.openedx.course.R import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.calendarsync.CalendarSyncUIState -import org.openedx.course.presentation.container.CourseContainerFragment import org.openedx.course.presentation.ui.CourseDatesBanner import org.openedx.course.presentation.ui.CourseDatesBannerTablet -import org.openedx.course.presentation.ui.DatesShiftedSnackBar import java.util.concurrent.atomic.AtomicReference -import org.openedx.core.R as coreR +import org.openedx.core.R as CoreR -class CourseDatesFragment : Fragment() { - - val viewModel by viewModel { - parametersOf( - requireArguments().getString(ARG_COURSE_ID, ""), - requireArguments().getString(ARG_COURSE_NAME, ""), - requireArguments().getBoolean(ARG_IS_SELF_PACED, true), - requireArguments().getString(ARG_ENROLLMENT_MODE, "") - ) - } - private val router by inject() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - viewModel.updateAndFetchCalendarSyncState() - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ) = ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - OpenEdXTheme { - val windowSize = rememberWindowSize() - val uiState by viewModel.uiState.observeAsState(DatesUIState.Loading) - val uiMessage by viewModel.uiMessage.observeAsState() - val refreshing by viewModel.updating.observeAsState(false) - val calendarSyncUIState by viewModel.calendarSyncUIState.collectAsState() - - CourseDatesScreen( - windowSize = windowSize, - uiState = uiState, - uiMessage = uiMessage, - refreshing = refreshing, - isSelfPaced = viewModel.isSelfPaced, - hasInternetConnection = viewModel.hasInternetConnection, - calendarSyncUIState = calendarSyncUIState, - onReloadClick = { - viewModel.getCourseDates() - }, - onSwipeRefresh = { - viewModel.getCourseDates(swipeToRefresh = true) - }, - onItemClick = { block -> - if (block.blockId.isNotEmpty()) { - viewModel.getVerticalBlock(block.blockId)?.let { verticalBlock -> - viewModel.logCourseComponentTapped(true, block) - if (viewModel.isCourseExpandableSectionsEnabled) { - router.navigateToCourseContainer( - fm = requireActivity().supportFragmentManager, - courseId = viewModel.courseId, +@Composable +fun CourseDatesScreen( + windowSize: WindowSize, + courseDatesViewModel: CourseDatesViewModel, + courseRouter: CourseRouter, + 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 context = LocalContext.current + + CourseDatesUI( + windowSize = windowSize, + uiState = uiState, + uiMessage = uiMessage, + isSelfPaced = courseDatesViewModel.isSelfPaced, + calendarSyncUIState = calendarSyncUIState, + onItemClick = { block -> + if (block.blockId.isNotEmpty()) { + courseDatesViewModel.getVerticalBlock(block.blockId) + ?.let { verticalBlock -> + courseDatesViewModel.logCourseComponentTapped(true, block) + if (courseDatesViewModel.isCourseExpandableSectionsEnabled) { + courseRouter.navigateToCourseContainer( + fm = fragmentManager, + courseId = courseDatesViewModel.courseId, + unitId = verticalBlock.id, + componentId = "", + mode = CourseViewMode.FULL + ) + } else { + courseDatesViewModel.getSequentialBlock(verticalBlock.id) + ?.let { sequentialBlock -> + courseRouter.navigateToCourseSubsections( + fm = fragmentManager, + subSectionId = sequentialBlock.id, + courseId = courseDatesViewModel.courseId, unitId = verticalBlock.id, - componentId = "", mode = CourseViewMode.FULL ) - } else { - viewModel.getSequentialBlock(verticalBlock.id) - ?.let { sequentialBlock -> - router.navigateToCourseSubsections( - fm = requireActivity().supportFragmentManager, - subSectionId = sequentialBlock.id, - courseId = viewModel.courseId, - unitId = verticalBlock.id, - mode = CourseViewMode.FULL - ) - } } - } ?: { - viewModel.logCourseComponentTapped(false, block) - ActionDialogFragment.newInstance( - title = getString(coreR.string.core_leaving_the_app), - message = getString( - coreR.string.core_leaving_the_app_message, - getString(coreR.string.platform_name) - ), - url = block.link, - source = CoreAnalyticsScreen.COURSE_DATES.screenName - ).show( - requireActivity().supportFragmentManager, - ActionDialogFragment::class.simpleName - ) - - } } - }, - onPLSBannerViewed = { - if (isResumed) { - viewModel.logPlsBannerViewed() - } - }, - onSyncDates = { - viewModel.logPlsShiftButtonClicked() - viewModel.resetCourseDatesBanner { - viewModel.logPlsShiftDates(it) - if (it) { - (parentFragment as CourseContainerFragment) - .updateCourseStructure(false) - } - } - }, - onCalendarSyncSwitch = { isChecked -> - viewModel.handleCalendarSyncState(isChecked) - }, - ) - } - } - } - - fun updateData() { - viewModel.getCourseDates() - } + } ?: { + courseDatesViewModel.logCourseComponentTapped(false, block) + ActionDialogFragment.newInstance( + title = context.getString(CoreR.string.core_leaving_the_app), + message = context.getString( + CoreR.string.core_leaving_the_app_message, + context.getString(CoreR.string.platform_name) + ), + url = block.link, + source = CoreAnalyticsScreen.COURSE_DATES.screenName + ).show( + fragmentManager, + ActionDialogFragment::class.simpleName + ) - companion object { - private const val ARG_COURSE_ID = "courseId" - private const val ARG_COURSE_NAME = "courseName" - private const val ARG_IS_SELF_PACED = "selfPaced" - private const val ARG_ENROLLMENT_MODE = "enrollmentMode" - - fun newInstance( - courseId: String, - courseName: String, - isSelfPaced: Boolean, - enrollmentMode: String, - ): CourseDatesFragment { - val fragment = CourseDatesFragment() - fragment.arguments = - bundleOf( - ARG_COURSE_ID to courseId, - ARG_COURSE_NAME to courseName, - ARG_IS_SELF_PACED to isSelfPaced, - ARG_ENROLLMENT_MODE to enrollmentMode, - ) - return fragment - } - } + } + } + }, + onPLSBannerViewed = { + if (isFragmentResumed) { + courseDatesViewModel.logPlsBannerViewed() + } + }, + onSyncDates = { + courseDatesViewModel.logPlsShiftButtonClicked() + courseDatesViewModel.resetCourseDatesBanner { + courseDatesViewModel.logPlsShiftDates(it) + if (it) { + updateCourseStructure() + } + } + }, + onCalendarSyncSwitch = { isChecked -> + courseDatesViewModel.handleCalendarSyncState(isChecked) + }, + ) } -@OptIn(ExperimentalMaterialApi::class) @Composable -internal fun CourseDatesScreen( +private fun CourseDatesUI( windowSize: WindowSize, uiState: DatesUIState, uiMessage: UIMessage?, - refreshing: Boolean, isSelfPaced: Boolean, - hasInternetConnection: Boolean, calendarSyncUIState: CalendarSyncUIState, - onReloadClick: () -> Unit, - onSwipeRefresh: () -> Unit, onItemClick: (CourseDateBlock) -> Unit, onPLSBannerViewed: () -> Unit, onSyncDates: () -> Unit, onCalendarSyncSwitch: (Boolean) -> Unit = {}, ) { val scaffoldState = rememberScaffoldState() - val pullRefreshState = - rememberPullRefreshState(refreshing = refreshing, onRefresh = { onSwipeRefresh() }) - - var isInternetConnectionShown by rememberSaveable { - mutableStateOf(false) - } Scaffold( modifier = Modifier.fillMaxSize(), @@ -305,16 +212,6 @@ internal fun CourseDatesScreen( ) } - val snackState = remember { SnackbarHostState() } - if (uiMessage is DatesShiftedSnackBar) { - val datesShiftedMessage = stringResource(id = R.string.course_dates_shifted_message) - LaunchedEffect(uiMessage) { - snackState.showSnackbar( - message = datesShiftedMessage, - duration = SnackbarDuration.Long - ) - } - } HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) Box( @@ -330,18 +227,8 @@ internal fun CourseDatesScreen( Box( Modifier .fillMaxWidth() - .pullRefresh(pullRefreshState) ) { when (uiState) { - is DatesUIState.Loading -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator(color = MaterialTheme.appColors.primary) - } - } - is DatesUIState.Dates -> { LazyColumn( modifier = Modifier @@ -423,34 +310,10 @@ internal fun CourseDatesScreen( ) } } - } - PullRefreshIndicator( - refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter) - ) - if (!isInternetConnectionShown && !hasInternetConnection) { - OfflineModeDialog( - Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter), - onDismissCLick = { - isInternetConnectionShown = true - }, - onReloadClick = { - isInternetConnectionShown = true - onReloadClick() - }) + DatesUIState.Loading -> {} } } - - SnackbarHost( - modifier = Modifier.align(Alignment.BottomStart), - hostState = snackState - ) { snackbarData: SnackbarData -> - DatesShiftedSnackBar(onClose = { - snackbarData.dismiss() - }) - } } } } @@ -566,7 +429,7 @@ fun ExpandableView( AnimatedVisibility(visible = expanded.not()) { Text( text = pluralStringResource( - id = coreR.plurals.core_date_items_hidden, + id = CoreR.plurals.core_date_items_hidden, count = sectionDates.size, formatArgs = arrayOf(sectionDates.size) ), @@ -726,7 +589,7 @@ private fun CourseDateItem( modifier = Modifier .padding(end = 4.dp) .align(Alignment.CenterVertically), - painter = painterResource(id = if (dateBlock.learnerHasAccess.not()) coreR.drawable.core_ic_lock else icon), + painter = painterResource(id = if (dateBlock.learnerHasAccess.not()) CoreR.drawable.core_ic_lock else icon), contentDescription = null, tint = MaterialTheme.appColors.textDark ) @@ -776,16 +639,12 @@ private fun CourseDateItem( @Composable private fun CourseDatesScreenPreview() { OpenEdXTheme { - CourseDatesScreen( + CourseDatesUI( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), uiState = DatesUIState.Dates(CourseDatesResult(mockedResponse, mockedCourseBannerInfo)), uiMessage = null, - refreshing = false, isSelfPaced = true, - hasInternetConnection = true, calendarSyncUIState = mockCalendarSyncUIState, - onReloadClick = {}, - onSwipeRefresh = {}, onItemClick = {}, onPLSBannerViewed = {}, onSyncDates = {}, @@ -799,16 +658,12 @@ private fun CourseDatesScreenPreview() { @Composable private fun CourseDatesScreenTabletPreview() { OpenEdXTheme { - CourseDatesScreen( + CourseDatesUI( windowSize = WindowSize(WindowType.Medium, WindowType.Medium), uiState = DatesUIState.Dates(CourseDatesResult(mockedResponse, mockedCourseBannerInfo)), uiMessage = null, - refreshing = false, isSelfPaced = true, - hasInternetConnection = true, calendarSyncUIState = mockCalendarSyncUIState, - onReloadClick = {}, - onSwipeRefresh = {}, onItemClick = {}, onPLSBannerViewed = {}, onSyncDates = {}, 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 8fa8ad43e..79f866ba7 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 @@ -3,14 +3,16 @@ 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 +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.SingleEventLiveData import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences @@ -20,12 +22,15 @@ import org.openedx.core.domain.model.CourseDateBlock import org.openedx.core.extension.getSequentialBlocks import org.openedx.core.extension.getVerticalBlocks import org.openedx.core.extension.isInternetError +import org.openedx.core.presentation.course.CourseContainerTab 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.CourseDataReady +import org.openedx.core.system.notifier.CourseDatesShifted +import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier -import org.openedx.course.DatesShiftedSnackBar +import org.openedx.core.system.notifier.CourseRefresh import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseAnalyticsEvent @@ -36,27 +41,27 @@ import org.openedx.course.presentation.calendarsync.CalendarSyncUIState import org.openedx.core.R as CoreR class CourseDatesViewModel( - val courseId: String, - val courseName: String, - val isSelfPaced: Boolean, private val enrollmentMode: String, - private val notifier: CourseNotifier, + private val courseNotifier: CourseNotifier, private val interactor: CourseInteractor, private val calendarManager: CalendarManager, - private val networkConnection: NetworkConnection, private val resourceManager: ResourceManager, private val corePreferences: CorePreferences, private val courseAnalytics: CourseAnalytics, private val config: Config, ) : BaseViewModel() { + var courseId = "" + var courseName = "" + var isSelfPaced = true + private val _uiState = MutableLiveData(DatesUIState.Loading) val uiState: LiveData get() = _uiState - private val _uiMessage = SingleEventLiveData() - val uiMessage: LiveData - get() = _uiMessage + private val _uiMessage = MutableSharedFlow() + val uiMessage: SharedFlow + get() = _uiMessage.asSharedFlow() private val _calendarSyncUIState = MutableStateFlow( CalendarSyncUIState( @@ -68,36 +73,36 @@ class CourseDatesViewModel( val calendarSyncUIState: StateFlow = _calendarSyncUIState.asStateFlow() - private val _updating = MutableLiveData() - val updating: LiveData - get() = _updating - - val hasInternetConnection: Boolean - get() = networkConnection.isOnline() - private var courseBannerType: CourseBannerType = CourseBannerType.BLANK val isCourseExpandableSectionsEnabled get() = config.isCourseNestedListEnabled() init { - getCourseDates() viewModelScope.launch { - notifier.notifier.collect { event -> - if (event is CheckCalendarSyncEvent) { - _calendarSyncUIState.update { it.copy(isSynced = event.isSynced) } + courseNotifier.notifier.collect { event -> + when (event) { + is CheckCalendarSyncEvent -> { + _calendarSyncUIState.update { it.copy(isSynced = event.isSynced) } + } + + is CourseRefresh -> { + if (event.courseContainerTab == CourseContainerTab.DATES) { + loadingCourseDatesInternal() + } + } + + is CourseDataReady -> { + courseId = event.courseStructure.id + courseName = event.courseStructure.name + isSelfPaced = event.courseStructure.isSelfPaced + loadingCourseDatesInternal() + updateAndFetchCalendarSyncState() + } } } } } - fun getCourseDates(swipeToRefresh: Boolean = false) { - if (!swipeToRefresh) { - _uiState.value = DatesUIState.Loading - } - _updating.value = swipeToRefresh - loadingCourseDatesInternal() - } - private fun loadingCourseDatesInternal() { viewModelScope.launch { try { @@ -111,14 +116,13 @@ class CourseDatesViewModel( } } catch (e: Exception) { if (e.isInternetError()) { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(CoreR.string.core_error_no_connection)) + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(CoreR.string.core_error_no_connection))) } else { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(CoreR.string.core_error_unknown_error)) + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(CoreR.string.core_error_unknown_error))) } + } finally { + courseNotifier.send(CourseLoading(false)) } - _updating.value = false } } @@ -126,16 +130,14 @@ class CourseDatesViewModel( viewModelScope.launch { try { interactor.resetCourseDates(courseId = courseId) - getCourseDates() - _uiMessage.value = DatesShiftedSnackBar() + loadingCourseDatesInternal() + courseNotifier.send(CourseDatesShifted) onResetDates(true) } catch (e: Exception) { if (e.isInternetError()) { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(CoreR.string.core_error_no_connection)) + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(CoreR.string.core_error_no_connection))) } else { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_dates_shift_dates_unsuccessful_msg)) + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_dates_shift_dates_unsuccessful_msg))) } onResetDates(false) } @@ -172,7 +174,7 @@ class CourseDatesViewModel( ) } - fun updateAndFetchCalendarSyncState(): Boolean { + private fun updateAndFetchCalendarSyncState(): Boolean { val isCalendarSynced = calendarManager.isCalendarExists( calendarTitle = _calendarSyncUIState.value.calendarTitle ) @@ -184,7 +186,7 @@ class CourseDatesViewModel( val value = _uiState.value if (value is DatesUIState.Dates) { viewModelScope.launch { - notifier.send( + courseNotifier.send( CreateCalendarSyncEvent( courseDates = value.courseDatesResult.datesSection.values.flatten(), dialogType = dialog.name, @@ -199,7 +201,7 @@ class CourseDatesViewModel( val value = _uiState.value if (value is DatesUIState.Dates) { viewModelScope.launch { - notifier.send( + courseNotifier.send( CreateCalendarSyncEvent( courseDates = value.courseDatesResult.datesSection.values.flatten(), dialogType = CalendarSyncDialogType.NONE.name, @@ -225,7 +227,7 @@ class CourseDatesViewModel( } fun logPlsShiftDates(isSuccess: Boolean) { - logPLSBannerEvent(CourseAnalyticsEvent.PLS_SHIFT_DATES, isSuccess) + logPLSBannerEvent(CourseAnalyticsEvent.PLS_SHIFT_DATES_SUCCESS, isSuccess) } fun logCourseComponentTapped(isSupported: Boolean, block: CourseDateBlock) { @@ -261,7 +263,11 @@ class CourseDatesViewModel( put(CourseAnalyticsKey.NAME.key, event.biValue) put(CourseAnalyticsKey.COURSE_ID.key, courseId) put(CourseAnalyticsKey.ENROLLMENT_MODE.key, enrollmentMode) - put(CourseAnalyticsKey.PACING.key, isSelfPaced) + put( + CourseAnalyticsKey.PACING.key, + if (isSelfPaced) CourseAnalyticsKey.SELF_PACED.key + else CourseAnalyticsKey.INSTRUCTOR_PACED.key + ) putAll(param) } ) diff --git a/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsFragment.kt b/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsScreen.kt similarity index 69% rename from course/src/main/java/org/openedx/course/presentation/handouts/HandoutsFragment.kt rename to course/src/main/java/org/openedx/course/presentation/handouts/HandoutsScreen.kt index 8f49c86c0..184031091 100644 --- a/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsScreen.kt @@ -1,13 +1,26 @@ package org.openedx.course.presentation.handouts import android.content.res.Configuration -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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +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.material.* +import androidx.compose.material.Divider +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.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -15,75 +28,24 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.painter.Painter -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.Devices 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 org.koin.android.ext.android.inject -import org.openedx.core.ui.* +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.course.presentation.CourseRouter +import org.openedx.core.ui.windowSizeValue import org.openedx.course.presentation.ui.CardArrow import org.openedx.course.R as courseR -class HandoutsFragment : Fragment() { - - private val router by inject() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ) = ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - OpenEdXTheme { - val windowSize = rememberWindowSize() - HandoutsScreen( - windowSize = windowSize, - onHandoutsClick = { - router.navigateToHandoutsWebView( - requireActivity().supportFragmentManager, - requireArguments().getString(ARG_COURSE_ID, ""), - getString(courseR.string.course_handouts), - HandoutsType.Handouts - ) - }, - onAnnouncementsClick = { - router.navigateToHandoutsWebView( - requireActivity().supportFragmentManager, - requireArguments().getString(ARG_COURSE_ID, ""), - getString(courseR.string.course_announcements), - HandoutsType.Announcements - ) - }) - } - } - } - - companion object { - private const val ARG_COURSE_ID = "argCourseId" - fun newInstance(courseId: String): HandoutsFragment { - val fragment = HandoutsFragment() - fragment.arguments = bundleOf( - ARG_COURSE_ID to courseId - ) - return fragment - } - } - -} - @Composable -private fun HandoutsScreen( +fun HandoutsScreen( windowSize: WindowSize, onHandoutsClick: () -> Unit, onAnnouncementsClick: () -> Unit, diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineFragment.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt similarity index 59% rename from course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineFragment.kt rename to course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt index 26b6384a6..7e950cba8 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt @@ -2,9 +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 android.os.Bundle -import android.view.LayoutInflater -import android.view.ViewGroup import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -12,7 +9,6 @@ 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.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -22,35 +18,22 @@ 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.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.Scaffold -import androidx.compose.material.SnackbarData -import androidx.compose.material.SnackbarDuration -import androidx.compose.material.SnackbarHost -import androidx.compose.material.SnackbarHostState 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.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 import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.AndroidUriHandler -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow @@ -58,13 +41,8 @@ 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.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 androidx.fragment.app.FragmentManager import org.openedx.core.BlockType -import org.openedx.core.R import org.openedx.core.UIMessage import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts @@ -74,219 +52,151 @@ import org.openedx.core.domain.model.CoursewareAccess import org.openedx.core.extension.takeIfNotEmpty import org.openedx.core.presentation.course.CourseViewMode 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.WindowSize import org.openedx.core.ui.WindowType 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.DatesShiftedSnackBar +import org.openedx.course.R import org.openedx.course.presentation.CourseRouter -import org.openedx.course.presentation.container.CourseContainerFragment -import org.openedx.course.presentation.container.CourseContainerTab -import org.openedx.course.presentation.outline.CourseOutlineFragment.Companion.getUnitBlockIcon 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.CourseImageHeader import org.openedx.course.presentation.ui.CourseSectionCard import org.openedx.course.presentation.ui.CourseSubSectionItem -import org.openedx.course.presentation.ui.DatesShiftedSnackBar import java.io.File import java.util.Date +import org.openedx.core.R as CoreR -class CourseOutlineFragment : Fragment() { - - private val viewModel by viewModel { - parametersOf(requireArguments().getString(ARG_COURSE_ID, "")) - } - private val router by inject() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - lifecycle.addObserver(viewModel) - with(requireArguments()) { - viewModel.courseTitle = getString(ARG_TITLE, "") - } - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ) = ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - OpenEdXTheme { - val windowSize = rememberWindowSize() - - val uiState by viewModel.uiState.observeAsState(CourseOutlineUIState.Loading) - val uiMessage by viewModel.uiMessage.observeAsState() - val refreshing by viewModel.isUpdating.observeAsState(false) - - CourseOutlineScreen( - windowSize = windowSize, - uiState = uiState, - apiHostUrl = viewModel.apiHostUrl, - isCourseNestedListEnabled = viewModel.isCourseNestedListEnabled, - isCourseBannerEnabled = viewModel.isCourseBannerEnabled, - uiMessage = uiMessage, - refreshing = refreshing, - onSwipeRefresh = { - viewModel.setIsUpdating() - (parentFragment as CourseContainerFragment).updateCourseStructure(true) - }, - hasInternetConnection = viewModel.hasInternetConnection, - onReloadClick = { - (parentFragment as CourseContainerFragment).updateCourseStructure(false) - }, - onItemClick = { block -> - viewModel.sequentialClickedEvent(block.blockId, block.displayName) - router.navigateToCourseSubsections( - fm = requireActivity().supportFragmentManager, - courseId = viewModel.courseId, - subSectionId = block.id, +@Composable +fun CourseOutlineScreen( + windowSize: WindowSize, + courseOutlineViewModel: CourseOutlineViewModel, + courseRouter: CourseRouter, + fragmentManager: FragmentManager, + onResetDatesClick: () -> Unit +) { + val uiState by courseOutlineViewModel.uiState.collectAsState() + val uiMessage by courseOutlineViewModel.uiMessage.collectAsState(null) + val context = LocalContext.current + + CourseOutlineUI( + windowSize = windowSize, + uiState = uiState, + isCourseNestedListEnabled = courseOutlineViewModel.isCourseNestedListEnabled, + uiMessage = uiMessage, + onItemClick = { block -> + courseOutlineViewModel.sequentialClickedEvent( + block.blockId, + block.displayName + ) + courseRouter.navigateToCourseSubsections( + fm = fragmentManager, + courseId = courseOutlineViewModel.courseId, + subSectionId = block.id, + mode = CourseViewMode.FULL + ) + }, + onExpandClick = { block -> + if (courseOutlineViewModel.switchCourseSections(block.id)) { + courseOutlineViewModel.sequentialClickedEvent( + block.blockId, + block.displayName + ) + } + }, + onSubSectionClick = { subSectionBlock -> + courseOutlineViewModel.courseSubSectionUnit[subSectionBlock.id]?.let { unit -> + courseOutlineViewModel.logUnitDetailViewedEvent( + unit.blockId, + unit.displayName + ) + courseRouter.navigateToCourseContainer( + fragmentManager, + courseId = courseOutlineViewModel.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 ) - }, - onExpandClick = { block -> - if (viewModel.switchCourseSections(block.id)) { - viewModel.sequentialClickedEvent(block.blockId, block.displayName) - } - }, - onSubSectionClick = { subSectionBlock -> - viewModel.courseSubSectionUnit[subSectionBlock.id]?.let { unit -> - viewModel.logUnitDetailViewedEvent(unit.blockId, unit.displayName) - router.navigateToCourseContainer( - requireActivity().supportFragmentManager, - courseId = viewModel.courseId, - unitId = unit.id, - mode = CourseViewMode.FULL - ) - } - }, - onResumeClick = { componentId -> - viewModel.resumeSectionBlock?.let { subSection -> - viewModel.resumeCourseTappedEvent(subSection.id) - viewModel.resumeVerticalBlock?.let { unit -> - if (viewModel.isCourseExpandableSectionsEnabled) { - router.navigateToCourseContainer( - fm = requireActivity().supportFragmentManager, - courseId = viewModel.courseId, - unitId = unit.id, - componentId = componentId, - mode = CourseViewMode.FULL - ) - } else { - router.navigateToCourseSubsections( - requireActivity().supportFragmentManager, - courseId = viewModel.courseId, - subSectionId = subSection.id, - mode = CourseViewMode.FULL, - unitId = unit.id, - componentId = componentId - ) - } - } - } - }, - onDownloadClick = { - if (viewModel.isBlockDownloading(it.id)) { - router.navigateToDownloadQueue( - fm = requireActivity().supportFragmentManager, - viewModel.getDownloadableChildren(it.id) ?: arrayListOf() - ) - } else if (viewModel.isBlockDownloaded(it.id)) { - viewModel.removeDownloadModels(it.id) - } else { - viewModel.saveDownloadModels( - requireContext().externalCacheDir.toString() + - File.separator + - requireContext() - .getString(R.string.app_name) - .replace(Regex("\\s"), "_"), it.id - ) - } - }, - onResetDatesClick = { - viewModel.resetCourseDatesBanner(onResetDates = { - (parentFragment as CourseContainerFragment).updateCourseDates() - }) - }, - onViewDates = { - (parentFragment as CourseContainerFragment).navigateToTab(CourseContainerTab.DATES) - }, - onCertificateClick = { - viewModel.viewCertificateTappedEvent() - it.takeIfNotEmpty() - ?.let { url -> AndroidUriHandler(requireContext()).openUri(url) } + } else { + courseRouter.navigateToCourseSubsections( + fragmentManager, + courseId = courseOutlineViewModel.courseId, + subSectionId = subSection.id, + mode = CourseViewMode.FULL, + unitId = unit.id, + componentId = componentId + ) } + } + } + }, + onDownloadClick = { + if (courseOutlineViewModel.isBlockDownloading(it.id)) { + courseRouter.navigateToDownloadQueue( + fm = fragmentManager, + courseOutlineViewModel.getDownloadableChildren(it.id) + ?: arrayListOf() + ) + } else if (courseOutlineViewModel.isBlockDownloaded(it.id)) { + courseOutlineViewModel.removeDownloadModels(it.id) + } else { + courseOutlineViewModel.saveDownloadModels( + context.externalCacheDir.toString() + + File.separator + + context + .getString(CoreR.string.app_name) + .replace(Regex("\\s"), "_"), it.id ) } - } - } - - companion object { - private const val ARG_COURSE_ID = "courseId" - private const val ARG_TITLE = "title" - fun newInstance( - courseId: String, - title: String, - ): CourseOutlineFragment { - val fragment = CourseOutlineFragment() - fragment.arguments = bundleOf( - ARG_COURSE_ID to courseId, - ARG_TITLE to title + }, + onResetDatesClick = { + courseOutlineViewModel.resetCourseDatesBanner( + onResetDates = { + onResetDatesClick() + } ) - return fragment - } - - fun getUnitBlockIcon(block: Block): Int { - return when (block.type) { - BlockType.VIDEO -> org.openedx.course.R.drawable.ic_course_video - BlockType.PROBLEM -> org.openedx.course.R.drawable.ic_course_pen - BlockType.DISCUSSION -> org.openedx.course.R.drawable.ic_course_discussion - else -> org.openedx.course.R.drawable.ic_course_block - } + }, + onCertificateClick = { + courseOutlineViewModel.viewCertificateTappedEvent() + it.takeIfNotEmpty() + ?.let { url -> AndroidUriHandler(context).openUri(url) } } - } + ) } - -@OptIn(ExperimentalMaterialApi::class) @Composable -internal fun CourseOutlineScreen( +private fun CourseOutlineUI( windowSize: WindowSize, uiState: CourseOutlineUIState, - apiHostUrl: String, isCourseNestedListEnabled: Boolean, - isCourseBannerEnabled: Boolean, uiMessage: UIMessage?, - refreshing: Boolean, - hasInternetConnection: Boolean, - onReloadClick: () -> Unit, - onSwipeRefresh: () -> Unit, onItemClick: (Block) -> Unit, onExpandClick: (Block) -> Unit, onSubSectionClick: (Block) -> Unit, onResumeClick: (String) -> Unit, onDownloadClick: (Block) -> Unit, onResetDatesClick: () -> Unit, - onViewDates: () -> Unit?, onCertificateClick: (String) -> Unit, ) { val scaffoldState = rememberScaffoldState() - val pullRefreshState = - rememberPullRefreshState(refreshing = refreshing, onRefresh = { onSwipeRefresh() }) - - var isInternetConnectionShown by rememberSaveable { - mutableStateOf(false) - } Scaffold( modifier = Modifier @@ -322,17 +232,6 @@ internal fun CourseOutlineScreen( ) } - val snackState = remember { SnackbarHostState() } - if (uiMessage is DatesShiftedSnackBar) { - val datesShiftedMessage = - stringResource(id = org.openedx.course.R.string.course_dates_shifted_message) - LaunchedEffect(uiMessage) { - snackState.showSnackbar( - message = datesShiftedMessage, - duration = SnackbarDuration.Long - ) - } - } HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) Box( @@ -346,37 +245,13 @@ internal fun CourseOutlineScreen( modifier = screenWidth, color = MaterialTheme.appColors.background ) { - Box(Modifier.pullRefresh(pullRefreshState)) { + Box { when (uiState) { - is CourseOutlineUIState.Loading -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator(color = MaterialTheme.appColors.primary) - } - } - is CourseOutlineUIState.CourseData -> { LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = listBottomPadding ) { - if (isCourseBannerEnabled) { - item { - CourseImageHeader( - modifier = Modifier - .aspectRatio(1.86f) - .padding(6.dp), - apiHostUrl = apiHostUrl, - courseImage = uiState.courseStructure.media?.image?.large - ?: "", - courseCertificate = uiState.courseStructure.certificate, - onCertificateClick = onCertificateClick, - courseName = uiState.courseStructure.name - ) - } - } if (uiState.datesBannerInfo.isBannerAvailableForDashboard()) { item { Box( @@ -490,37 +365,9 @@ internal fun CourseOutlineScreen( } } } - } - PullRefreshIndicator( - refreshing, - pullRefreshState, - Modifier.align(Alignment.TopCenter) - ) - if (!isInternetConnectionShown && !hasInternetConnection) { - OfflineModeDialog( - Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter), - onDismissCLick = { - isInternetConnectionShown = true - }, - onReloadClick = { - isInternetConnectionShown = true - onReloadClick() - } - ) - } - } - SnackbarHost( - modifier = Modifier.align(Alignment.BottomStart), - hostState = snackState - ) { snackbarData: SnackbarData -> - DatesShiftedSnackBar(showAction = true, - onViewDates = onViewDates, - onClose = { - snackbarData.dismiss() - }) + CourseOutlineUIState.Loading -> {} + } } } } @@ -537,7 +384,7 @@ private fun ResumeCourse( modifier = modifier.fillMaxWidth() ) { Text( - text = stringResource(id = org.openedx.course.R.string.course_continue_with), + text = stringResource(id = R.string.course_continue_with), style = MaterialTheme.appTypography.labelMedium, color = MaterialTheme.appColors.textPrimaryVariant ) @@ -562,14 +409,14 @@ private fun ResumeCourse( } Spacer(Modifier.height(24.dp)) OpenEdXButton( - text = stringResource(id = org.openedx.course.R.string.course_resume), + text = stringResource(id = R.string.course_resume), onClick = { onResumeClick(block.id) }, content = { TextIcon( - text = stringResource(id = org.openedx.course.R.string.course_resume), - painter = painterResource(id = R.drawable.core_ic_forward), + text = stringResource(id = R.string.course_resume), + painter = painterResource(id = CoreR.drawable.core_ic_forward), color = MaterialTheme.appColors.buttonText, textStyle = MaterialTheme.appTypography.labelLarge ) @@ -595,7 +442,7 @@ private fun ResumeCourseTablet( .padding(end = 35.dp) ) { Text( - text = stringResource(id = org.openedx.course.R.string.course_continue_with), + text = stringResource(id = R.string.course_continue_with), style = MaterialTheme.appTypography.labelMedium, color = MaterialTheme.appColors.textPrimaryVariant ) @@ -621,14 +468,14 @@ private fun ResumeCourseTablet( } OpenEdXButton( modifier = Modifier.width(210.dp), - text = stringResource(id = org.openedx.course.R.string.course_resume), + text = stringResource(id = R.string.course_resume), onClick = { onResumeClick(block.id) }, content = { TextIcon( - text = stringResource(id = org.openedx.course.R.string.course_resume), - painter = painterResource(id = R.drawable.core_ic_forward), + text = stringResource(id = R.string.course_resume), + painter = painterResource(id = CoreR.drawable.core_ic_forward), color = MaterialTheme.appColors.buttonText, textStyle = MaterialTheme.appTypography.labelLarge ) @@ -637,12 +484,21 @@ private fun ResumeCourseTablet( } } +fun getUnitBlockIcon(block: Block): Int { + return when (block.type) { + 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 = UI_MODE_NIGHT_NO) @Preview(uiMode = UI_MODE_NIGHT_YES) @Composable private fun CourseOutlineScreenPreview() { OpenEdXTheme { - CourseOutlineScreen( + CourseOutlineUI( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), uiState = CourseOutlineUIState.CourseData( mockCourseStructure, @@ -659,21 +515,14 @@ private fun CourseOutlineScreenPreview() { hasEnded = false ) ), - apiHostUrl = "", isCourseNestedListEnabled = true, - isCourseBannerEnabled = true, uiMessage = null, - refreshing = false, - hasInternetConnection = true, - onSwipeRefresh = {}, onItemClick = {}, onExpandClick = {}, onSubSectionClick = {}, onResumeClick = {}, - onReloadClick = {}, onDownloadClick = {}, onResetDatesClick = {}, - onViewDates = {}, onCertificateClick = {}, ) } @@ -684,7 +533,7 @@ private fun CourseOutlineScreenPreview() { @Composable private fun CourseOutlineScreenTabletPreview() { OpenEdXTheme { - CourseOutlineScreen( + CourseOutlineUI( windowSize = WindowSize(WindowType.Medium, WindowType.Medium), uiState = CourseOutlineUIState.CourseData( mockCourseStructure, @@ -701,21 +550,14 @@ private fun CourseOutlineScreenTabletPreview() { hasEnded = false ) ), - apiHostUrl = "", isCourseNestedListEnabled = true, - isCourseBannerEnabled = true, uiMessage = null, - refreshing = false, - hasInternetConnection = true, - onSwipeRefresh = {}, onItemClick = {}, onExpandClick = {}, onSubSectionClick = {}, onResumeClick = {}, - onReloadClick = {}, onDownloadClick = {}, onResetDatesClick = {}, - onViewDates = {}, onCertificateClick = {}, ) } 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 8b29be31e..0307b1f8e 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 @@ -16,5 +16,5 @@ sealed class CourseOutlineUIState { val datesBannerInfo: CourseDatesBannerInfo, ) : CourseOutlineUIState() - object Loading : 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 eeabab539..569498ab6 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,13 +1,15 @@ package org.openedx.course.presentation.outline -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData 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.BlockType 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 @@ -26,9 +28,11 @@ 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 import org.openedx.core.system.notifier.CourseStructureUpdated -import org.openedx.course.DatesShiftedSnackBar import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseAnalyticsEvent @@ -38,10 +42,11 @@ import org.openedx.course.R as courseR class CourseOutlineViewModel( val courseId: String, + private val courseTitle: String, private val config: Config, private val interactor: CourseInteractor, private val resourceManager: ResourceManager, - private val notifier: CourseNotifier, + private val courseNotifier: CourseNotifier, private val networkConnection: NetworkConnection, private val preferencesManager: CorePreferences, private val analytics: CourseAnalytics, @@ -55,48 +60,38 @@ class CourseOutlineViewModel( workerController, coreAnalytics ) { - - val apiHostUrl get() = config.getApiHostURL() - val isCourseNestedListEnabled get() = config.isCourseNestedListEnabled() - val isCourseBannerEnabled get() = config.isCourseBannerEnabled() - - private val _uiState = MutableLiveData(CourseOutlineUIState.Loading) - val uiState: LiveData - get() = _uiState - - private val _uiMessage = SingleEventLiveData() - val uiMessage: LiveData - get() = _uiMessage - - private val _isUpdating = MutableLiveData() - val isUpdating: LiveData - get() = _isUpdating + private val _uiState = MutableStateFlow(CourseOutlineUIState.Loading) + val uiState: StateFlow + get() = _uiState.asStateFlow() - var courseTitle = "" + private val _uiMessage = MutableSharedFlow() + val uiMessage: SharedFlow + get() = _uiMessage.asSharedFlow() var resumeSectionBlock: Block? = null private set var resumeVerticalBlock: Block? = null private set - val hasInternetConnection: Boolean - get() = networkConnection.isOnline() - val isCourseExpandableSectionsEnabled get() = config.isCourseNestedListEnabled() private val courseSubSections = mutableMapOf>() private val subSectionsDownloadsCount = mutableMapOf() val courseSubSectionUnit = mutableMapOf() - override fun onCreate(owner: LifecycleOwner) { - super.onCreate(owner) + init { viewModelScope.launch { - notifier.notifier.collect { event -> - if (event is CourseStructureUpdated) { - if (event.courseId == courseId) { - updateCourseData(event.withSwipeRefresh) + courseNotifier.notifier.collect { event -> + when(event) { + is CourseStructureUpdated -> { + if (event.courseId == courseId) { + updateCourseData() + } + } + is CourseDataReady -> { + getCourseData() } } } @@ -120,34 +115,28 @@ class CourseOutlineViewModel( } } - init { - getCourseData() - } - - fun setIsUpdating() { - _isUpdating.value = true - } - 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(courseR.string.course_can_download_only_with_wifi)) + viewModelScope.launch { + _uiMessage.emit(UIMessage.ToastMessage(resourceManager.getString(courseR.string.course_can_download_only_with_wifi))) + } } } else { super.saveDownloadModels(folder, id) } } - fun updateCourseData(withSwipeRefresh: Boolean) { - _isUpdating.value = withSwipeRefresh + fun updateCourseData() { getCourseDataInternal() } - private fun getCourseData() { - _uiState.value = CourseOutlineUIState.Loading + fun getCourseData() { + viewModelScope.launch { + courseNotifier.send(CourseLoading(true)) + } getCourseDataInternal() } @@ -222,18 +211,14 @@ class CourseOutlineViewModel( subSectionsDownloadsCount = subSectionsDownloadsCount, datesBannerInfo = datesBannerInfo, ) + courseNotifier.send(CourseLoading(false)) } catch (e: Exception) { if (e.isInternetError()) { - _uiMessage.value = UIMessage.SnackBarMessage( - resourceManager.getString(R.string.core_error_no_connection) - ) + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection))) } else { - _uiMessage.value = UIMessage.SnackBarMessage( - resourceManager.getString(R.string.core_error_unknown_error) - ) + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error))) } } - _isUpdating.value = false } } @@ -280,16 +265,14 @@ class CourseOutlineViewModel( viewModelScope.launch { try { interactor.resetCourseDates(courseId = courseId) - updateCourseData(false) - _uiMessage.value = DatesShiftedSnackBar() + updateCourseData() + courseNotifier.send(CourseDatesShifted) onResetDates(true) } catch (e: Exception) { if (e.isInternetError()) { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection))) } else { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_dates_shift_dates_unsuccessful_msg)) + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_dates_shift_dates_unsuccessful_msg))) } onResetDates(false) } @@ -354,7 +337,7 @@ class CourseOutlineViewModel( private fun checkIfCalendarOutOfDate(courseDates: List) { viewModelScope.launch { - notifier.send( + courseNotifier.send( CreateCalendarSyncEvent( courseDates = courseDates, dialogType = CalendarSyncDialogType.NONE.name, 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 9523d99e5..f9f028c0f 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 @@ -104,7 +104,7 @@ import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography import org.openedx.course.R import org.openedx.course.presentation.dates.mockedCourseBannerInfo -import org.openedx.course.presentation.outline.CourseOutlineFragment +import org.openedx.course.presentation.outline.getUnitBlockIcon import subtitleFile.Caption import subtitleFile.TimedTextObject import java.util.Date @@ -1014,7 +1014,7 @@ fun SubSectionUnitsList( modifier = Modifier .size(18.dp), painter = painterResource( - id = CourseOutlineFragment.getUnitBlockIcon(unit) + id = getUnitBlockIcon(unit) ), contentDescription = null, tint = MaterialTheme.appColors.textPrimary 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 8d5bc7172..c69e26c0d 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 @@ -12,7 +12,6 @@ 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.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -21,10 +20,11 @@ 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 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 @@ -38,11 +38,9 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Videocam -import androidx.compose.material.pullrefresh.PullRefreshIndicator -import androidx.compose.material.pullrefresh.pullRefresh -import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -50,12 +48,14 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.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 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.UIMessage @@ -66,8 +66,9 @@ import org.openedx.core.domain.model.CoursewareAccess 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.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 @@ -76,39 +77,124 @@ 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.CourseRouter +import org.openedx.course.presentation.videos.CourseVideoViewModel import org.openedx.course.presentation.videos.CourseVideosUIState +import java.io.File import java.util.Date -@OptIn(ExperimentalMaterialApi::class) @Composable fun CourseVideosScreen( + windowSize: WindowSize, + courseVideoViewModel: CourseVideoViewModel, + fragmentManager: FragmentManager, + courseRouter: CourseRouter +) { + val uiState by courseVideoViewModel.uiState.collectAsState(CourseVideosUIState.Loading) + val uiMessage by courseVideoViewModel.uiMessage.collectAsState(null) + val videoSettings by courseVideoViewModel.videoSettings.collectAsState() + val context = LocalContext.current + + CourseVideosUI( + windowSize = windowSize, + uiState = uiState, + uiMessage = uiMessage, + courseTitle = courseVideoViewModel.courseTitle, + isCourseNestedListEnabled = courseVideoViewModel.isCourseNestedListEnabled, + videoSettings = videoSettings, + onItemClick = { block -> + courseRouter.navigateToCourseSubsections( + fm = fragmentManager, + courseId = courseVideoViewModel.courseId, + subSectionId = block.id, + mode = CourseViewMode.VIDEOS + ) + }, + onExpandClick = { block -> + courseVideoViewModel.switchCourseSections(block.id) + }, + onSubSectionClick = { subSectionBlock -> + courseVideoViewModel.courseSubSectionUnit[subSectionBlock.id]?.let { unit -> + courseVideoViewModel.sequentialClickedEvent( + unit.blockId, + unit.displayName + ) + courseRouter.navigateToCourseContainer( + fm = fragmentManager, + courseId = courseVideoViewModel.courseId, + unitId = unit.id, + mode = CourseViewMode.VIDEOS + ) + } + }, + onDownloadClick = { + if (courseVideoViewModel.isBlockDownloading(it.id)) { + courseRouter.navigateToDownloadQueue( + fm = fragmentManager, + courseVideoViewModel.getDownloadableChildren(it.id) + ?: arrayListOf() + ) + } else if (courseVideoViewModel.isBlockDownloaded(it.id)) { + courseVideoViewModel.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 + ) + } + }, + onDownloadAllClick = { isAllBlocksDownloadedOrDownloading -> + courseVideoViewModel.logBulkDownloadToggleEvent(!isAllBlocksDownloadedOrDownloading) + if (isAllBlocksDownloadedOrDownloading) { + courseVideoViewModel.removeAllDownloadModels() + } else { + courseVideoViewModel.saveAllDownloadModels( + context.externalCacheDir.toString() + + File.separator + + context + .getString(org.openedx.core.R.string.app_name) + .replace(Regex("\\s"), "_") + ) + } + }, + onDownloadQueueClick = { + if (courseVideoViewModel.hasDownloadModelsInQueue()) { + courseRouter.navigateToDownloadQueue(fm = fragmentManager) + } + }, + onVideoDownloadQualityClick = { + if (courseVideoViewModel.hasDownloadModelsInQueue()) { + courseVideoViewModel.onChangingVideoQualityWhileDownloading() + } else { + courseRouter.navigateToVideoQuality( + fragmentManager, + VideoQualityType.Download + ) + } + } + ) +} + +@Composable +private fun CourseVideosUI( windowSize: WindowSize, uiState: CourseVideosUIState, uiMessage: UIMessage?, courseTitle: String, - apiHostUrl: String, isCourseNestedListEnabled: Boolean, - isCourseBannerEnabled: Boolean, - isUpdating: Boolean, - hasInternetConnection: Boolean, videoSettings: VideoSettings, - onSwipeRefresh: () -> Unit, onItemClick: (Block) -> Unit, onExpandClick: (Block) -> Unit, onSubSectionClick: (Block) -> Unit, - onReloadClick: () -> Unit, onDownloadClick: (Block) -> Unit, onDownloadAllClick: (Boolean) -> Unit, onDownloadQueueClick: () -> Unit, onVideoDownloadQualityClick: () -> Unit ) { val scaffoldState = rememberScaffoldState() - val pullRefreshState = - rememberPullRefreshState(refreshing = isUpdating, onRefresh = { onSwipeRefresh() }) - - var isInternetConnectionShown by rememberSaveable { - mutableStateOf(false) - } Scaffold( modifier = Modifier @@ -169,7 +255,7 @@ fun CourseVideosScreen( modifier = screenWidth, color = MaterialTheme.appColors.background ) { - Box(Modifier.pullRefresh(pullRefreshState)) { + Box { Column( Modifier .fillMaxSize() @@ -177,7 +263,9 @@ fun CourseVideosScreen( when (uiState) { is CourseVideosUIState.Empty -> { Box( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), contentAlignment = Alignment.Center ) { Text( @@ -190,35 +278,11 @@ fun CourseVideosScreen( } } - is CourseVideosUIState.Loading -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator(color = MaterialTheme.appColors.primary) - } - } - is CourseVideosUIState.CourseData -> { LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = listBottomPadding ) { - if (isCourseBannerEnabled) { - item { - CourseImageHeader( - modifier = Modifier - .aspectRatio(1.86f) - .padding(6.dp), - apiHostUrl = apiHostUrl, - courseImage = uiState.courseStructure.media?.image?.large - ?: "", - courseCertificate = uiState.courseStructure.certificate, - courseName = uiState.courseStructure.name - ) - } - } - if (uiState.downloadModelsSize.allCount > 0) { item { AllVideosDownloadItem( @@ -230,7 +294,6 @@ fun CourseVideosScreen( onDownloadAllClick = { isSwitched -> if (isSwitched) { isDeleteDownloadsConfirmationShowed = true - } else { onDownloadAllClick(false) } @@ -243,10 +306,8 @@ fun CourseVideosScreen( if (isCourseNestedListEnabled) { uiState.courseStructure.blockData.forEach { section -> - val courseSubSections = - uiState.courseSubSections[section.id] - val courseSectionsState = - uiState.courseSectionsState[section.id] + val courseSubSections = uiState.courseSubSections[section.id] + val courseSectionsState = uiState.courseSectionsState[section.id] item { Column { @@ -329,27 +390,10 @@ fun CourseVideosScreen( } } } + + CourseVideosUIState.Loading -> {} } } - PullRefreshIndicator( - isUpdating, - pullRefreshState, - Modifier.align(Alignment.TopCenter) - ) - if (!isInternetConnectionShown && !hasInternetConnection) { - OfflineModeDialog( - Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter), - onDismissCLick = { - isInternetConnectionShown = true - }, - onReloadClick = { - isInternetConnectionShown = true - onReloadClick() - } - ) - } } } } @@ -655,7 +699,7 @@ private fun AllVideosDownloadItem( @Composable private fun CourseVideosScreenPreview() { OpenEdXTheme { - CourseVideosScreen( + CourseVideosUI( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), uiMessage = null, uiState = CourseVideosUIState.CourseData( @@ -668,22 +712,16 @@ private fun CourseVideosScreenPreview() { isAllBlocksDownloadedOrDownloading = false, remainingCount = 0, remainingSize = 0, - allCount = 0, + allCount = 1, allSize = 0 ) ), courseTitle = "", - apiHostUrl = "", isCourseNestedListEnabled = false, - isCourseBannerEnabled = true, onItemClick = { }, onExpandClick = { }, onSubSectionClick = { }, - hasInternetConnection = true, - isUpdating = false, videoSettings = VideoSettings.default, - onSwipeRefresh = {}, - onReloadClick = {}, onDownloadClick = {}, onDownloadAllClick = {}, onDownloadQueueClick = {}, @@ -697,23 +735,17 @@ private fun CourseVideosScreenPreview() { @Composable private fun CourseVideosScreenEmptyPreview() { OpenEdXTheme { - CourseVideosScreen( + CourseVideosUI( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), uiMessage = null, uiState = CourseVideosUIState.Empty( "This course does not include any videos." ), courseTitle = "", - apiHostUrl = "", isCourseNestedListEnabled = false, - isCourseBannerEnabled = true, onItemClick = { }, onExpandClick = { }, onSubSectionClick = { }, - onSwipeRefresh = {}, - onReloadClick = {}, - hasInternetConnection = true, - isUpdating = false, videoSettings = VideoSettings.default, onDownloadClick = {}, onDownloadAllClick = {}, @@ -728,7 +760,7 @@ private fun CourseVideosScreenEmptyPreview() { @Composable private fun CourseVideosScreenTabletPreview() { OpenEdXTheme { - CourseVideosScreen( + CourseVideosUI( windowSize = WindowSize(WindowType.Medium, WindowType.Medium), uiMessage = null, uiState = CourseVideosUIState.CourseData( @@ -746,16 +778,10 @@ private fun CourseVideosScreenTabletPreview() { ) ), courseTitle = "", - apiHostUrl = "", isCourseNestedListEnabled = false, - isCourseBannerEnabled = true, onItemClick = { }, onExpandClick = { }, onSubSectionClick = { }, - onSwipeRefresh = {}, - onReloadClick = {}, - isUpdating = false, - hasInternetConnection = true, videoSettings = VideoSettings.default, onDownloadClick = {}, onDownloadAllClick = {}, 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 958d479c1..6d37954ee 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 @@ -9,7 +9,7 @@ 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.DiscussionTopicsFragment +import org.openedx.discussion.presentation.topics.DiscussionTopicsViewModel class CourseUnitContainerAdapter( fragment: Fragment, @@ -57,7 +57,7 @@ class CourseUnitContainerAdapter( (block.isDiscussionBlock && block.studentViewData?.topicId.isNullOrEmpty().not()) -> { DiscussionThreadsFragment.newInstance( - DiscussionTopicsFragment.TOPIC, + DiscussionTopicsViewModel.TOPIC, viewModel.courseId, block.studentViewData?.topicId ?: "", block.displayName, 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 5d0f3996d..dc88105a8 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,13 +1,14 @@ package org.openedx.course.presentation.videos -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData 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.BlockType -import org.openedx.core.SingleEventLiveData import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences @@ -19,6 +20,8 @@ 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 import org.openedx.core.system.notifier.VideoNotifier @@ -29,6 +32,7 @@ import org.openedx.course.presentation.CourseAnalytics class CourseVideoViewModel( val courseId: String, + val courseTitle: String, private val config: Config, private val interactor: CourseInteractor, private val resourceManager: ResourceManager, @@ -48,32 +52,19 @@ class CourseVideoViewModel( coreAnalytics ) { - val apiHostUrl get() = config.getApiHostURL() - val isCourseNestedListEnabled get() = config.isCourseNestedListEnabled() - val isCourseBannerEnabled get() = config.isCourseBannerEnabled() - - private val _uiState = MutableLiveData() - val uiState: LiveData - get() = _uiState - - var courseTitle = "" - - private val _isUpdating = MutableLiveData() - val isUpdating: LiveData - get() = _isUpdating + private val _uiState = MutableStateFlow(CourseVideosUIState.Loading) + val uiState: StateFlow + get() = _uiState.asStateFlow() - private val _uiMessage = SingleEventLiveData() - val uiMessage: LiveData - get() = _uiMessage + private val _uiMessage = MutableSharedFlow() + val uiMessage: SharedFlow + get() = _uiMessage.asSharedFlow() private val _videoSettings = MutableStateFlow(VideoSettings.default) val videoSettings = _videoSettings.asStateFlow() - val hasInternetConnection: Boolean - get() = networkConnection.isOnline() - private val courseSubSections = mutableMapOf>() private val subSectionsDownloadsCount = mutableMapOf() val courseSubSectionUnit = mutableMapOf() @@ -81,9 +72,15 @@ class CourseVideoViewModel( init { viewModelScope.launch { courseNotifier.notifier.collect { event -> - if (event is CourseStructureUpdated) { - if (event.courseId == courseId) { - updateVideos() + when (event) { + is CourseStructureUpdated -> { + if (event.courseId == courseId) { + updateVideos() + } + } + + is CourseDataReady -> { + getVideos() } } } @@ -116,8 +113,6 @@ class CourseVideoViewModel( } } - getVideos() - _videoSettings.value = preferencesManager.videoSettings } @@ -126,8 +121,9 @@ class CourseVideoViewModel( if (networkConnection.isWifiConnected()) { super.saveDownloadModels(folder, id) } else { - _uiMessage.value = - UIMessage.ToastMessage(resourceManager.getString(R.string.course_can_download_only_with_wifi)) + viewModelScope.launch { + _uiMessage.emit(UIMessage.ToastMessage(resourceManager.getString(R.string.course_can_download_only_with_wifi))) + } } } else { super.saveDownloadModels(folder, id) @@ -136,21 +132,17 @@ class CourseVideoViewModel( override fun saveAllDownloadModels(folder: String) { if (preferencesManager.videoSettings.wifiDownloadOnly && !networkConnection.isWifiConnected()) { - _uiMessage.value = - UIMessage.ToastMessage(resourceManager.getString(R.string.course_can_download_only_with_wifi)) + viewModelScope.launch { + _uiMessage.emit(UIMessage.ToastMessage(resourceManager.getString(R.string.course_can_download_only_with_wifi))) + } return } super.saveAllDownloadModels(folder) } - fun setIsUpdating() { - _isUpdating.value = true - } - private fun updateVideos() { getVideos() - _isUpdating.value = false } fun getVideos() { @@ -177,6 +169,7 @@ class CourseVideoViewModel( courseSectionsState, subSectionsDownloadsCount, getDownloadModelsSize() ) } + courseNotifier.send(CourseLoading(false)) } } @@ -198,9 +191,9 @@ class CourseVideoViewModel( } fun onChangingVideoQualityWhileDownloading() { - _uiMessage.value = UIMessage.SnackBarMessage( - resourceManager.getString(R.string.course_change_quality_when_downloading) - ) + viewModelScope.launch { + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.course_change_quality_when_downloading))) + } } private fun sortBlocks(blocks: List): List { diff --git a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideosFragment.kt b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideosFragment.kt deleted file mode 100644 index aa17ad783..000000000 --- a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideosFragment.kt +++ /dev/null @@ -1,163 +0,0 @@ -package org.openedx.course.presentation.videos - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy -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.presentation.course.CourseViewMode -import org.openedx.core.presentation.settings.VideoQualityType -import org.openedx.core.ui.rememberWindowSize -import org.openedx.core.ui.theme.OpenEdXTheme -import org.openedx.course.presentation.CourseRouter -import org.openedx.course.presentation.container.CourseContainerFragment -import org.openedx.course.presentation.ui.CourseVideosScreen -import java.io.File - -class CourseVideosFragment : Fragment() { - - private val viewModel by viewModel { - parametersOf(requireArguments().getString(ARG_COURSE_ID, "")) - } - private val router by inject() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - lifecycle.addObserver(viewModel) - with(requireArguments()) { - viewModel.courseTitle = getString(ARG_TITLE, "") - } - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ) = ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - OpenEdXTheme { - val windowSize = rememberWindowSize() - - val uiState by viewModel.uiState.observeAsState(CourseVideosUIState.Loading) - val uiMessage by viewModel.uiMessage.observeAsState() - val isUpdating by viewModel.isUpdating.observeAsState(false) - val videoSettings by viewModel.videoSettings.collectAsState() - - CourseVideosScreen( - windowSize = windowSize, - uiState = uiState, - uiMessage = uiMessage, - courseTitle = viewModel.courseTitle, - apiHostUrl = viewModel.apiHostUrl, - isCourseNestedListEnabled = viewModel.isCourseNestedListEnabled, - isCourseBannerEnabled = viewModel.isCourseBannerEnabled, - hasInternetConnection = viewModel.hasInternetConnection, - isUpdating = isUpdating, - videoSettings = videoSettings, - onSwipeRefresh = { - viewModel.setIsUpdating() - (parentFragment as CourseContainerFragment).updateCourseStructure(true) - }, - onReloadClick = { - (parentFragment as CourseContainerFragment).updateCourseStructure(false) - }, - onItemClick = { block -> - router.navigateToCourseSubsections( - fm = requireActivity().supportFragmentManager, - courseId = viewModel.courseId, - subSectionId = block.id, - mode = CourseViewMode.VIDEOS - ) - }, - onExpandClick = { block -> - viewModel.switchCourseSections(block.id) - }, - onSubSectionClick = { subSectionBlock -> - viewModel.courseSubSectionUnit[subSectionBlock.id]?.let { unit -> - viewModel.sequentialClickedEvent(unit.blockId, unit.displayName) - router.navigateToCourseContainer( - fm = requireActivity().supportFragmentManager, - courseId = viewModel.courseId, - unitId = unit.id, - mode = CourseViewMode.VIDEOS - ) - } - }, - onDownloadClick = { - if (viewModel.isBlockDownloading(it.id)) { - router.navigateToDownloadQueue( - fm = requireActivity().supportFragmentManager, - viewModel.getDownloadableChildren(it.id) ?: arrayListOf() - ) - } else if (viewModel.isBlockDownloaded(it.id)) { - viewModel.removeDownloadModels(it.id) - } else { - viewModel.saveDownloadModels( - requireContext().externalCacheDir.toString() + - File.separator + - requireContext() - .getString(R.string.app_name) - .replace(Regex("\\s"), "_"), it.id - ) - } - }, - onDownloadAllClick = { isAllBlocksDownloadedOrDownloading -> - viewModel.logBulkDownloadToggleEvent(!isAllBlocksDownloadedOrDownloading) - if (isAllBlocksDownloadedOrDownloading) { - viewModel.removeAllDownloadModels() - } else { - viewModel.saveAllDownloadModels( - requireContext().externalCacheDir.toString() + - File.separator + - requireContext() - .getString(R.string.app_name) - .replace(Regex("\\s"), "_") - ) - } - }, - onDownloadQueueClick = { - if (viewModel.hasDownloadModelsInQueue()) { - router.navigateToDownloadQueue(fm = requireActivity().supportFragmentManager) - } - }, - onVideoDownloadQualityClick = { - if (viewModel.hasDownloadModelsInQueue()) { - viewModel.onChangingVideoQualityWhileDownloading() - } else { - router.navigateToVideoQuality( - requireActivity().supportFragmentManager, VideoQualityType.Download - ) - } - } - ) - } - } - } - - companion object { - private const val ARG_COURSE_ID = "courseId" - private const val ARG_TITLE = "title" - fun newInstance( - courseId: String, - title: String, - ): CourseVideosFragment { - val fragment = CourseVideosFragment() - fragment.arguments = bundleOf( - ARG_COURSE_ID to courseId, - ARG_TITLE to title - ) - return fragment - } - } -} - diff --git a/course/src/main/res/layout/fragment_course_container.xml b/course/src/main/res/layout/fragment_course_container.xml index 9990fd80d..3eb159dc3 100644 --- a/course/src/main/res/layout/fragment_course_container.xml +++ b/course/src/main/res/layout/fragment_course_container.xml @@ -6,45 +6,12 @@ android:background="@color/background"> - - - - - - - - - - - - - - - - - - - diff --git a/course/src/main/res/values-uk/strings.xml b/course/src/main/res/values-uk/strings.xml index e7799bdbf..ffbf7c459 100644 --- a/course/src/main/res/values-uk/strings.xml +++ b/course/src/main/res/values-uk/strings.xml @@ -30,10 +30,10 @@ Повернутись до модуля Цей курс ще не розпочався. Ви не підключені до Інтернету. Будь ласка, перевірте ваше підключення до Інтернету. - Курс - Відео - Обговорення - Матеріали + Курс + Відео + Обговорення + Матеріали Ви можете завантажувати контент тільки через Wi-Fi Ця інтерактивна компонента ще не доступна Досліджуйте інші частини цього курсу або перегляньте це на веб-сайті. diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index 002fc06a1..c6b370267 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -30,10 +30,6 @@ Next section This course hasn’t started yet. You are not connected to the Internet. Please check your Internet connection. - Course - Videos - Discussions - More 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. @@ -43,7 +39,6 @@ Resume To proceed with \"%s\" press \"Next section\". Some content in this part of the course is locked for upgraded users only. - Dates You cannot change the download video quality when all videos are downloading Dates Shifted 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 0dc214f81..63dce6272 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 @@ -1,5 +1,6 @@ package org.openedx.course.presentation.container +import android.graphics.Bitmap import androidx.arch.core.executor.testing.InstantTaskExecutorRule import io.mockk.coEvery import io.mockk.coVerify @@ -21,6 +22,7 @@ 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.model.User @@ -37,6 +39,7 @@ import org.openedx.course.data.storage.CoursePreferences 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 @@ -58,6 +61,9 @@ class CourseContainerViewModelTest { 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 openEdx = "OpenEdx" private val calendarTitle = "OpenEdx - Abc" @@ -112,6 +118,9 @@ class CourseContainerViewModelTest { 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 } @After @@ -134,6 +143,8 @@ class CourseContainerViewModelTest { corePreferences, coursePreferences, analytics, + imageProcessor, + courseRouter ) every { networkConnection.isOnline() } returns true coEvery { interactor.preloadCourseStructure(any()) } throws UnknownHostException() @@ -146,7 +157,7 @@ class CourseContainerViewModelTest { val message = viewModel.errorMessage.value assertEquals(noInternet, message) - assert(viewModel.showProgress.value == false) + assert(!viewModel.refreshing.value) assert(viewModel.dataReady.value == null) } @@ -165,6 +176,8 @@ class CourseContainerViewModelTest { corePreferences, coursePreferences, analytics, + imageProcessor, + courseRouter ) every { networkConnection.isOnline() } returns true coEvery { interactor.preloadCourseStructure(any()) } throws Exception() @@ -177,7 +190,7 @@ class CourseContainerViewModelTest { val message = viewModel.errorMessage.value assertEquals(somethingWrong, message) - assert(viewModel.showProgress.value == false) + assert(!viewModel.refreshing.value) assert(viewModel.dataReady.value == null) } @@ -196,6 +209,8 @@ class CourseContainerViewModelTest { corePreferences, coursePreferences, analytics, + imageProcessor, + courseRouter ) every { networkConnection.isOnline() } returns true coEvery { interactor.preloadCourseStructure(any()) } returns Unit @@ -208,7 +223,7 @@ class CourseContainerViewModelTest { verify(exactly = 1) { analytics.logEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } assert(viewModel.errorMessage.value == null) - assert(viewModel.showProgress.value == false) + assert(!viewModel.refreshing.value) assert(viewModel.dataReady.value != null) } @@ -227,6 +242,8 @@ class CourseContainerViewModelTest { corePreferences, coursePreferences, analytics, + imageProcessor, + courseRouter ) every { networkConnection.isOnline() } returns false coEvery { interactor.preloadCourseStructureFromCache(any()) } returns Unit @@ -240,7 +257,7 @@ class CourseContainerViewModelTest { verify(exactly = 1) { analytics.logEvent(any(), any()) } assert(viewModel.errorMessage.value == null) - assert(viewModel.showProgress.value == false) + assert(!viewModel.refreshing.value) assert(viewModel.dataReady.value != null) } @@ -259,17 +276,19 @@ class CourseContainerViewModelTest { corePreferences, coursePreferences, analytics, + imageProcessor, + courseRouter ) coEvery { interactor.preloadCourseStructure(any()) } throws UnknownHostException() - coEvery { notifier.send(CourseStructureUpdated("", false)) } returns Unit - viewModel.updateData(false) + coEvery { notifier.send(CourseStructureUpdated("")) } returns Unit + viewModel.updateData() advanceUntilIdle() coVerify(exactly = 1) { interactor.preloadCourseStructure(any()) } val message = viewModel.errorMessage.value assertEquals(noInternet, message) - assert(viewModel.showProgress.value == false) + assert(!viewModel.refreshing.value) } @Test @@ -287,17 +306,19 @@ class CourseContainerViewModelTest { corePreferences, coursePreferences, analytics, + imageProcessor, + courseRouter ) coEvery { interactor.preloadCourseStructure(any()) } throws Exception() - coEvery { notifier.send(CourseStructureUpdated("", false)) } returns Unit - viewModel.updateData(false) + coEvery { notifier.send(CourseStructureUpdated("")) } returns Unit + viewModel.updateData() advanceUntilIdle() coVerify(exactly = 1) { interactor.preloadCourseStructure(any()) } val message = viewModel.errorMessage.value assertEquals(somethingWrong, message) - assert(viewModel.showProgress.value == false) + assert(!viewModel.refreshing.value) } @Test @@ -315,15 +336,17 @@ class CourseContainerViewModelTest { corePreferences, coursePreferences, analytics, + imageProcessor, + courseRouter ) coEvery { interactor.preloadCourseStructure(any()) } returns Unit - coEvery { notifier.send(CourseStructureUpdated("", false)) } returns Unit - viewModel.updateData(false) + coEvery { notifier.send(CourseStructureUpdated("")) } returns Unit + viewModel.updateData() advanceUntilIdle() coVerify(exactly = 1) { interactor.preloadCourseStructure(any()) } assert(viewModel.errorMessage.value == null) - assert(viewModel.showProgress.value == false) + 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 82c3728e4..40a2d41c0 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 @@ -7,12 +7,16 @@ import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeoutOrNull import org.junit.After import org.junit.Assert import org.junit.Before @@ -34,8 +38,9 @@ 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.connection.NetworkConnection 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 import org.openedx.course.presentation.CourseAnalytics @@ -54,7 +59,6 @@ class CourseDatesViewModelTest { private val notifier = mockk() private val interactor = mockk() private val calendarManager = mockk() - private val networkConnection = mockk() private val corePreferences = mockk() private val analytics = mockk() private val config = mockk() @@ -142,9 +146,12 @@ class CourseDatesViewModelTest { every { interactor.getCourseStructureFromCache() } returns courseStructure every { corePreferences.user } returns user every { corePreferences.appConfig } returns appConfig - every { notifier.notifier } returns emptyFlow() + every { notifier.notifier } returns flowOf(CourseDataReady(courseStructure)) 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 } @After @@ -153,119 +160,109 @@ class CourseDatesViewModelTest { } @Test - fun `getCourseDates no internet connection exception`() = runTest { + fun `getCourseDates no internet connection exception`() = runTest(UnconfinedTestDispatcher()) { val viewModel = CourseDatesViewModel( - "", - "", - true, "", notifier, interactor, calendarManager, - networkConnection, resourceManager, corePreferences, analytics, config ) - every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseDates(any()) } throws UnknownHostException() + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + } advanceUntilIdle() - coVerify(exactly = 1) { interactor.getCourseDates(any()) } - - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage + coVerify(exactly = 1) { interactor.getCourseDates(any()) } - Assert.assertEquals(noInternet, message?.message) - assert(viewModel.updating.value == false) + Assert.assertEquals(noInternet, message.await()?.message) assert(viewModel.uiState.value is DatesUIState.Loading) } @Test - fun `getCourseDates unknown exception`() = runTest { + fun `getCourseDates unknown exception`() = runTest(UnconfinedTestDispatcher()) { val viewModel = CourseDatesViewModel( - "", - "", - true, "", notifier, interactor, calendarManager, - networkConnection, resourceManager, corePreferences, analytics, config ) - every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseDates(any()) } throws Exception() + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + } advanceUntilIdle() coVerify(exactly = 1) { interactor.getCourseDates(any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - - Assert.assertEquals(somethingWrong, message?.message) - assert(viewModel.updating.value == false) + Assert.assertEquals(somethingWrong, message.await()?.message) assert(viewModel.uiState.value is DatesUIState.Loading) } @Test - fun `getCourseDates success with internet`() = runTest { + fun `getCourseDates success with internet`() = runTest(UnconfinedTestDispatcher()) { val viewModel = CourseDatesViewModel( - "", - "", - true, "", notifier, interactor, calendarManager, - networkConnection, resourceManager, corePreferences, analytics, config ) - every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseDates(any()) } returns mockedCourseDatesResult - + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + } advanceUntilIdle() coVerify(exactly = 1) { interactor.getCourseDates(any()) } - assert(viewModel.uiMessage.value == null) - assert(viewModel.updating.value == false) + assert(message.await()?.message.isNullOrEmpty()) assert(viewModel.uiState.value is DatesUIState.Dates) } @Test - fun `getCourseDates success with EmptyList`() = runTest { + fun `getCourseDates success with EmptyList`() = runTest(UnconfinedTestDispatcher()) { val viewModel = CourseDatesViewModel( - "", - "", - true, "", notifier, interactor, calendarManager, - networkConnection, resourceManager, corePreferences, analytics, config ) - every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseDates(any()) } returns CourseDatesResult( datesSection = linkedMapOf(), courseBanner = mockCourseDatesBannerInfo, ) - + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + } advanceUntilIdle() coVerify(exactly = 1) { interactor.getCourseDates(any()) } - assert(viewModel.uiMessage.value == null) - assert(viewModel.updating.value == false) + assert(message.await()?.message.isNullOrEmpty()) assert(viewModel.uiState.value is DatesUIState.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 174e8ea4f..098960a2a 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 @@ -12,13 +12,15 @@ import io.mockk.spyk import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.delay +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first 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 kotlinx.coroutines.withTimeoutOrNull import org.junit.After import org.junit.Assert.assertEquals import org.junit.Before @@ -215,12 +217,14 @@ class CourseOutlineViewModelTest { } @Test - fun `getCourseDataInternal no internet connection exception`() = runTest { + fun `getCourseDataInternal no internet connection exception`() = runTest(UnconfinedTestDispatcher()) { every { interactor.getCourseStructureFromCache() } returns courseStructure every { networkConnection.isOnline() } returns true + every { downloadDao.readAllData() } returns flow { emit(emptyList()) } coEvery { interactor.getCourseStatus(any()) } throws UnknownHostException() val viewModel = CourseOutlineViewModel( + "", "", config, interactor, @@ -234,23 +238,27 @@ class CourseOutlineViewModelTest { workerController, ) + val message = async { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + viewModel.getCourseData() advanceUntilIdle() verify(exactly = 1) { interactor.getCourseStructureFromCache() } coVerify(exactly = 1) { interactor.getCourseStatus(any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - assertEquals(noInternet, message?.message) - assert(viewModel.isUpdating.value == false) + assertEquals(noInternet, message.await()?.message) assert(viewModel.uiState.value is CourseOutlineUIState.Loading) } @Test - fun `getCourseDataInternal unknown exception`() = runTest { + fun `getCourseDataInternal unknown exception`() = runTest(UnconfinedTestDispatcher()) { every { interactor.getCourseStructureFromCache() } returns courseStructure every { networkConnection.isOnline() } returns true + every { downloadDao.readAllData() } returns flow { emit(emptyList()) } coEvery { interactor.getCourseStatus(any()) } throws Exception() val viewModel = CourseOutlineViewModel( + "", "", config, interactor, @@ -264,19 +272,21 @@ class CourseOutlineViewModelTest { workerController ) + val message = async { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + viewModel.getCourseData() advanceUntilIdle() verify(exactly = 1) { interactor.getCourseStructureFromCache() } coVerify(exactly = 1) { interactor.getCourseStatus(any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - assertEquals(somethingWrong, message?.message) - assert(viewModel.isUpdating.value == false) + assertEquals(somethingWrong, message.await()?.message) assert(viewModel.uiState.value is CourseOutlineUIState.Loading) } @Test - fun `getCourseDataInternal success with internet connection`() = runTest { + fun `getCourseDataInternal success with internet connection`() = runTest(UnconfinedTestDispatcher()) { every { interactor.getCourseStructureFromCache() } returns courseStructure every { networkConnection.isOnline() } returns true coEvery { downloadDao.readAllData() } returns flow { @@ -292,6 +302,7 @@ class CourseOutlineViewModelTest { every { config.isCourseNestedListEnabled() } returns false val viewModel = CourseOutlineViewModel( + "", "", config, interactor, @@ -305,18 +316,23 @@ class CourseOutlineViewModelTest { workerController ) + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + } + viewModel.getCourseData() advanceUntilIdle() verify(exactly = 1) { interactor.getCourseStructureFromCache() } coVerify(exactly = 1) { interactor.getCourseStatus(any()) } - assert(viewModel.uiMessage.value == null) - assert(viewModel.isUpdating.value == false) + assert(message.await() == null) assert(viewModel.uiState.value is CourseOutlineUIState.CourseData) } @Test - fun `getCourseDataInternal success without internet connection`() = runTest { + fun `getCourseDataInternal success without internet connection`() = runTest(UnconfinedTestDispatcher()) { every { interactor.getCourseStructureFromCache() } returns courseStructure every { networkConnection.isOnline() } returns false coEvery { downloadDao.readAllData() } returns flow { @@ -332,6 +348,7 @@ class CourseOutlineViewModelTest { every { config.isCourseNestedListEnabled() } returns false val viewModel = CourseOutlineViewModel( + "", "", config, interactor, @@ -345,18 +362,23 @@ class CourseOutlineViewModelTest { workerController ) + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + } + viewModel.getCourseData() advanceUntilIdle() verify(exactly = 1) { interactor.getCourseStructureFromCache() } coVerify(exactly = 0) { interactor.getCourseStatus(any()) } - assert(viewModel.uiMessage.value == null) - assert(viewModel.isUpdating.value == false) + assert(message.await() == null) assert(viewModel.uiState.value is CourseOutlineUIState.CourseData) } @Test - fun `updateCourseData success with internet connection`() = runTest { + fun `updateCourseData success with internet connection`() = runTest(UnconfinedTestDispatcher()) { every { interactor.getCourseStructureFromCache() } returns courseStructure every { networkConnection.isOnline() } returns true coEvery { downloadDao.readAllData() } returns flow { @@ -372,6 +394,7 @@ class CourseOutlineViewModelTest { every { config.isCourseNestedListEnabled() } returns false val viewModel = CourseOutlineViewModel( + "", "", config, interactor, @@ -385,20 +408,27 @@ class CourseOutlineViewModelTest { workerController ) - viewModel.updateCourseData(false) + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + } + viewModel.getCourseData() + viewModel.updateCourseData() advanceUntilIdle() coVerify(exactly = 2) { interactor.getCourseStructureFromCache() } coVerify(exactly = 2) { interactor.getCourseStatus(any()) } - assert(viewModel.uiMessage.value == null) - assert(viewModel.isUpdating.value == false) + assert(message.await() == null) assert(viewModel.uiState.value is CourseOutlineUIState.CourseData) } @Test - fun `CourseStructureUpdated notifier test`() = runTest { + fun `CourseStructureUpdated notifier test`() = runTest(UnconfinedTestDispatcher()) { + coEvery { downloadDao.readAllData() } returns flow { emit(emptyList()) } val viewModel = CourseOutlineViewModel( + "", "", config, interactor, @@ -411,14 +441,8 @@ class CourseOutlineViewModelTest { downloadDao, workerController ) - coEvery { notifier.notifier } returns flow { emit(CourseStructureUpdated("", false)) } + coEvery { notifier.notifier } returns flow { emit(CourseStructureUpdated("")) } every { interactor.getCourseStructureFromCache() } returns courseStructure - every { downloadDao.readAllData() } returns flow { - repeat(5) { - delay(10000) - emit(emptyList()) - } - } every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") @@ -427,15 +451,15 @@ class CourseOutlineViewModelTest { lifecycleRegistry.addObserver(viewModel) lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START) - viewModel.setIsUpdating() + viewModel.getCourseData() advanceUntilIdle() - coVerify(exactly = 2) { interactor.getCourseStructureFromCache() } + coVerify(exactly = 1) { interactor.getCourseStructureFromCache() } coVerify(exactly = 1) { interactor.getCourseStatus(any()) } } @Test - fun `saveDownloadModels test`() = runTest { + fun `saveDownloadModels test`() = runTest(UnconfinedTestDispatcher()) { every { preferencesManager.videoSettings.wifiDownloadOnly } returns false every { interactor.getCourseStructureFromCache() } returns courseStructure every { networkConnection.isWifiConnected() } returns true @@ -452,6 +476,7 @@ class CourseOutlineViewModelTest { every { config.isCourseNestedListEnabled() } returns false val viewModel = CourseOutlineViewModel( + "", "", config, interactor, @@ -464,7 +489,11 @@ class CourseOutlineViewModelTest { downloadDao, workerController ) - + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + } viewModel.saveDownloadModels("", "") advanceUntilIdle() verify(exactly = 1) { @@ -474,23 +503,23 @@ class CourseOutlineViewModelTest { ) } - assert(viewModel.uiMessage.value == null) + assert(message.await()?.message.isNullOrEmpty()) } @Test - fun `saveDownloadModels only wifi download, with connection`() = runTest { + fun `saveDownloadModels only wifi download, with connection`() = runTest(UnconfinedTestDispatcher()) { every { interactor.getCourseStructureFromCache() } returns courseStructure coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") every { preferencesManager.videoSettings.wifiDownloadOnly } returns true every { networkConnection.isWifiConnected() } returns true every { networkConnection.isOnline() } returns true - coEvery { downloadDao.readAllData() } returns mockk() coEvery { workerController.saveModels(any()) } returns Unit coEvery { downloadDao.readAllData() } returns flow { emit(emptyList()) } every { config.isCourseNestedListEnabled() } returns false every { coreAnalytics.logEvent(any(), any()) } returns Unit val viewModel = CourseOutlineViewModel( + "", "", config, interactor, @@ -503,15 +532,19 @@ class CourseOutlineViewModelTest { downloadDao, workerController ) - + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + } viewModel.saveDownloadModels("", "") advanceUntilIdle() - assert(viewModel.uiMessage.value == null) + assert(message.await()?.message.isNullOrEmpty()) } @Test - fun `saveDownloadModels only wifi download, without connection`() = runTest { + fun `saveDownloadModels only wifi download, without connection`() = runTest(UnconfinedTestDispatcher()) { every { interactor.getCourseStructureFromCache() } returns courseStructure every { preferencesManager.videoSettings.wifiDownloadOnly } returns true every { networkConnection.isWifiConnected() } returns false @@ -521,6 +554,7 @@ class CourseOutlineViewModelTest { every { config.isCourseNestedListEnabled() } returns false val viewModel = CourseOutlineViewModel( + "", "", config, interactor, @@ -533,12 +567,15 @@ class CourseOutlineViewModelTest { downloadDao, workerController ) - + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + } viewModel.saveDownloadModels("", "") advanceUntilIdle() - assert(viewModel.uiMessage.value != null) - assert(!viewModel.hasInternetConnection) + 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 6106792f5..ba6aa779c 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 @@ -171,6 +171,7 @@ class CourseSectionViewModelTest { @Test fun `getBlocks no internet connection exception`() = runTest { + every { downloadDao.readAllData() } returns flow { emit(emptyList()) } val viewModel = CourseSectionViewModel( "", interactor, @@ -200,6 +201,7 @@ class CourseSectionViewModelTest { @Test fun `getBlocks unknown exception`() = runTest { + every { downloadDao.readAllData() } returns flow { emit(emptyList()) } val viewModel = CourseSectionViewModel( "", interactor, @@ -229,6 +231,9 @@ class CourseSectionViewModelTest { @Test fun `getBlocks success`() = runTest { + coEvery { downloadDao.readAllData() } returns flow { + emit(listOf(DownloadModelEntity.createFrom(downloadModel))) + } val viewModel = CourseSectionViewModel( "", interactor, @@ -242,9 +247,6 @@ class CourseSectionViewModelTest { downloadDao, ) - coEvery { downloadDao.readAllData() } returns flow { - emit(listOf(DownloadModelEntity.createFrom(downloadModel))) - } coEvery { interactor.getCourseStructureFromCache() } returns courseStructure coEvery { interactor.getCourseStructureForVideos() } returns courseStructure @@ -260,6 +262,9 @@ class CourseSectionViewModelTest { @Test fun `saveDownloadModels test`() = runTest { + coEvery { downloadDao.readAllData() } returns flow { + emit(listOf(DownloadModelEntity.createFrom(downloadModel))) + } val viewModel = CourseSectionViewModel( "", interactor, @@ -275,9 +280,6 @@ class CourseSectionViewModelTest { every { preferencesManager.videoSettings.wifiDownloadOnly } returns false every { networkConnection.isWifiConnected() } returns true coEvery { workerController.saveModels(any()) } returns Unit - coEvery { downloadDao.readAllData() } returns flow { - emit(listOf(DownloadModelEntity.createFrom(downloadModel))) - } every { coreAnalytics.logEvent(any(), any()) } returns Unit viewModel.saveDownloadModels("", "") @@ -288,6 +290,9 @@ class CourseSectionViewModelTest { @Test fun `saveDownloadModels only wifi download, with connection`() = runTest { + coEvery { downloadDao.readAllData() } returns flow { + emit(listOf(DownloadModelEntity.createFrom(downloadModel))) + } val viewModel = CourseSectionViewModel( "", interactor, @@ -303,9 +308,6 @@ class CourseSectionViewModelTest { every { preferencesManager.videoSettings.wifiDownloadOnly } returns true every { networkConnection.isWifiConnected() } returns true coEvery { workerController.saveModels(any()) } returns Unit - coEvery { downloadDao.readAllData() } returns flow { - emit(listOf(DownloadModelEntity.createFrom(downloadModel))) - } every { coreAnalytics.logEvent(any(), any()) } returns Unit viewModel.saveDownloadModels("", "") @@ -316,6 +318,7 @@ class CourseSectionViewModelTest { @Test fun `saveDownloadModels only wifi download, without connection`() = runTest { + every { downloadDao.readAllData() } returns flow { emit(emptyList()) } val viewModel = CourseSectionViewModel( "", interactor, @@ -343,6 +346,12 @@ class CourseSectionViewModelTest { @Test fun `updateVideos success`() = runTest { + every { downloadDao.readAllData() } returns flow { + repeat(5) { + delay(10000) + emit(emptyList()) + } + } val viewModel = CourseSectionViewModel( "", interactor, @@ -356,12 +365,6 @@ class CourseSectionViewModelTest { downloadDao, ) - every { downloadDao.readAllData() } returns flow { - repeat(5) { - delay(10000) - emit(emptyList()) - } - } coEvery { notifier.notifier } returns flow { } coEvery { interactor.getCourseStructureFromCache() } returns courseStructure coEvery { interactor.getCourseStructureForVideos() } 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 a825345cf..43d057a6c 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 @@ -11,19 +11,25 @@ import io.mockk.mockk import io.mockk.spyk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeoutOrNull import org.junit.After import org.junit.Before 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.Block @@ -40,6 +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.CourseNotifier import org.openedx.core.system.notifier.CourseStructureUpdated import org.openedx.core.system.notifier.VideoNotifier @@ -161,9 +168,10 @@ class CourseVideoViewModelTest { @Before fun setUp() { every { resourceManager.getString(R.string.course_does_not_include_videos) } returns "" - every { resourceManager.getString(org.openedx.course.R.string.course_can_download_only_with_wifi) } returns cantDownload + 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)) } @After @@ -178,6 +186,7 @@ class CourseVideoViewModelTest { every { downloadDao.readAllData() } returns flow { emit(emptyList()) } every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( + "", "", config, interactor, @@ -208,6 +217,7 @@ class CourseVideoViewModelTest { every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( + "", "", config, interactor, @@ -235,7 +245,10 @@ class CourseVideoViewModelTest { fun `updateVideos success`() = runTest { every { config.isCourseNestedListEnabled() } returns false every { interactor.getCourseStructureForVideos() } returns courseStructure - coEvery { courseNotifier.notifier } returns flow { emit(CourseStructureUpdated("", false)) } + coEvery { courseNotifier.notifier } returns flow { + emit(CourseStructureUpdated("")) + emit(CourseDataReady(courseStructure)) + } every { downloadDao.readAllData() } returns flow { repeat(5) { delay(10000) @@ -244,6 +257,7 @@ class CourseVideoViewModelTest { } every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( + "", "", config, interactor, @@ -268,40 +282,23 @@ class CourseVideoViewModelTest { coVerify(exactly = 2) { interactor.getCourseStructureForVideos() } assert(viewModel.uiState.value is CourseVideosUIState.CourseData) - assert(viewModel.isUpdating.value == false) } @Test fun `setIsUpdating success`() = runTest { every { config.isCourseNestedListEnabled() } returns false every { preferencesManager.videoSettings } returns VideoSettings.default - val viewModel = CourseVideoViewModel( - "", - config, - interactor, - resourceManager, - networkConnection, - preferencesManager, - courseNotifier, - videoNotifier, - analytics, - coreAnalytics, - downloadDao, - workerController - ) coEvery { interactor.getCourseStructureForVideos() } returns courseStructure coEvery { downloadDao.readAllData() } returns flow { emit(listOf(downloadModelEntity)) } - viewModel.setIsUpdating() advanceUntilIdle() - - assert(viewModel.isUpdating.value == true) } @Test - fun `saveDownloadModels test`() = runTest { + fun `saveDownloadModels test`() = runTest(UnconfinedTestDispatcher()) { every { config.isCourseNestedListEnabled() } returns false every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( + "", "", config, interactor, @@ -321,18 +318,23 @@ class CourseVideoViewModelTest { every { networkConnection.isWifiConnected() } returns true coEvery { workerController.saveModels(any()) } returns Unit every { coreAnalytics.logEvent(any(), any()) } returns Unit - + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + } viewModel.saveDownloadModels("", "") advanceUntilIdle() - assert(viewModel.uiMessage.value == null) + assert(message.await()?.message.isNullOrEmpty()) } @Test - fun `saveDownloadModels only wifi download, with connection`() = runTest { + fun `saveDownloadModels only wifi download, with connection`() = runTest(UnconfinedTestDispatcher()) { every { config.isCourseNestedListEnabled() } returns false every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( + "", "", config, interactor, @@ -355,18 +357,24 @@ class CourseVideoViewModelTest { emit(listOf(DownloadModelEntity.createFrom(downloadModel))) } every { coreAnalytics.logEvent(any(), any()) } returns Unit + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + } viewModel.saveDownloadModels("", "") advanceUntilIdle() - assert(viewModel.uiMessage.value == null) + assert(message.await()?.message.isNullOrEmpty()) } @Test - fun `saveDownloadModels only wifi download, without conection`() = runTest { + fun `saveDownloadModels only wifi download, without connection`() = runTest(UnconfinedTestDispatcher()) { every { config.isCourseNestedListEnabled() } returns false every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( + "", "", config, interactor, @@ -386,13 +394,17 @@ class CourseVideoViewModelTest { coEvery { interactor.getCourseStructureForVideos() } returns courseStructure coEvery { downloadDao.readAllData() } returns flow { emit(listOf(downloadModelEntity)) } coEvery { workerController.saveModels(any()) } returns Unit + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + } viewModel.saveDownloadModels("", "") advanceUntilIdle() - assert(viewModel.uiMessage.value != null) - assert(!viewModel.hasInternetConnection) + assert(message.await()?.message.isNullOrEmpty()) } 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 646f606a1..dbf15acd4 100644 --- a/dashboard/src/androidTest/java/org/openedx/dashboard/presentation/MyCoursesScreenTest.kt +++ b/dashboard/src/androidTest/java/org/openedx/dashboard/presentation/MyCoursesScreenTest.kt @@ -81,6 +81,7 @@ class MyCoursesScreenTest { paginationCallback = {}, onItemClick = {}, appUpgradeParameters = AppUpdateState.AppUpgradeParameters(), + onSettingsClick = {} ) } @@ -113,6 +114,7 @@ class MyCoursesScreenTest { paginationCallback = {}, onItemClick = {}, appUpgradeParameters = AppUpdateState.AppUpgradeParameters(), + onSettingsClick = {} ) } @@ -138,6 +140,7 @@ class MyCoursesScreenTest { paginationCallback = {}, onItemClick = {}, appUpgradeParameters = AppUpdateState.AppUpgradeParameters(), + onSettingsClick = {} ) } diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardFragment.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardFragment.kt index 1c314c445..f6bc5c56a 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardFragment.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardFragment.kt @@ -96,6 +96,7 @@ import org.openedx.core.ui.windowSizeValue import org.openedx.core.utils.TimeUtils import org.openedx.dashboard.R import java.util.Date +import org.openedx.core.R as CoreR class DashboardFragment : Fragment() { @@ -154,6 +155,9 @@ class DashboardFragment : Fragment() { AppUpdateState.openPlayMarket(requireContext()) }, ), + onSettingsClick = { + router.navigateToSettings(requireActivity().supportFragmentManager) + } ) } } @@ -173,6 +177,7 @@ internal fun MyCoursesScreen( onReloadClick: () -> Unit, onSwipeRefresh: () -> Unit, paginationCallback: () -> Unit, + onSettingsClick: () -> Unit, onItemClick: (EnrolledCourse) -> Unit, appUpgradeParameters: AppUpdateState.AppUpgradeParameters, ) { @@ -240,7 +245,11 @@ internal fun MyCoursesScreen( .displayCutoutForLandscape(), horizontalAlignment = Alignment.CenterHorizontally ) { - Toolbar(label = stringResource(id = R.string.dashboard_title)) + Toolbar( + label = stringResource(id = R.string.dashboard_title), + canShowSettingsIcon = true, + onSettingsClick = onSettingsClick + ) Surface( color = MaterialTheme.appColors.background, @@ -399,8 +408,8 @@ private fun CourseItem( AsyncImage( model = ImageRequest.Builder(LocalContext.current) .data(imageUrl) - .error(org.openedx.core.R.drawable.core_no_image_course) - .placeholder(org.openedx.core.R.drawable.core_no_image_course) + .error(CoreR.drawable.core_no_image_course) + .placeholder(CoreR.drawable.core_no_image_course) .build(), contentDescription = null, contentScale = ContentScale.Crop, @@ -565,6 +574,7 @@ private fun MyCoursesScreenDay() { refreshing = false, canLoadMore = false, paginationCallback = {}, + onSettingsClick = {}, appUpgradeParameters = AppUpdateState.AppUpgradeParameters() ) } @@ -596,6 +606,7 @@ private fun MyCoursesScreenTabletPreview() { 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 6cd185fa9..b0b0740d3 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardRouter.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardRouter.kt @@ -10,4 +10,6 @@ interface DashboardRouter { courseTitle: String, enrollmentMode: String, ) + + fun navigateToSettings(fm: FragmentManager) } diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index 59372a8ef..e1582bfcf 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -13,7 +13,7 @@ AGREEMENT_URLS: DATA_SELL_CONSENT_URL: '' TOS_URL: '' EULA_URL: '' - SUPPORTED_LANGUAGES: [ ] #en is defalut language + SUPPORTED_LANGUAGES: [ ] #en is default language DISCOVERY: TYPE: 'native' @@ -77,7 +77,5 @@ WHATS_NEW_ENABLED: false SOCIAL_AUTH_ENABLED: false #Course navigation feature flags COURSE_NESTED_LIST_ENABLED: false -COURSE_BANNER_ENABLED: true -COURSE_TOP_TAB_BAR_ENABLED: false COURSE_UNIT_PROGRESS_ENABLED: false diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml index 423ca0b3c..f7afc7bed 100644 --- a/default_config/prod/config.yaml +++ b/default_config/prod/config.yaml @@ -13,7 +13,7 @@ AGREEMENT_URLS: DATA_SELL_CONSENT_URL: '' TOS_URL: '' EULA_URL: '' - SUPPORTED_LANGUAGES: [ ] #en is defalut language + SUPPORTED_LANGUAGES: [ ] #en is default language DISCOVERY: TYPE: 'native' @@ -77,6 +77,4 @@ WHATS_NEW_ENABLED: false SOCIAL_AUTH_ENABLED: false #Course navigation feature flags COURSE_NESTED_LIST_ENABLED: false -COURSE_BANNER_ENABLED: true -COURSE_TOP_TAB_BAR_ENABLED: false COURSE_UNIT_PROGRESS_ENABLED: false diff --git a/default_config/stage/config.yaml b/default_config/stage/config.yaml index 423ca0b3c..f7afc7bed 100644 --- a/default_config/stage/config.yaml +++ b/default_config/stage/config.yaml @@ -13,7 +13,7 @@ AGREEMENT_URLS: DATA_SELL_CONSENT_URL: '' TOS_URL: '' EULA_URL: '' - SUPPORTED_LANGUAGES: [ ] #en is defalut language + SUPPORTED_LANGUAGES: [ ] #en is default language DISCOVERY: TYPE: 'native' @@ -77,6 +77,4 @@ WHATS_NEW_ENABLED: false SOCIAL_AUTH_ENABLED: false #Course navigation feature flags COURSE_NESTED_LIST_ENABLED: false -COURSE_BANNER_ENABLED: true -COURSE_TOP_TAB_BAR_ENABLED: false COURSE_UNIT_PROGRESS_ENABLED: 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 c1b1c423d..e1c4baa74 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryRouter.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryRouter.kt @@ -5,7 +5,10 @@ import androidx.fragment.app.FragmentManager interface DiscoveryRouter { fun navigateToCourseOutline( - fm: FragmentManager, courseId: String, courseTitle: String, enrollmentMode: String + fm: FragmentManager, + courseId: String, + courseTitle: String, + enrollmentMode: String ) fun navigateToLogistration(fm: FragmentManager, courseId: String?) @@ -22,5 +25,7 @@ interface DiscoveryRouter { fun navigateToSignIn(fm: FragmentManager, courseId: String?, infoType: String?) + fun navigateToSettings(fm: FragmentManager) + fun navigateToEnrolledProgramInfo(fm: FragmentManager, pathId: String) } 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 9f11c27dc..ee99a5bb3 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt @@ -69,6 +69,7 @@ import org.openedx.core.ui.BackBtn 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 @@ -166,7 +167,11 @@ class NativeDiscoveryFragment : Fragment() { }, onBackClick = { requireActivity().supportFragmentManager.popBackStackImmediate() - }) + }, + onSettingsClick = { + router.navigateToSettings(requireActivity().supportFragmentManager) + } + ) LaunchedEffect(uiState) { if (querySearch.isNotEmpty()) { router.navigateToCourseSearch( @@ -213,6 +218,7 @@ internal fun DiscoveryScreen( onRegisterClick: () -> Unit, onSignInClick: () -> Unit, onBackClick: () -> Unit, + onSettingsClick: () -> Unit, ) { val scaffoldState = rememberScaffoldState() val scrollState = rememberLazyListState() @@ -310,22 +316,23 @@ internal fun DiscoveryScreen( ) { Column( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) - .padding(top = 10.dp), + .fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { - Text( - modifier = Modifier.testTag("txt_discovery_title"), - text = stringResource(id = R.string.discovery_Discovery), - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.titleMedium + Toolbar( + label = stringResource(id = R.string.discovery_Discovery), + canShowBackBtn = canShowBackButton, + canShowSettingsIcon = !canShowBackButton, + onBackClick = onBackClick, + onSettingsClick = onSettingsClick ) + Spacer(modifier = Modifier.height(16.dp)) StaticSearchBar( modifier = Modifier .height(48.dp) + .padding(horizontal = 24.dp) .then(searchTabWidth), onClick = { onSearchClick() @@ -514,6 +521,7 @@ private fun DiscoveryScreenPreview() { onSignInClick = {}, onRegisterClick = {}, onBackClick = {}, + onSettingsClick = {}, canShowBackButton = false ) } @@ -554,6 +562,7 @@ private fun DiscoveryScreenTabletPreview() { onSignInClick = {}, onRegisterClick = {}, onBackClick = {}, + onSettingsClick = {}, canShowBackButton = false ) } 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 66cd62bbe..6696e765b 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryFragment.kt @@ -142,6 +142,9 @@ class WebViewDiscoveryFragment : Fragment() { onSignInClick = { viewModel.navigateToSignIn(parentFragmentManager) }, + onSettingsClick = { + viewModel.navigateToSettings(requireActivity().supportFragmentManager) + }, onBackClick = { requireActivity().supportFragmentManager.popBackStackImmediate() } @@ -176,6 +179,7 @@ private fun WebViewDiscoveryScreen( onUriClick: (String, WebViewLink.Authority) -> Unit, onRegisterClick: () -> Unit, onSignInClick: () -> Unit, + onSettingsClick: () -> Unit, onBackClick: () -> Unit ) { val scaffoldState = rememberScaffoldState() @@ -232,7 +236,9 @@ private fun WebViewDiscoveryScreen( Toolbar( label = stringResource(id = R.string.discovery_explore_the_catalog), canShowBackBtn = isPreLogin, - onBackClick = onBackClick + canShowSettingsIcon = !isPreLogin, + onBackClick = onBackClick, + onSettingsClick = onSettingsClick ) Surface { @@ -363,6 +369,7 @@ private fun WebViewDiscoveryScreenPreview() { onUriClick = { _, _ -> }, onRegisterClick = {}, onSignInClick = {}, + onSettingsClick = {}, 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 2fbfffb07..f86eef2b8 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryViewModel.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryViewModel.kt @@ -61,6 +61,10 @@ class WebViewDiscoveryViewModel( router.navigateToSignIn(fragmentManager, null, null) } + fun navigateToSettings(fragmentManager: FragmentManager) { + router.navigateToSettings(fragmentManager) + } + fun courseInfoClickedEvent(courseId: String) { logEvent(DiscoveryAnalyticsEvent.COURSE_INFO, courseId) } 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..636cb9275 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/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramFragment.kt index 98fab2557..4e97efe18 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 @@ -179,6 +179,9 @@ class ProgramFragment(private val myPrograms: Boolean = false) : Fragment() { } } }, + onSettingsClick = { + viewModel.navigateToSettings(requireActivity().supportFragmentManager) + }, refreshSessionCookie = { viewModel.refreshCookie() }, @@ -221,6 +224,7 @@ private fun ProgramInfoScreen( hasInternetConnection: Boolean, checkInternetConnection: () -> Unit, onWebPageLoaded: () -> Unit, + onSettingsClick: () -> Unit, onBackClick: () -> Unit, onUriClick: (String, WebViewLink.Authority) -> Unit, refreshSessionCookie: () -> Unit = {}, @@ -268,7 +272,9 @@ private fun ProgramInfoScreen( Toolbar( label = stringResource(id = R.string.discovery_programs), canShowBackBtn = canShowBackBtn, - onBackClick = onBackClick + canShowSettingsIcon = !canShowBackBtn, + onBackClick = onBackClick, + onSettingsClick = onSettingsClick ) Surface { @@ -339,6 +345,7 @@ fun MyProgramsPreview() { checkInternetConnection = {}, onBackClick = {}, onWebPageLoaded = {}, + onSettingsClick = {}, onUriClick = { _, _ -> }, ) } 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 3a680da1b..68bbdc6be 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 @@ -101,6 +101,10 @@ class ProgramViewModel( viewModelScope.launch { notifier.send(NavigationToDiscovery()) } } + fun navigateToSettings(fragmentManager: FragmentManager) { + router.navigateToSettings(fragmentManager) + } + fun refreshCookie() { viewModelScope.launch { edxCookieManager.tryToRefreshSessionCookie() } } 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 6f14fa265..a0c5c5c62 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 @@ -4,6 +4,17 @@ import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.cancellable +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.debounce +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 @@ -13,11 +24,6 @@ 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 kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.launch class DiscussionSearchThreadViewModel( private val interactor: DiscussionInteractor, @@ -51,6 +57,7 @@ class DiscussionSearchThreadViewModel( private var currentQuery: String? = null private val threadsList = mutableListOf() private var isLoading = false + private var loadNextJob: Job? = null private val queryChannel = MutableSharedFlow(replay = 0, extraBufferCapacity = 0) @@ -84,26 +91,26 @@ class DiscussionSearchThreadViewModel( queryChannel .asSharedFlow() .debounce(400) - .collect { + .collect { query -> nextPage = 1 - currentQuery = it threadsList.clear() - _uiState.value = DiscussionSearchThreadUIState.Loading - loadThreadsInternal(currentQuery!!, nextPage!!) + if (query.isNotEmpty()) { + currentQuery = query + _uiState.value = DiscussionSearchThreadUIState.Loading + loadThreadsInternal(currentQuery!!, nextPage!!) + } else { + loadNextJob?.cancel() + currentQuery = null + _uiState.value = DiscussionSearchThreadUIState.Threads(emptyList(), 0) + _canLoadMore.value = false + } } } } fun searchThreads(query: String) { viewModelScope.launch { - if (query.trim().isNotEmpty()) { - queryChannel.emit(query.trim()) - } else { - currentQuery = null - nextPage = 1 - threadsList.clear() - _uiState.value = DiscussionSearchThreadUIState.Threads(emptyList(), 0) - } + queryChannel.emit(query.trim()) } } @@ -126,10 +133,13 @@ class DiscussionSearchThreadViewModel( } private fun loadThreadsInternal(query: String, page: Int) { - viewModelScope.launch { - try { + loadNextJob?.cancel() + loadNextJob = flow { + emit(interactor.searchThread(courseId, query, page)) + } + .cancellable() + .onEach { response -> isLoading = true - val response = interactor.searchThread(courseId, query, page) if (response.pagination.next.isNotEmpty() && page < response.pagination.numPages) { _canLoadMore.value = true nextPage = page + 1 @@ -139,8 +149,12 @@ class DiscussionSearchThreadViewModel( } threadsList.addAll(response.results) _uiState.value = - DiscussionSearchThreadUIState.Threads(threadsList, response.pagination.count) - } catch (e: Exception) { + DiscussionSearchThreadUIState.Threads( + threadsList, response.pagination.count + ) + isLoading = false + _isUpdating.value = false + }.catch { e -> if (e.isInternetError()) { _uiMessage.value = UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) @@ -148,11 +162,10 @@ class DiscussionSearchThreadViewModel( _uiMessage.value = UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) } - } finally { isLoading = false _isUpdating.value = false } - } + .launchIn(viewModelScope) } } \ No newline at end of file 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 08709538b..2dbbd9af6 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 @@ -4,6 +4,7 @@ import androidx.lifecycle.LifecycleOwner 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 @@ -11,11 +12,10 @@ 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.DiscussionTopicsFragment +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 kotlinx.coroutines.launch class DiscussionThreadsViewModel( private val interactor: DiscussionInteractor, @@ -101,15 +101,15 @@ class DiscussionThreadsViewModel( } lastOrderBy = orderBy when (threadType) { - DiscussionTopicsFragment.ALL_POSTS -> { + DiscussionTopicsViewModel.ALL_POSTS -> { getAllThreads(orderBy) } - DiscussionTopicsFragment.FOLLOWING_POSTS -> { + DiscussionTopicsViewModel.FOLLOWING_POSTS -> { getFollowingThreads(orderBy) } - DiscussionTopicsFragment.TOPIC -> { + DiscussionTopicsViewModel.TOPIC -> { getThreads( topicId, orderBy @@ -129,15 +129,15 @@ class DiscussionThreadsViewModel( filter } when (threadType) { - DiscussionTopicsFragment.ALL_POSTS -> { + DiscussionTopicsViewModel.ALL_POSTS -> { getAllThreads(lastOrderBy) } - DiscussionTopicsFragment.FOLLOWING_POSTS -> { + DiscussionTopicsViewModel.FOLLOWING_POSTS -> { getFollowingThreads(lastOrderBy) } - DiscussionTopicsFragment.TOPIC -> { + DiscussionTopicsViewModel.TOPIC -> { getThreads( topicId, lastOrderBy diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsFragment.kt b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsScreen.kt similarity index 65% rename from discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsFragment.kt rename to discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsScreen.kt index e6b1ddbee..2797ed1a6 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsFragment.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsScreen.kt @@ -1,9 +1,6 @@ package org.openedx.discussion.presentation.topics import android.content.res.Configuration -import android.os.Bundle -import android.view.LayoutInflater -import android.view.ViewGroup import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -18,49 +15,36 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.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.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.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.res.painterResource 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.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 androidx.fragment.app.FragmentManager +import org.koin.androidx.compose.koinViewModel import org.openedx.core.FragmentViewType import org.openedx.core.UIMessage import org.openedx.core.ui.HandleUIMessage -import org.openedx.core.ui.OfflineModeDialog 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.rememberWindowSize import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors @@ -68,113 +52,57 @@ 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.Topic -import org.openedx.discussion.presentation.DiscussionRouter import org.openedx.discussion.presentation.ui.ThreadItemCategory import org.openedx.discussion.presentation.ui.TopicItem import org.openedx.discussion.R as discussionR -class DiscussionTopicsFragment : Fragment() { - - private val viewModel by viewModel { - parametersOf(requireArguments().getString(ARG_COURSE_ID, "")) - } - private val router by inject() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - viewModel.courseName = requireArguments().getString(ARG_COURSE_NAME, "") - viewModel.updateCourseTopics() - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ) = ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - OpenEdXTheme { - val windowSize = rememberWindowSize() - - val uiState by viewModel.uiState.observeAsState(DiscussionTopicsUIState.Loading) - val uiMessage by viewModel.uiMessage.observeAsState() - val refreshing by viewModel.isUpdating.observeAsState(false) - DiscussionTopicsScreen( - windowSize = windowSize, - uiState = uiState, - uiMessage = uiMessage, - refreshing = refreshing, - hasInternetConnection = viewModel.hasInternetConnection, - onReloadClick = { - viewModel.updateCourseTopics() - }, - onSwipeRefresh = { - viewModel.updateCourseTopics(withSwipeRefresh = true) - }, - onItemClick = { action, data, title -> - viewModel.discussionClickedEvent(action, data, title) - router.navigateToDiscussionThread( - requireActivity().supportFragmentManager, - action, - viewModel.courseId, - data, - title, - FragmentViewType.FULL_CONTENT - ) - }, - onSearchClick = { - router.navigateToSearchThread( - requireActivity().supportFragmentManager, - viewModel.courseId - ) - } - ) - } - } - } - - companion object { - const val TOPIC = "Topic" - const val ALL_POSTS = "All posts" - const val FOLLOWING_POSTS = "Following" +@Composable +fun DiscussionTopicsScreen( + discussionTopicsViewModel: DiscussionTopicsViewModel = koinViewModel(), + windowSize: WindowSize, + fragmentManager: FragmentManager +) { + val uiState by discussionTopicsViewModel.uiState.observeAsState(DiscussionTopicsUIState.Loading) + val uiMessage by discussionTopicsViewModel.uiMessage.collectAsState(null) - private const val ARG_COURSE_ID = "argCourseID" - private const val ARG_COURSE_NAME = "argCourseName" - fun newInstance( - courseId: String, - courseName: String - ): DiscussionTopicsFragment { - val fragment = DiscussionTopicsFragment() - fragment.arguments = bundleOf( - ARG_COURSE_ID to courseId, - ARG_COURSE_NAME to courseName, + DiscussionTopicsUI( + windowSize = windowSize, + uiState = uiState, + uiMessage = uiMessage, + onSearchClick = { + discussionTopicsViewModel.discussionRouter.navigateToSearchThread( + fragmentManager, + discussionTopicsViewModel.courseId ) - return fragment - } - } + }, + onItemClick = { action, data, title -> + discussionTopicsViewModel.discussionClickedEvent( + action, + data, + title + ) + discussionTopicsViewModel.discussionRouter.navigateToDiscussionThread( + fragmentManager, + action, + discussionTopicsViewModel.courseId, + data, + title, + FragmentViewType.FULL_CONTENT + ) + }, + ) } -@OptIn(ExperimentalMaterialApi::class) @Composable -private fun DiscussionTopicsScreen( +private fun DiscussionTopicsUI( windowSize: WindowSize, uiState: DiscussionTopicsUIState, uiMessage: UIMessage?, - refreshing: Boolean, - hasInternetConnection: Boolean, - onReloadClick: () -> Unit, onSearchClick: () -> Unit, - onSwipeRefresh: () -> Unit, onItemClick: (String, String, String) -> Unit ) { val scaffoldState = rememberScaffoldState() val context = LocalContext.current - val pullRefreshState = - rememberPullRefreshState(refreshing = refreshing, onRefresh = { onSwipeRefresh() }) - - var isInternetConnectionShown by rememberSaveable { - mutableStateOf(false) - } Scaffold( scaffoldState = scaffoldState, @@ -243,7 +171,7 @@ private fun DiscussionTopicsScreen( color = MaterialTheme.appColors.background, shape = MaterialTheme.appShapes.screenBackgroundShape ) { - Box(Modifier.pullRefresh(pullRefreshState)) { + Box { Column( modifier = Modifier .fillMaxSize() @@ -278,7 +206,7 @@ private fun DiscussionTopicsScreen( .height(categoriesHeight), onClick = { onItemClick( - DiscussionTopicsFragment.ALL_POSTS, + DiscussionTopicsViewModel.ALL_POSTS, "", context.getString(discussionR.string.discussion_all_posts) ) @@ -291,7 +219,7 @@ private fun DiscussionTopicsScreen( .height(categoriesHeight), onClick = { onItemClick( - DiscussionTopicsFragment.FOLLOWING_POSTS, + DiscussionTopicsViewModel.FOLLOWING_POSTS, "", context.getString(discussionR.string.discussion_posts_following) ) @@ -311,7 +239,7 @@ private fun DiscussionTopicsScreen( } else { TopicItem(topic = topic, onClick = { id, title -> onItemClick( - DiscussionTopicsFragment.TOPIC, + DiscussionTopicsViewModel.TOPIC, id, title ) @@ -324,35 +252,9 @@ private fun DiscussionTopicsScreen( } } - DiscussionTopicsUIState.Loading -> { - Box( - Modifier - .fillMaxSize(), contentAlignment = Alignment.Center - ) { - CircularProgressIndicator(color = MaterialTheme.appColors.primary) - } - } + DiscussionTopicsUIState.Loading -> {} } } - PullRefreshIndicator( - refreshing, - pullRefreshState, - Modifier.align(Alignment.TopCenter) - ) - if (!isInternetConnectionShown && !hasInternetConnection) { - OfflineModeDialog( - Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter), - onDismissCLick = { - isInternetConnectionShown = true - }, - onReloadClick = { - isInternetConnectionShown = true - onReloadClick() - } - ) - } } } } @@ -360,7 +262,6 @@ private fun DiscussionTopicsScreen( } } - @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) @@ -368,15 +269,11 @@ private fun DiscussionTopicsScreen( @Composable private fun DiscussionTopicsScreenPreview() { OpenEdXTheme { - DiscussionTopicsScreen( + DiscussionTopicsUI( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), uiState = DiscussionTopicsUIState.Topics(listOf(mockTopic, mockTopic)), uiMessage = null, - refreshing = false, - hasInternetConnection = true, - onReloadClick = {}, onItemClick = { _, _, _ -> }, - onSwipeRefresh = {}, onSearchClick = {} ) } @@ -387,15 +284,11 @@ private fun DiscussionTopicsScreenPreview() { @Composable private fun DiscussionTopicsScreenTabletPreview() { OpenEdXTheme { - DiscussionTopicsScreen( + DiscussionTopicsUI( windowSize = WindowSize(WindowType.Medium, WindowType.Medium), uiState = DiscussionTopicsUIState.Topics(listOf(mockTopic, mockTopic)), uiMessage = null, - refreshing = false, - hasInternetConnection = true, - onReloadClick = {}, onItemClick = { _, _, _ -> }, - onSwipeRefresh = {}, onSearchClick = {} ) } 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 72e26405d..5bdd90d70 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 @@ -3,65 +3,60 @@ package org.openedx.discussion.presentation.topics import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData 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.SingleEventLiveData 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.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.CourseRefresh import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.presentation.DiscussionAnalytics -import org.openedx.discussion.presentation.topics.DiscussionTopicsFragment.Companion.ALL_POSTS -import org.openedx.discussion.presentation.topics.DiscussionTopicsFragment.Companion.FOLLOWING_POSTS -import org.openedx.discussion.presentation.topics.DiscussionTopicsFragment.Companion.TOPIC +import org.openedx.discussion.presentation.DiscussionRouter class DiscussionTopicsViewModel( private val interactor: DiscussionInteractor, private val resourceManager: ResourceManager, private val analytics: DiscussionAnalytics, - private val networkConnection: NetworkConnection, - val courseId: String + private val courseNotifier: CourseNotifier, + val discussionRouter: DiscussionRouter, ) : BaseViewModel() { + var courseId: String = "" + var courseName: String = "" + private val _uiState = MutableLiveData() val uiState: LiveData get() = _uiState - private val _uiMessage = SingleEventLiveData() - val uiMessage: LiveData - get() = _uiMessage - - private val _isUpdating = MutableLiveData() - val isUpdating: LiveData - get() = _isUpdating + private val _uiMessage = MutableSharedFlow() + val uiMessage: SharedFlow + get() = _uiMessage.asSharedFlow() - val hasInternetConnection: Boolean - get() = networkConnection.isOnline() - - var courseName = "" + init { + collectCourseNotifier() + } - fun updateCourseTopics(withSwipeRefresh: Boolean = false) { + private fun getCourseTopic() { viewModelScope.launch { try { - if (withSwipeRefresh) { - _isUpdating.value = true - } else { - _uiState.value = DiscussionTopicsUIState.Loading - } - val response = interactor.getCourseTopics(courseId) _uiState.value = DiscussionTopicsUIState.Topics(response) } catch (e: Exception) { - val errorMessage = if (e.isInternetError()) { - resourceManager.getString(R.string.core_error_no_connection) + if (e.isInternetError()) { + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection))) } else { - resourceManager.getString(R.string.core_error_unknown_error) + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error))) } - _uiMessage.value = UIMessage.SnackBarMessage(errorMessage) } finally { - _isUpdating.value = false + courseNotifier.send(CourseLoading(false)) } } } @@ -81,4 +76,30 @@ class DiscussionTopicsViewModel( } } } + + private fun collectCourseNotifier() { + 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() + } + } + } + } + } + } + + companion object DiscussionTopic { + const val TOPIC = "Topic" + const val ALL_POSTS = "All posts" + const val FOLLOWING_POSTS = "Following" + } } \ No newline at end of file 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 e34df3ed8..92e5cd2fa 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 @@ -4,6 +4,26 @@ 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.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.Pagination @@ -12,22 +32,10 @@ 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.presentation.topics.DiscussionTopicsFragment +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 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.After -import org.junit.Assert.assertEquals -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.rules.TestRule import java.net.UnknownHostException @OptIn(ExperimentalCoroutinesApi::class) @@ -119,7 +127,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopicsFragment.ALL_POSTS + DiscussionTopicsViewModel.ALL_POSTS ) advanceUntilIdle() @@ -139,7 +147,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopicsFragment.ALL_POSTS + DiscussionTopicsViewModel.ALL_POSTS ) coEvery { interactor.getAllThreads(any(), any(), any(), any()) } throws Exception() advanceUntilIdle() @@ -170,7 +178,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopicsFragment.ALL_POSTS + DiscussionTopicsViewModel.ALL_POSTS ) advanceUntilIdle() @@ -198,7 +206,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopicsFragment.FOLLOWING_POSTS + DiscussionTopicsViewModel.FOLLOWING_POSTS ) advanceUntilIdle() @@ -218,7 +226,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopicsFragment.FOLLOWING_POSTS + DiscussionTopicsViewModel.FOLLOWING_POSTS ) coEvery { interactor.getFollowingThreads( @@ -273,7 +281,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopicsFragment.FOLLOWING_POSTS + DiscussionTopicsViewModel.FOLLOWING_POSTS ) advanceUntilIdle() @@ -301,7 +309,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopicsFragment.TOPIC + DiscussionTopicsViewModel.TOPIC ) advanceUntilIdle() @@ -321,7 +329,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopicsFragment.TOPIC + DiscussionTopicsViewModel.TOPIC ) coEvery { interactor.getThreads(any(), any(), any(), any(), any()) } throws Exception() advanceUntilIdle() @@ -352,7 +360,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopicsFragment.TOPIC + DiscussionTopicsViewModel.TOPIC ) advanceUntilIdle() @@ -371,7 +379,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopicsFragment.TOPIC + DiscussionTopicsViewModel.TOPIC ) coEvery { interactor.getThreads(any(), any(), any(), any(), any()) } returns ThreadsData( threads, @@ -391,7 +399,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopicsFragment.TOPIC + DiscussionTopicsViewModel.TOPIC ) coEvery { interactor.getThreads(any(), any(), any(), any(), any()) } returns ThreadsData( threads, @@ -411,7 +419,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopicsFragment.TOPIC + DiscussionTopicsViewModel.TOPIC ) coEvery { interactor.getThreads(any(), any(), any(), any(), any()) } returns ThreadsData( threads, @@ -441,7 +449,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopicsFragment.TOPIC + DiscussionTopicsViewModel.TOPIC ) viewModel.updateThread("") advanceUntilIdle() @@ -477,7 +485,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopicsFragment.TOPIC + DiscussionTopicsViewModel.TOPIC ) @@ -516,7 +524,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopicsFragment.TOPIC + DiscussionTopicsViewModel.TOPIC ) val mockLifeCycleOwner: LifecycleOwner = mockk() 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 48fc87e75..fcff13a30 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 @@ -7,24 +7,38 @@ import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeoutOrNull import org.junit.After import org.junit.Assert.assertEquals import org.junit.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.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.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.discussion.domain.interactor.DiscussionInteractor 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 { @@ -37,16 +51,93 @@ class DiscussionTopicsViewModelTest { private val resourceManager = mockk() private val interactor = mockk() private val analytics = mockk() - private val networkConnection = mockk() + private val router = mockk() + private val courseNotifier = mockk() private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" + 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 + ), + 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 + ), + 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 + ) + ) + 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 + ) + @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(CourseDataReady(courseStructure)) + coEvery { courseNotifier.send(any()) } returns Unit } @After @@ -55,98 +146,106 @@ class DiscussionTopicsViewModelTest { } @Test - fun `getCourseTopics no internet exception`() = runTest { - val viewModel = - DiscussionTopicsViewModel(interactor, resourceManager, analytics, networkConnection, "") + fun `getCourseTopics no internet exception`() = runTest(UnconfinedTestDispatcher()) { + val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, courseNotifier, router) coEvery { interactor.getCourseTopics(any()) } throws UnknownHostException() - viewModel.updateCourseTopics() + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + } advanceUntilIdle() coVerify(exactly = 1) { interactor.getCourseTopics(any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - assertEquals(noInternet, message?.message) - assert(viewModel.uiState.value is DiscussionTopicsUIState.Loading) + assertEquals(noInternet, message.await()?.message) } @Test - fun `getCourseTopics unknown exception`() = runTest { - val viewModel = - DiscussionTopicsViewModel(interactor, resourceManager, analytics, networkConnection, "") + fun `getCourseTopics unknown exception`() = runTest(UnconfinedTestDispatcher()) { + val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, courseNotifier, router) coEvery { interactor.getCourseTopics(any()) } throws Exception() - viewModel.updateCourseTopics() + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + } advanceUntilIdle() coVerify(exactly = 1) { interactor.getCourseTopics(any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - assertEquals(somethingWrong, message?.message) - assert(viewModel.uiState.value is DiscussionTopicsUIState.Loading) + assertEquals(somethingWrong, message.await()?.message) } @Test - fun `getCourseTopics success`() = runTest { - val viewModel = - DiscussionTopicsViewModel(interactor, resourceManager, analytics, networkConnection, "") + fun `getCourseTopics success`() = runTest(UnconfinedTestDispatcher()) { + val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, courseNotifier, router) coEvery { interactor.getCourseTopics(any()) } returns mockk() - viewModel.updateCourseTopics() advanceUntilIdle() - + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + } coVerify(exactly = 1) { interactor.getCourseTopics(any()) } - assert(viewModel.uiMessage.value == null) + assert(message.await()?.message.isNullOrEmpty()) assert(viewModel.uiState.value is DiscussionTopicsUIState.Topics) } @Test - fun `updateCourseTopics no internet exception`() = runTest { - val viewModel = - DiscussionTopicsViewModel(interactor, resourceManager, analytics, networkConnection, "") + fun `updateCourseTopics no internet exception`() = runTest(UnconfinedTestDispatcher()) { + val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, courseNotifier, router) coEvery { interactor.getCourseTopics(any()) } throws UnknownHostException() - viewModel.updateCourseTopics(withSwipeRefresh = true) + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + } advanceUntilIdle() coVerify(exactly = 1) { interactor.getCourseTopics(any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - assertEquals(noInternet, message?.message) - assert(viewModel.isUpdating.value == false) + assertEquals(noInternet, message.await()?.message) } @Test - fun `updateCourseTopics unknown exception`() = runTest { - val viewModel = - DiscussionTopicsViewModel(interactor, resourceManager, analytics, networkConnection, "") + fun `updateCourseTopics unknown exception`() = runTest(UnconfinedTestDispatcher()) { + val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, courseNotifier, router) coEvery { interactor.getCourseTopics(any()) } throws Exception() - viewModel.updateCourseTopics(withSwipeRefresh = true) + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + } advanceUntilIdle() coVerify(exactly = 1) { interactor.getCourseTopics(any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - assertEquals(somethingWrong, message?.message) - assert(viewModel.isUpdating.value == false) + assertEquals(somethingWrong, message.await()?.message) } @Test - fun `updateCourseTopics success`() = runTest { - val viewModel = - DiscussionTopicsViewModel(interactor, resourceManager, analytics, networkConnection, "") + fun `updateCourseTopics success`() = runTest(UnconfinedTestDispatcher()) { + val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, courseNotifier, router) coEvery { interactor.getCourseTopics(any()) } returns mockk() - viewModel.updateCourseTopics(withSwipeRefresh = true) + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + } advanceUntilIdle() coVerify(exactly = 1) { interactor.getCourseTopics(any()) } - assert(viewModel.uiMessage.value == null) + assert(message.await()?.message.isNullOrEmpty()) assert(viewModel.uiState.value is DiscussionTopicsUIState.Topics) - assert(viewModel.isUpdating.value == false) } -} \ No newline at end of file +} diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 4166a41c2..35c31a92e 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Mon Sep 12 17:38:01 EEST 2022 +#Fri May 03 13:24:00 EEST 2024 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip distributionPath=wrapper/dists -zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/profile/src/main/java/org/openedx/profile/domain/model/UpdateProfileRequest.kt b/profile/src/main/java/org/openedx/profile/domain/model/UpdateProfileRequest.kt deleted file mode 100644 index 1327cc90c..000000000 --- a/profile/src/main/java/org/openedx/profile/domain/model/UpdateProfileRequest.kt +++ /dev/null @@ -1,5 +0,0 @@ -package org.openedx.profile.domain.model - -class UpdateProfileRequest( -) - 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 bcab1126d..2422ba505 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/ProfileAnalytics.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/ProfileAnalytics.kt @@ -50,7 +50,7 @@ enum class ProfileAnalyticsEvent(val eventName: String, val biValue: String) { "edx.bi.app.profile.delete_account.clicked" ), USER_DELETE_ACCOUNT_CLICKED( - "Profile:User Delete Account Success", + "Profile:User Delete Account Clicked", "edx.bi.app.profile.user.delete_account.clicked" ), DELETE_ACCOUNT_SUCCESS( @@ -80,6 +80,5 @@ enum class ProfileAnalyticsKey(val key: String) { LIMITED_PROFILE("limited_profile"), SUCCESS("success"), FORCE("force"), - TRUE("true"), FALSE("false"), } 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 e272c071f..a4b194de4 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/ProfileRouter.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/ProfileRouter.kt @@ -8,13 +8,17 @@ interface ProfileRouter { fun navigateToEditProfile(fm: FragmentManager, account: Account) + fun navigateToDeleteAccount(fm: FragmentManager) + + fun navigateToSettings(fm: FragmentManager) + + fun restartApp(fm: FragmentManager, isLogistrationEnabled: Boolean) + fun navigateToVideoSettings(fm: FragmentManager) fun navigateToVideoQuality(fm: FragmentManager, videoQualityType: VideoQualityType) - fun navigateToDeleteAccount(fm: FragmentManager) - fun navigateToWebContent(fm: FragmentManager, title: String, url: String) - fun restartApp(fm: FragmentManager, isLogistrationEnabled: Boolean) + fun navigateToManageAccount(fm: FragmentManager) } diff --git a/profile/src/main/java/org/openedx/profile/presentation/anothers_account/AnothersProfileFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/anothersaccount/AnothersProfileFragment.kt similarity index 96% rename from profile/src/main/java/org/openedx/profile/presentation/anothers_account/AnothersProfileFragment.kt rename to profile/src/main/java/org/openedx/profile/presentation/anothersaccount/AnothersProfileFragment.kt index c311f88da..db330faa3 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/anothers_account/AnothersProfileFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/anothersaccount/AnothersProfileFragment.kt @@ -1,4 +1,4 @@ -package org.openedx.profile.presentation.anothers_account +package org.openedx.profile.presentation.anothersaccount import android.content.res.Configuration.UI_MODE_NIGHT_NO import android.content.res.Configuration.UI_MODE_NIGHT_YES @@ -192,7 +192,11 @@ private fun AnothersProfileScreen( .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally ) { - ProfileTopic(uiState.account) + ProfileTopic( + image = uiState.account.profileImage.imageUrlFull, + title = uiState.account.name, + subtitle = uiState.account.username + ) Spacer(modifier = Modifier.height(36.dp)) diff --git a/profile/src/main/java/org/openedx/profile/presentation/anothers_account/AnothersProfileUIState.kt b/profile/src/main/java/org/openedx/profile/presentation/anothersaccount/AnothersProfileUIState.kt similarity index 77% rename from profile/src/main/java/org/openedx/profile/presentation/anothers_account/AnothersProfileUIState.kt rename to profile/src/main/java/org/openedx/profile/presentation/anothersaccount/AnothersProfileUIState.kt index ebffce6dd..29cac7c1a 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/anothers_account/AnothersProfileUIState.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/anothersaccount/AnothersProfileUIState.kt @@ -1,4 +1,4 @@ -package org.openedx.profile.presentation.anothers_account +package org.openedx.profile.presentation.anothersaccount import org.openedx.profile.domain.model.Account diff --git a/profile/src/main/java/org/openedx/profile/presentation/anothers_account/AnothersProfileViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/anothersaccount/AnothersProfileViewModel.kt similarity index 96% rename from profile/src/main/java/org/openedx/profile/presentation/anothers_account/AnothersProfileViewModel.kt rename to profile/src/main/java/org/openedx/profile/presentation/anothersaccount/AnothersProfileViewModel.kt index b0c82e3e0..baabdb360 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/anothers_account/AnothersProfileViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/anothersaccount/AnothersProfileViewModel.kt @@ -1,4 +1,4 @@ -package org.openedx.profile.presentation.anothers_account +package org.openedx.profile.presentation.anothersaccount import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf 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 50771187a..f92ca3c3e 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 @@ -5,10 +5,10 @@ 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.Box import androidx.compose.foundation.layout.Column 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 @@ -24,6 +24,7 @@ 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.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -33,6 +34,7 @@ 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.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.platform.testTag @@ -51,13 +53,11 @@ 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 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.IconText import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.OpenEdXOutlinedTextField import org.openedx.core.ui.Toolbar @@ -65,20 +65,21 @@ 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.presentation.ProfileRouter -import org.openedx.profile.presentation.edit.EditProfileFragment -import org.openedx.profile.presentation.profile.ProfileViewModel +import org.openedx.profile.presentation.settings.SettingsViewModel import org.openedx.profile.R as profileR class DeleteProfileFragment : Fragment() { private val viewModel by viewModel() - private val logoutViewModel by viewModel() + private val logoutViewModel by viewModel() private val router by inject() override fun onCreate(savedInstanceState: Bundle?) { @@ -98,7 +99,7 @@ class DeleteProfileFragment : Fragment() { val uiState by viewModel.uiState.observeAsState(DeleteProfileFragmentUIState.Initial) val uiMessage by viewModel.uiMessage.observeAsState() - val logoutSuccess by logoutViewModel.successLogout.observeAsState(false) + val logoutSuccess by logoutViewModel.successLogout.collectAsState(false) DeleteProfileScreen( windowSize = windowSize, @@ -107,12 +108,6 @@ class DeleteProfileFragment : Fragment() { onBackClick = { requireActivity().supportFragmentManager.popBackStack() }, - onBackToProfileClick = { - requireActivity().supportFragmentManager.popBackStack( - EditProfileFragment::class.java.simpleName, - FragmentManager.POP_BACK_STACK_INCLUSIVE - ) - }, onDeleteClick = { viewModel.deleteProfile(it) } @@ -139,8 +134,7 @@ fun DeleteProfileScreen( uiState: DeleteProfileFragmentUIState, uiMessage: UIMessage?, onDeleteClick: (String) -> Unit, - onBackClick: () -> Unit, - onBackToProfileClick: () -> Unit + onBackClick: () -> Unit ) { val scaffoldState = rememberScaffoldState() val scrollState = rememberScrollState() @@ -193,103 +187,104 @@ fun DeleteProfileScreen( Column( modifier = Modifier .padding(paddingValues) - .statusBarsInset() - .displayCutoutForLandscape(), + .settingsHeaderBackground() + .statusBarsInset(), horizontalAlignment = Alignment.CenterHorizontally ) { Toolbar( - modifier = topBarWidth, + modifier = topBarWidth + .displayCutoutForLandscape(), label = stringResource(id = profileR.string.profile_delete_account), + labelTint = MaterialTheme.appColors.settingsTitleContent, + iconTint = MaterialTheme.appColors.settingsTitleContent, canShowBackBtn = true, onBackClick = onBackClick ) - Column( + Box( Modifier - .fillMaxHeight() - .then(contentWidth) + .fillMaxSize() + .clip(MaterialTheme.appShapes.screenBackgroundShape) + .background(MaterialTheme.appColors.background) + .displayCutoutForLandscape() .verticalScroll(scrollState), - horizontalAlignment = Alignment.CenterHorizontally + contentAlignment = Alignment.TopCenter ) { - Spacer(Modifier.height(48.dp)) - Image( - modifier = Modifier.size(145.dp), - painter = painterResource(id = org.openedx.profile.R.drawable.profile_delete_box), - contentDescription = null, - ) - Spacer(Modifier.height(32.dp)) - Text( - modifier = Modifier - .testTag("txt_delete_account_title") - .fillMaxWidth(), - text = buildAnnotatedString { - append(stringResource(id = profileR.string.profile_you_want_to)) - append(" ") - append(stringResource(id = profileR.string.profile_delete_your_account)) - addStyle( - style = SpanStyle( - color = MaterialTheme.appColors.textPrimary - ), - start = 0, - end = stringResource(id = profileR.string.profile_you_want_to).length - ) - addStyle( - style = SpanStyle( - color = MaterialTheme.appColors.error - ), - start = stringResource(id = profileR.string.profile_you_want_to).length + 1, - end = this.length - ) - }, - style = MaterialTheme.appTypography.headlineSmall, - textAlign = TextAlign.Center - ) - Spacer(Modifier.height(16.dp)) - Text( - modifier = Modifier - .testTag("txt_delete_account_description") - .fillMaxWidth(), - text = stringResource(id = profileR.string.profile_confirm_action), - style = MaterialTheme.appTypography.labelLarge, - color = MaterialTheme.appColors.textSecondary, - textAlign = TextAlign.Center - ) - Spacer(Modifier.height(40.dp)) - OpenEdXOutlinedTextField( - modifier = Modifier - .fillMaxWidth(), - title = stringResource(id = R.string.core_password), - onValueChanged = { - password = it - }, - visualTransformation = PasswordVisualTransformation(), - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Done, - keyboardActions = { - it.clearFocus() - onDeleteClick(password) - }, - errorText = errorText - ) - Spacer(Modifier.height(38.dp)) - OpenEdXButton( - text = stringResource(id = profileR.string.profile_yes_delete_account), - enabled = uiState !is DeleteProfileFragmentUIState.Loading && password.isNotEmpty(), - backgroundColor = MaterialTheme.appColors.error, - onClick = { - onDeleteClick(password) - } - ) - Spacer(Modifier.height(35.dp)) - IconText( - text = stringResource(id = profileR.string.profile_back_to_profile), - painter = painterResource(id = R.drawable.core_ic_back), - color = MaterialTheme.appColors.primary, - textStyle = MaterialTheme.appTypography.labelLarge, - onClick = { - onBackToProfileClick() - } - ) - Spacer(Modifier.height(24.dp)) + Column( + modifier = contentWidth + ) { + Spacer(Modifier.height(48.dp)) + Image( + modifier = Modifier + .size(145.dp) + .align(Alignment.CenterHorizontally), + painter = painterResource(id = profileR.drawable.profile_delete_box), + contentDescription = null, + ) + Spacer(Modifier.height(32.dp)) + Text( + modifier = Modifier + .testTag("txt_delete_account_title") + .fillMaxWidth(), + text = buildAnnotatedString { + append(stringResource(id = profileR.string.profile_you_want_to)) + append(" ") + append(stringResource(id = profileR.string.profile_delete_your_account)) + addStyle( + style = SpanStyle( + color = MaterialTheme.appColors.textPrimary + ), + start = 0, + end = stringResource(id = profileR.string.profile_you_want_to).length + ) + addStyle( + style = SpanStyle( + color = MaterialTheme.appColors.error + ), + start = stringResource(id = profileR.string.profile_you_want_to).length + 1, + end = this.length + ) + }, + style = MaterialTheme.appTypography.headlineSmall, + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(16.dp)) + Text( + modifier = Modifier + .testTag("txt_delete_account_description") + .fillMaxWidth(), + text = stringResource(id = profileR.string.profile_confirm_action), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textSecondary, + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(40.dp)) + OpenEdXOutlinedTextField( + modifier = Modifier + .fillMaxWidth(), + title = stringResource(id = R.string.core_password), + onValueChanged = { + password = it + }, + visualTransformation = PasswordVisualTransformation(), + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + keyboardActions = { + it.clearFocus() + onDeleteClick(password) + }, + errorText = errorText + ) + Spacer(Modifier.height(38.dp)) + OpenEdXButton( + text = stringResource(id = profileR.string.profile_yes_delete_account), + enabled = uiState !is DeleteProfileFragmentUIState.Loading && password.isNotEmpty(), + backgroundColor = MaterialTheme.appColors.error, + onClick = { + onDeleteClick(password) + } + ) + Spacer(Modifier.height(24.dp)) + } } } } @@ -315,7 +310,6 @@ fun DeleteProfileScreenPreview() { uiState = DeleteProfileFragmentUIState.Initial, uiMessage = null, onBackClick = {}, - onBackToProfileClick = {}, onDeleteClick = {} ) } 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 475d1be6f..c4477ef28 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 @@ -34,7 +34,6 @@ class DeleteProfileViewModel( val uiMessage: LiveData get() = _uiMessage - fun deleteProfile(password: String) { logDeleteProfileClickedEvent() if (!validator.isPasswordValid(password)) { 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 dcf8d4d35..907b3942a 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 @@ -105,7 +105,6 @@ import androidx.fragment.app.Fragment import coil.compose.AsyncImage import coil.request.ImageRequest import kotlinx.coroutines.launch -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.AppDataConstants.DEFAULT_MIME_TYPE @@ -139,7 +138,6 @@ import org.openedx.core.ui.windowSizeValue import org.openedx.core.utils.LocaleUtils import org.openedx.profile.R import org.openedx.profile.domain.model.Account -import org.openedx.profile.presentation.ProfileRouter import java.io.ByteArrayOutputStream import java.io.File import java.io.FileOutputStream @@ -153,8 +151,6 @@ class EditProfileFragment : Fragment() { parametersOf(requireArguments().parcelable(ARG_ACCOUNT)!!) } - private val router by inject() - private val registerForActivityResult = registerForActivityResult(ActivityResultContracts.GetContent()) { uri -> uri?.let { @@ -224,12 +220,6 @@ class EditProfileFragment : Fragment() { onKeepEdit = { viewModel.setShowLeaveDialog(false) }, - onDeleteClick = { - viewModel.profileDeleteAccountClickedEvent() - router.navigateToDeleteAccount( - requireActivity().supportFragmentManager - ) - }, onSelectImageClick = { registerForActivityResult.launch("image/*") }, @@ -331,7 +321,6 @@ private fun EditProfileScreen( onLimitedProfileChange: (Boolean) -> Unit, onBackClick: (Boolean) -> Unit, onSaveClick: (Map) -> Unit, - onDeleteClick: () -> Unit, onSelectImageClick: () -> Unit, onDeleteImageClick: () -> Unit, ) { @@ -738,15 +727,6 @@ private fun EditProfileScreen( mapFields = mapFields, onDoneClick = { onSaveClick(mapFields.toMap()) } ) - Spacer(Modifier.height(40.dp)) - IconText( - text = stringResource(id = R.string.profile_delete_profile), - painter = painterResource(id = R.drawable.profile_ic_trash), - textStyle = MaterialTheme.appTypography.labelLarge, - color = MaterialTheme.appColors.error, - onClick = { - onDeleteClick() - }) Spacer(Modifier.height(52.dp)) } if (openWarningMessageDialog) { @@ -1314,7 +1294,6 @@ private fun EditProfileScreenPreview() { leaveDialog = false, onBackClick = {}, onSaveClick = {}, - onDeleteClick = {}, onSelectImageClick = {}, onDeleteImageClick = {}, onDataChanged = {}, @@ -1338,7 +1317,6 @@ private fun EditProfileScreenTabletPreview() { leaveDialog = false, onBackClick = {}, onSaveClick = {}, - onDeleteClick = {}, onSelectImageClick = {}, onDeleteImageClick = {}, onDataChanged = {}, 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 7505d5c88..64cf9789f 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 @@ -143,10 +143,6 @@ class EditProfileViewModel( logProfileEvent(ProfileAnalyticsEvent.EDIT_DONE_CLICKED) } - fun profileDeleteAccountClickedEvent() { - logProfileEvent(ProfileAnalyticsEvent.DELETE_ACCOUNT_CLICKED) - } - private fun logProfileEvent( event: ProfileAnalyticsEvent, params: Map = emptyMap(), 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 new file mode 100644 index 000000000..084d544aa --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/manageaccount/ManageAccountFragment.kt @@ -0,0 +1,69 @@ +package org.openedx.profile.presentation.manageaccount + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.Fragment +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.openedx.core.ui.rememberWindowSize +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.profile.presentation.manageaccount.compose.ManageAccountView +import org.openedx.profile.presentation.manageaccount.compose.ManageAccountViewAction + +class ManageAccountFragment : Fragment() { + + private val viewModel: ManageAccountViewModel by viewModel() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + lifecycle.addObserver(viewModel) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val windowSize = rememberWindowSize() + val uiState by viewModel.uiState.collectAsState() + val uiMessage by viewModel.uiMessage.collectAsState(null) + val refreshing by viewModel.isUpdating.collectAsState(false) + + ManageAccountView( + windowSize = windowSize, + uiState = uiState, + uiMessage = uiMessage, + refreshing = refreshing, + onAction = { action -> + when (action) { + ManageAccountViewAction.EditAccountClick -> { + viewModel.profileEditClicked( + requireActivity().supportFragmentManager + ) + } + ManageAccountViewAction.SwipeRefresh -> { + viewModel.updateAccount() + } + ManageAccountViewAction.BackClick -> { + requireActivity().supportFragmentManager.popBackStack() + } + ManageAccountViewAction.DeleteAccount -> { + viewModel.profileDeleteAccountClickedEvent() + viewModel.profileRouter.navigateToDeleteAccount( + requireActivity().supportFragmentManager + ) + } + } + } + ) + } + } + } +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/manageaccount/ManageAccountUIState.kt b/profile/src/main/java/org/openedx/profile/presentation/manageaccount/ManageAccountUIState.kt new file mode 100644 index 000000000..4625023db --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/manageaccount/ManageAccountUIState.kt @@ -0,0 +1,14 @@ +package org.openedx.profile.presentation.manageaccount + +import org.openedx.profile.domain.model.Account + +sealed class ManageAccountUIState { + /** + * @param account User account data + */ + data class Data( + val account: Account + ) : ManageAccountUIState() + + object Loading : ManageAccountUIState() +} 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 new file mode 100644 index 000000000..2370e0508 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/manageaccount/ManageAccountViewModel.kt @@ -0,0 +1,120 @@ +package org.openedx.profile.presentation.manageaccount + +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.LifecycleOwner +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.extension.isInternetError +import org.openedx.core.system.ResourceManager +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.presentation.ProfileRouter +import org.openedx.profile.system.notifier.AccountUpdated +import org.openedx.profile.system.notifier.ProfileNotifier + +class ManageAccountViewModel( + private val interactor: ProfileInteractor, + private val resourceManager: ResourceManager, + private val notifier: ProfileNotifier, + private val analytics: ProfileAnalytics, + val profileRouter: ProfileRouter +) : BaseViewModel() { + + private val _uiState: MutableStateFlow = MutableStateFlow(ManageAccountUIState.Loading) + internal val uiState: StateFlow = _uiState.asStateFlow() + + private val _uiMessage = MutableSharedFlow() + val uiMessage: SharedFlow + get() = _uiMessage.asSharedFlow() + + private val _isUpdating = MutableStateFlow(false) + val isUpdating: StateFlow + get() = _isUpdating.asStateFlow() + + init { + getAccount() + } + + override fun onCreate(owner: LifecycleOwner) { + super.onCreate(owner) + viewModelScope.launch { + notifier.notifier.collect { + if (it is AccountUpdated) { + getAccount() + } + } + } + } + + private fun getAccount() { + _uiState.value = ManageAccountUIState.Loading + viewModelScope.launch { + try { + val cachedAccount = interactor.getCachedAccount() + if (cachedAccount == null) { + _uiState.value = ManageAccountUIState.Loading + } else { + _uiState.value = ManageAccountUIState.Data( + account = cachedAccount + ) + } + val account = interactor.getAccount() + _uiState.value = ManageAccountUIState.Data( + account = account + ) + } 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 { + _isUpdating.value = false + } + } + } + + fun updateAccount() { + _isUpdating.value = true + getAccount() + } + + fun profileEditClicked(fragmentManager: FragmentManager) { + (uiState.value as? ManageAccountUIState.Data)?.let { data -> + profileRouter.navigateToEditProfile( + fragmentManager, + data.account + ) + } + logProfileEvent(ProfileAnalyticsEvent.EDIT_CLICKED) + } + + fun profileDeleteAccountClickedEvent() { + logProfileEvent(ProfileAnalyticsEvent.DELETE_ACCOUNT_CLICKED) + } + + private fun logProfileEvent( + event: ProfileAnalyticsEvent, + params: Map = emptyMap(), + ) { + analytics.logEvent( + event = event.eventName, + params = buildMap { + put(ProfileAnalyticsKey.NAME.key, event.biValue) + put(ProfileAnalyticsKey.CATEGORY.key, ProfileAnalyticsKey.PROFILE.key) + putAll(params) + } + ) + } +} 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 new file mode 100644 index 000000000..42ff5afef --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/manageaccount/compose/ManageAccountView.kt @@ -0,0 +1,247 @@ +package org.openedx.profile.presentation.manageaccount.compose + +import android.content.res.Configuration +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.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +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.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +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.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.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 +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.presentation.manageaccount.ManageAccountUIState +import org.openedx.profile.presentation.ui.ProfileTopic +import org.openedx.profile.presentation.ui.mockAccount +import org.openedx.profile.R as ProfileR + +@OptIn(ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class) +@Composable +internal fun ManageAccountView( + windowSize: WindowSize, + uiState: ManageAccountUIState, + uiMessage: UIMessage?, + refreshing: Boolean, + onAction: (ManageAccountViewAction) -> Unit +) { + val scaffoldState = rememberScaffoldState() + + val pullRefreshState = rememberPullRefreshState( + refreshing = refreshing, + onRefresh = { onAction(ManageAccountViewAction.SwipeRefresh) }) + + 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() + ) + ) + } + + HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) + + Column( + modifier = Modifier + .padding(paddingValues) + .settingsHeaderBackground() + .statusBarsInset(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + contentAlignment = Alignment.CenterEnd + ) { + Toolbar( + modifier = topBarWidth + .displayCutoutForLandscape(), + label = stringResource(id = R.string.core_manage_account), + canShowBackBtn = true, + labelTint = MaterialTheme.appColors.settingsTitleContent, + iconTint = MaterialTheme.appColors.settingsTitleContent, + onBackClick = { + onAction(ManageAccountViewAction.BackClick) + } + ) + } + Surface( + color = MaterialTheme.appColors.background, + shape = MaterialTheme.appShapes.screenBackgroundShape, + ) { + Box( + modifier = Modifier + .pullRefresh(pullRefreshState), + contentAlignment = Alignment.TopCenter + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .displayCutoutForLandscape(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + when (uiState) { + is ManageAccountUIState.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } + } + + is ManageAccountUIState.Data -> { + Column( + Modifier + .fillMaxHeight() + .then(contentWidth) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + Spacer(modifier = Modifier.height(12.dp)) + ProfileTopic( + image = uiState.account.profileImage.imageUrlFull, + title = uiState.account.name, + subtitle = uiState.account.email ?: "" + ) + OpenEdXOutlinedButton( + modifier = Modifier + .fillMaxWidth(), + text = stringResource(id = ProfileR.string.profile_edit_profile), + onClick = { + onAction(ManageAccountViewAction.EditAccountClick) + }, + borderColor = MaterialTheme.appColors.buttonBackground, + textColor = MaterialTheme.appColors.textAccent + ) + Spacer(modifier = Modifier.height(12.dp)) + IconText( + text = stringResource(id = ProfileR.string.profile_delete_profile), + painter = painterResource(id = ProfileR.drawable.profile_ic_trash), + textStyle = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.error, + onClick = { + onAction(ManageAccountViewAction.DeleteAccount) + }) + Spacer(modifier = Modifier.height(12.dp)) + } + } + } + } + PullRefreshIndicator( + refreshing, + pullRefreshState, + Modifier.align(Alignment.TopCenter) + ) + } + } + } + } +} + +@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 ManageAccountViewPreview() { + OpenEdXTheme { + ManageAccountView( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + uiState = mockUiState, + uiMessage = null, + refreshing = false, + onAction = {} + ) + } +} + + +@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 +private fun ManageAccountViewTabletPreview() { + OpenEdXTheme { + ManageAccountView( + windowSize = WindowSize(WindowType.Medium, WindowType.Medium), + uiState = mockUiState, + uiMessage = null, + refreshing = false, + onAction = {} + ) + } +} + +private val mockUiState = ManageAccountUIState.Data( + account = mockAccount +) + +internal interface ManageAccountViewAction { + object EditAccountClick : ManageAccountViewAction + object SwipeRefresh : ManageAccountViewAction + object DeleteAccount : ManageAccountViewAction + object BackClick : ManageAccountViewAction +} 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 4c18cd103..cdf190e6a 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 @@ -3,7 +3,6 @@ package org.openedx.profile.presentation.profile import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState @@ -35,81 +34,30 @@ class ProfileFragment : Fragment() { OpenEdXTheme { val windowSize = rememberWindowSize() val uiState by viewModel.uiState.collectAsState() - val logoutSuccess by viewModel.successLogout.observeAsState(false) val uiMessage by viewModel.uiMessage.observeAsState() val refreshing by viewModel.isUpdating.observeAsState(false) - val appUpgradeEvent by viewModel.appUpgradeEvent.observeAsState(null) ProfileView( windowSize = windowSize, uiState = uiState, uiMessage = uiMessage, refreshing = refreshing, - appUpgradeEvent = appUpgradeEvent, + onSettingsClick = { + viewModel.profileRouter.navigateToSettings(requireActivity().supportFragmentManager) + }, onAction = { action -> when (action) { - ProfileViewAction.AppVersionClick -> { - viewModel.appVersionClickedEvent(requireContext()) - } - ProfileViewAction.EditAccountClick -> { viewModel.profileEditClicked( requireParentFragment().parentFragmentManager ) } - - ProfileViewAction.LogoutClick -> { - viewModel.logout() - } - - ProfileViewAction.PrivacyPolicyClick -> { - viewModel.privacyPolicyClicked( - requireParentFragment().parentFragmentManager - ) - } - - ProfileViewAction.CookiePolicyClick -> { - viewModel.cookiePolicyClicked( - requireParentFragment().parentFragmentManager - ) - } - - ProfileViewAction.DataSellClick -> { - viewModel.dataSellClicked( - requireParentFragment().parentFragmentManager - ) - } - - ProfileViewAction.FaqClick -> viewModel.faqClicked() - - ProfileViewAction.SupportClick -> { - viewModel.emailSupportClicked(requireContext()) - } - - ProfileViewAction.TermsClick -> { - viewModel.termsOfUseClicked( - requireParentFragment().parentFragmentManager - ) - } - - ProfileViewAction.VideoSettingsClick -> { - viewModel.profileVideoSettingsClicked( - requireParentFragment().parentFragmentManager - ) - } - ProfileViewAction.SwipeRefresh -> { viewModel.updateAccount() } } - }, - ) - - LaunchedEffect(logoutSuccess) { - if (logoutSuccess) { - viewModel.restartApp(requireParentFragment().parentFragmentManager) } - } + ) } } } diff --git a/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileUIState.kt b/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileUIState.kt index f869b7f0b..b33720ff9 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileUIState.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileUIState.kt @@ -1,16 +1,13 @@ package org.openedx.profile.presentation.profile import org.openedx.profile.domain.model.Account -import org.openedx.profile.domain.model.Configuration sealed class ProfileUIState { /** * @param account User account data - * @param configuration Configuration data */ data class Data( - val account: Account, - val configuration: Configuration, + val account: Account ) : ProfileUIState() object Loading : ProfileUIState() 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 53a982509..d8fc19715 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 @@ -1,63 +1,38 @@ package org.openedx.profile.presentation.profile -import android.content.Context -import androidx.compose.ui.text.intl.Locale import androidx.fragment.app.FragmentManager import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow 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.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.AppUpgradeEvent -import org.openedx.core.system.notifier.AppUpgradeNotifier -import org.openedx.core.utils.EmailUtil import org.openedx.profile.domain.interactor.ProfileInteractor -import org.openedx.profile.domain.model.Configuration 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.AccountUpdated import org.openedx.profile.system.notifier.ProfileNotifier class ProfileViewModel( - private val appData: AppData, - private val config: Config, private val interactor: ProfileInteractor, private val resourceManager: ResourceManager, private val notifier: ProfileNotifier, - private val dispatcher: CoroutineDispatcher, - private val cookieManager: AppCookieManager, - private val workerController: DownloadWorkerController, private val analytics: ProfileAnalytics, - private val router: ProfileRouter, - private val appUpgradeNotifier: AppUpgradeNotifier, + val profileRouter: ProfileRouter ) : BaseViewModel() { - private val _uiState: MutableStateFlow = - MutableStateFlow(ProfileUIState.Loading) + private val _uiState: MutableStateFlow = MutableStateFlow(ProfileUIState.Loading) internal val uiState: StateFlow = _uiState.asStateFlow() - private val _successLogout = MutableLiveData() - val successLogout: LiveData - get() = _successLogout - private val _uiMessage = MutableLiveData() val uiMessage: LiveData get() = _uiMessage @@ -66,23 +41,8 @@ class ProfileViewModel( val isUpdating: LiveData get() = _isUpdating - private val _appUpgradeEvent = MutableLiveData() - val appUpgradeEvent: LiveData - get() = _appUpgradeEvent - - val isLogistrationEnabled get() = config.isPreLoginExperienceEnabled() - - private val configuration - get() = Configuration( - agreementUrls = config.getAgreement(Locale.current.language), - faqUrl = config.getFaqUrl(), - supportEmail = config.getFeedbackEmailAddress(), - versionName = appData.versionName, - ) - init { getAccount() - collectAppUpgradeEvent() } override fun onCreate(owner: LifecycleOwner) { @@ -91,8 +51,6 @@ class ProfileViewModel( notifier.notifier.collect { if (it is AccountUpdated) { getAccount() - } else if (it is AccountDeactivated) { - logout() } } } @@ -107,14 +65,12 @@ class ProfileViewModel( _uiState.value = ProfileUIState.Loading } else { _uiState.value = ProfileUIState.Data( - account = cachedAccount, - configuration = configuration, + account = cachedAccount ) } val account = interactor.getAccount() _uiState.value = ProfileUIState.Data( - account = account, - configuration = configuration, + account = account ) } catch (e: Exception) { if (e.isInternetError()) { @@ -135,46 +91,9 @@ class ProfileViewModel( getAccount() } - fun logout() { - logProfileEvent(ProfileAnalyticsEvent.LOGOUT_CLICKED) - viewModelScope.launch { - try { - workerController.removeModels() - withContext(dispatcher) { - interactor.logout() - } - logProfileEvent( - event = ProfileAnalyticsEvent.LOGGED_OUT, - params = buildMap { - put(ProfileAnalyticsKey.FORCE.key, ProfileAnalyticsKey.FALSE.key) - } - ) - } catch (e: Exception) { - if (e.isInternetError()) { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) - } else { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) - } - } finally { - cookieManager.clearWebViewCookie() - _successLogout.value = true - } - } - } - - private fun collectAppUpgradeEvent() { - viewModelScope.launch { - appUpgradeNotifier.notifier.collect { event -> - _appUpgradeEvent.value = event - } - } - } - fun profileEditClicked(fragmentManager: FragmentManager) { (uiState.value as? ProfileUIState.Data)?.let { data -> - router.navigateToEditProfile( + profileRouter.navigateToEditProfile( fragmentManager, data.account ) @@ -182,71 +101,6 @@ class ProfileViewModel( logProfileEvent(ProfileAnalyticsEvent.EDIT_CLICKED) } - fun profileVideoSettingsClicked(fragmentManager: FragmentManager) { - router.navigateToVideoSettings(fragmentManager) - logProfileEvent(ProfileAnalyticsEvent.VIDEO_SETTING_CLICKED) - } - - fun privacyPolicyClicked(fragmentManager: FragmentManager) { - router.navigateToWebContent( - fm = fragmentManager, - title = resourceManager.getString(R.string.core_privacy_policy), - url = configuration.agreementUrls.privacyPolicyUrl, - ) - logProfileEvent(ProfileAnalyticsEvent.PRIVACY_POLICY_CLICKED) - } - - fun cookiePolicyClicked(fragmentManager: FragmentManager) { - router.navigateToWebContent( - fm = fragmentManager, - title = resourceManager.getString(R.string.core_cookie_policy), - url = configuration.agreementUrls.cookiePolicyUrl, - ) - logProfileEvent(ProfileAnalyticsEvent.COOKIE_POLICY_CLICKED) - } - - fun dataSellClicked(fragmentManager: FragmentManager) { - router.navigateToWebContent( - fm = fragmentManager, - title = resourceManager.getString(R.string.core_data_sell), - url = configuration.agreementUrls.dataSellConsentUrl, - ) - logProfileEvent(ProfileAnalyticsEvent.DATA_SELL_CLICKED) - } - - fun faqClicked() { - logProfileEvent(ProfileAnalyticsEvent.FAQ_CLICKED) - } - - fun termsOfUseClicked(fragmentManager: FragmentManager) { - router.navigateToWebContent( - fm = fragmentManager, - title = resourceManager.getString(R.string.core_terms_of_use), - url = configuration.agreementUrls.tosUrl, - ) - logProfileEvent(ProfileAnalyticsEvent.TERMS_OF_USE_CLICKED) - } - - fun emailSupportClicked(context: Context) { - EmailUtil.showFeedbackScreen( - context = context, - feedbackEmailAddress = config.getFeedbackEmailAddress(), - appVersion = appData.versionName - ) - logProfileEvent(ProfileAnalyticsEvent.CONTACT_SUPPORT_CLICKED) - } - - fun appVersionClickedEvent(context: Context) { - AppUpdateState.openPlayMarket(context) - } - - fun restartApp(fragmentManager: FragmentManager) { - router.restartApp( - fragmentManager, - isLogistrationEnabled - ) - } - private fun logProfileEvent( event: ProfileAnalyticsEvent, params: Map = emptyMap(), 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 2d4b75dd2..bec24967f 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 @@ -1,40 +1,23 @@ package org.openedx.profile.presentation.profile.compose import android.content.res.Configuration -import androidx.compose.foundation.Image -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.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.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.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.automirrored.filled.ArrowForwardIos -import androidx.compose.material.icons.automirrored.filled.ExitToApp -import androidx.compose.material.icons.automirrored.filled.OpenInNew -import androidx.compose.material.icons.filled.Close import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState @@ -43,50 +26,32 @@ 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.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalUriHandler -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.compose.ui.window.Dialog import org.openedx.core.R import org.openedx.core.UIMessage -import org.openedx.core.domain.model.AgreementUrls -import org.openedx.core.domain.model.ProfileImage -import org.openedx.core.extension.tagId -import org.openedx.core.presentation.global.AppData -import org.openedx.core.system.notifier.AppUpgradeEvent import org.openedx.core.ui.HandleUIMessage -import org.openedx.core.ui.IconText -import org.openedx.core.ui.OpenEdXButton +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.theme.appShapes -import org.openedx.core.ui.theme.appTypography import org.openedx.core.ui.windowSizeValue -import org.openedx.profile.domain.model.Account import org.openedx.profile.presentation.profile.ProfileUIState import org.openedx.profile.presentation.ui.ProfileInfoSection import org.openedx.profile.presentation.ui.ProfileTopic -import org.openedx.profile.domain.model.Configuration as AppConfiguration +import org.openedx.profile.presentation.ui.mockAccount @OptIn(ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class) @Composable @@ -95,11 +60,10 @@ internal fun ProfileView( uiState: ProfileUIState, uiMessage: UIMessage?, refreshing: Boolean, - appUpgradeEvent: AppUpgradeEvent?, onAction: (ProfileViewAction) -> Unit, + onSettingsClick: () -> Unit ) { val scaffoldState = rememberScaffoldState() - var showLogoutDialog by rememberSaveable { mutableStateOf(false) } val pullRefreshState = rememberPullRefreshState( refreshing = refreshing, @@ -125,30 +89,8 @@ internal fun ProfileView( ) } - val topBarWidth by remember(key1 = windowSize) { - mutableStateOf( - windowSize.windowSizeValue( - expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), - compact = Modifier - .fillMaxWidth() - ) - ) - } - HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) - if (showLogoutDialog) { - LogoutDialog( - onDismissRequest = { - showLogoutDialog = false - }, - onLogoutClick = { - showLogoutDialog = false - onAction(ProfileViewAction.LogoutClick) - } - ) - } - Column( modifier = Modifier .padding(paddingValues) @@ -156,36 +98,12 @@ internal fun ProfileView( .displayCutoutForLandscape(), horizontalAlignment = Alignment.CenterHorizontally ) { - Box( - modifier = topBarWidth, - contentAlignment = Alignment.CenterEnd - ) { - Text( - modifier = Modifier - .testTag("txt_profile_title") - .fillMaxWidth(), - text = stringResource(id = R.string.core_profile), - color = MaterialTheme.appColors.textPrimary, - textAlign = TextAlign.Center, - style = MaterialTheme.appTypography.titleMedium - ) + Toolbar( + label = stringResource(id = R.string.core_profile), + canShowSettingsIcon = true, + onSettingsClick = onSettingsClick + ) - IconText( - modifier = Modifier - .testTag("it_edit_account") - .height(48.dp) - .padding(end = 24.dp), - text = stringResource(org.openedx.profile.R.string.profile_edit), - painter = painterResource(id = R.drawable.core_ic_edit), - textStyle = MaterialTheme.appTypography.labelLarge, - color = MaterialTheme.appColors.primary, - onClick = { - if (uiState is ProfileUIState.Data) { - onAction(ProfileViewAction.EditAccountClick) - } - } - ) - } Surface( color = MaterialTheme.appColors.background ) { @@ -214,35 +132,27 @@ internal fun ProfileView( .fillMaxHeight() .then(contentWidth) .verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp) ) { - ProfileTopic(uiState.account) - - Spacer(modifier = Modifier.height(36.dp)) - - ProfileInfoSection(uiState.account) - - Spacer(modifier = Modifier.height(24.dp)) - - SettingsSection(onVideoSettingsClick = { - onAction(ProfileViewAction.VideoSettingsClick) - }) - - Spacer(modifier = Modifier.height(24.dp)) - - SupportInfoSection( - uiState = uiState, - onAction = onAction, - appUpgradeEvent = appUpgradeEvent, + Spacer(modifier = Modifier.height(12.dp)) + ProfileTopic( + image = uiState.account.profileImage.imageUrlFull, + title = uiState.account.name, + subtitle = "@${uiState.account.username}" ) - - Spacer(modifier = Modifier.height(24.dp)) - - LogoutButton( - onClick = { showLogoutDialog = true } + ProfileInfoSection(uiState.account) + OpenEdXOutlinedButton( + modifier = Modifier + .fillMaxWidth(), + text = stringResource(id = org.openedx.profile.R.string.profile_edit_profile), + onClick = { + onAction(ProfileViewAction.EditAccountClick) + }, + borderColor = MaterialTheme.appColors.buttonBackground, + textColor = MaterialTheme.appColors.textAccent ) - - Spacer(Modifier.height(30.dp)) + Spacer(modifier = Modifier.height(12.dp)) } } } @@ -258,492 +168,6 @@ internal fun ProfileView( } } -@Composable -private fun SettingsSection(onVideoSettingsClick: () -> Unit) { - Column { - Text( - modifier = Modifier.testTag("txt_settings"), - text = stringResource(id = org.openedx.profile.R.string.profile_settings), - style = MaterialTheme.appTypography.labelLarge, - color = MaterialTheme.appColors.textSecondary - ) - Spacer(modifier = Modifier.height(14.dp)) - Card( - modifier = Modifier, - shape = MaterialTheme.appShapes.cardShape, - elevation = 0.dp, - backgroundColor = MaterialTheme.appColors.cardViewBackground - ) { - Column(Modifier.fillMaxWidth()) { - ProfileInfoItem( - text = stringResource(id = org.openedx.profile.R.string.profile_video_settings), - onClick = onVideoSettingsClick - ) - } - } - } -} - -@Composable -private fun SupportInfoSection( - uiState: ProfileUIState.Data, - appUpgradeEvent: AppUpgradeEvent?, - onAction: (ProfileViewAction) -> Unit -) { - Column { - Text( - modifier = Modifier.testTag("txt_support_info"), - text = stringResource(id = org.openedx.profile.R.string.profile_support_info), - style = MaterialTheme.appTypography.labelLarge, - color = MaterialTheme.appColors.textSecondary - ) - Spacer(modifier = Modifier.height(14.dp)) - Card( - modifier = Modifier, - shape = MaterialTheme.appShapes.cardShape, - elevation = 0.dp, - backgroundColor = MaterialTheme.appColors.cardViewBackground - ) { - Column(Modifier.fillMaxWidth()) { - if (uiState.configuration.supportEmail.isNotBlank()) { - ProfileInfoItem(text = stringResource(id = org.openedx.profile.R.string.profile_contact_support)) { - onAction(ProfileViewAction.SupportClick) - } - Divider( - modifier = Modifier.padding(vertical = 4.dp), - color = MaterialTheme.appColors.divider - ) - } - if (uiState.configuration.agreementUrls.tosUrl.isNotBlank()) { - ProfileInfoItem(text = stringResource(id = R.string.core_terms_of_use)) { - onAction(ProfileViewAction.TermsClick) - } - Divider( - modifier = Modifier.padding(vertical = 4.dp), - color = MaterialTheme.appColors.divider - ) - } - if (uiState.configuration.agreementUrls.privacyPolicyUrl.isNotBlank()) { - ProfileInfoItem(text = stringResource(id = R.string.core_privacy_policy)) { - onAction(ProfileViewAction.PrivacyPolicyClick) - } - Divider( - modifier = Modifier.padding(vertical = 4.dp), - color = MaterialTheme.appColors.divider - ) - } - if (uiState.configuration.agreementUrls.cookiePolicyUrl.isNotBlank()) { - ProfileInfoItem(text = stringResource(id = R.string.core_cookie_policy)) { - onAction(ProfileViewAction.CookiePolicyClick) - } - Divider( - modifier = Modifier.padding(vertical = 4.dp), - color = MaterialTheme.appColors.divider - ) - } - if (uiState.configuration.agreementUrls.dataSellConsentUrl.isNotBlank()) { - ProfileInfoItem(text = stringResource(id = R.string.core_data_sell)) { - onAction(ProfileViewAction.DataSellClick) - } - Divider( - modifier = Modifier.padding(vertical = 4.dp), - color = MaterialTheme.appColors.divider - ) - } - if (uiState.configuration.faqUrl.isNotBlank()) { - val uriHandler = LocalUriHandler.current - ProfileInfoItem( - text = stringResource(id = R.string.core_faq), - external = true, - ) { - uriHandler.openUri(uiState.configuration.faqUrl) - onAction(ProfileViewAction.FaqClick) - } - Divider( - modifier = Modifier.padding(vertical = 4.dp), - color = MaterialTheme.appColors.divider - ) - } - AppVersionItem( - versionName = uiState.configuration.versionName, - appUpgradeEvent = appUpgradeEvent, - ) { - onAction(ProfileViewAction.AppVersionClick) - } - } - } - } -} - -@Composable -private fun LogoutButton(onClick: () -> Unit) { - Card( - modifier = Modifier - .testTag("btn_logout") - .fillMaxWidth() - .clickable { - onClick() - }, - shape = MaterialTheme.appShapes.cardShape, - elevation = 0.dp, - backgroundColor = MaterialTheme.appColors.cardViewBackground - ) { - Row( - modifier = Modifier.padding(20.dp), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - modifier = Modifier.testTag("txt_logout"), - text = stringResource(id = org.openedx.profile.R.string.profile_logout), - style = MaterialTheme.appTypography.titleMedium, - color = MaterialTheme.appColors.error - ) - Icon( - imageVector = Icons.AutoMirrored.Filled.ExitToApp, - contentDescription = null, - tint = MaterialTheme.appColors.error - ) - } - } -} - -@OptIn(ExperimentalComposeUiApi::class) -@Composable -private fun LogoutDialog( - onDismissRequest: () -> Unit, - onLogoutClick: () -> Unit, -) { - Dialog( - onDismissRequest = onDismissRequest, - content = { - Column( - Modifier - .verticalScroll(rememberScrollState()) - .fillMaxWidth() - .background( - MaterialTheme.appColors.background, - MaterialTheme.appShapes.cardShape - ) - .clip(MaterialTheme.appShapes.cardShape) - .border( - 1.dp, - MaterialTheme.appColors.cardViewBorder, - MaterialTheme.appShapes.cardShape - ) - .padding(horizontal = 40.dp, vertical = 36.dp) - .semantics { testTagsAsResourceId = true }, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.CenterEnd - ) { - IconButton( - modifier = Modifier - .testTag("ib_close") - .size(24.dp), - onClick = onDismissRequest - ) { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = stringResource(id = R.string.core_cancel), - tint = MaterialTheme.appColors.primary - ) - } - } - Icon( - modifier = Modifier - .width(88.dp) - .height(85.dp), - painter = painterResource(org.openedx.profile.R.drawable.profile_ic_exit), - contentDescription = null, - tint = MaterialTheme.appColors.onBackground - ) - Spacer(Modifier.size(36.dp)) - Text( - modifier = Modifier.testTag("txt_logout_dialog_title"), - text = stringResource(id = org.openedx.profile.R.string.profile_logout_dialog_body), - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.titleLarge, - textAlign = TextAlign.Center - ) - Spacer(Modifier.size(36.dp)) - OpenEdXButton( - text = stringResource(id = org.openedx.profile.R.string.profile_logout), - backgroundColor = MaterialTheme.appColors.warning, - onClick = onLogoutClick, - content = { - Box( - Modifier - .testTag("btn_logout") - .fillMaxWidth(), - contentAlignment = Alignment.CenterEnd - ) { - Text( - modifier = Modifier - .testTag("txt_logout") - .fillMaxWidth(), - text = stringResource(id = org.openedx.profile.R.string.profile_logout), - color = MaterialTheme.appColors.textWarning, - style = MaterialTheme.appTypography.labelLarge, - textAlign = TextAlign.Center - ) - Icon( - modifier = Modifier - .testTag("ic_logout"), - painter = painterResource(id = org.openedx.profile.R.drawable.profile_ic_logout), - contentDescription = null, - tint = Color.Black - ) - } - } - ) - } - } - ) -} - -@Composable -private fun ProfileInfoItem( - text: String, - external: Boolean = false, - onClick: () -> Unit -) { - val icon = if (external) { - Icons.AutoMirrored.Filled.OpenInNew - } else { - Icons.AutoMirrored.Filled.ArrowForwardIos - } - Row( - Modifier - .testTag("btn_${text.tagId()}") - .fillMaxWidth() - .clickable { onClick() } - .padding(20.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - modifier = Modifier - .testTag("txt_${text.tagId()}") - .weight(1f), - text = text, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.appTypography.titleMedium, - color = MaterialTheme.appColors.textPrimary - ) - Icon( - modifier = Modifier.size(16.dp), - imageVector = icon, - contentDescription = null - ) - } -} - -@Composable -private fun AppVersionItem( - versionName: String, - appUpgradeEvent: AppUpgradeEvent?, - onClick: () -> Unit -) { - Box(modifier = Modifier.padding(20.dp)) { - when (appUpgradeEvent) { - is AppUpgradeEvent.UpgradeRecommendedEvent -> { - AppVersionItemUpgradeRecommended( - versionName = versionName, - appUpgradeEvent = appUpgradeEvent, - onClick = onClick - ) - } - - is AppUpgradeEvent.UpgradeRequiredEvent -> { - AppVersionItemUpgradeRequired( - versionName = versionName, - onClick = onClick - ) - } - - else -> { - AppVersionItemAppToDate( - versionName = versionName - ) - } - } - } -} - -@Composable -private fun AppVersionItemAppToDate(versionName: String) { - Column( - Modifier - .fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Text( - modifier = Modifier.testTag("txt_app_version_code"), - text = stringResource(id = R.string.core_version, versionName), - style = MaterialTheme.appTypography.titleMedium, - color = MaterialTheme.appColors.textPrimary - ) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - Icon( - modifier = Modifier.size( - (MaterialTheme.appTypography.labelLarge.fontSize.value + 4).dp - ), - painter = painterResource(id = R.drawable.core_ic_check), - contentDescription = null, - tint = MaterialTheme.appColors.accessGreen - ) - Text( - modifier = Modifier.testTag("txt_up_to_date"), - text = stringResource(id = R.string.core_up_to_date), - color = MaterialTheme.appColors.textSecondary, - style = MaterialTheme.appTypography.labelLarge - ) - } - } -} - -@Composable -private fun AppVersionItemUpgradeRecommended( - versionName: String, - appUpgradeEvent: AppUpgradeEvent.UpgradeRecommendedEvent, - onClick: () -> Unit -) { - Row( - modifier = Modifier - .testTag("btn_upgrade_recommended") - .fillMaxWidth() - .clickable { - onClick() - }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Column( - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Text( - modifier = Modifier.testTag("txt_app_version_code"), - text = stringResource(id = R.string.core_version, versionName), - style = MaterialTheme.appTypography.titleMedium, - color = MaterialTheme.appColors.textPrimary - ) - Text( - modifier = Modifier.testTag("txt_upgrade_recommended"), - text = stringResource( - id = R.string.core_tap_to_update_to_version, - appUpgradeEvent.newVersionName - ), - color = MaterialTheme.appColors.textAccent, - style = MaterialTheme.appTypography.labelLarge - ) - } - Icon( - modifier = Modifier.size(28.dp), - painter = painterResource(id = R.drawable.core_ic_icon_upgrade), - tint = MaterialTheme.appColors.primary, - contentDescription = null - ) - } -} - -@Composable -fun AppVersionItemUpgradeRequired( - versionName: String, - onClick: () -> Unit -) { - Row( - modifier = Modifier - .testTag("btn_upgrade_required") - .fillMaxWidth() - .clickable { - onClick() - }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Column( - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - Image( - modifier = Modifier - .size((MaterialTheme.appTypography.labelLarge.fontSize.value + 8).dp), - painter = painterResource(id = R.drawable.core_ic_warning), - contentDescription = null - ) - Text( - modifier = Modifier.testTag("txt_app_version_code"), - text = stringResource(id = R.string.core_version, versionName), - style = MaterialTheme.appTypography.titleMedium, - color = MaterialTheme.appColors.textPrimary - ) - } - Text( - modifier = Modifier.testTag("txt_upgrade_required"), - text = stringResource(id = R.string.core_tap_to_install_required_app_update), - color = MaterialTheme.appColors.textAccent, - style = MaterialTheme.appTypography.labelLarge - ) - } - Icon( - modifier = Modifier.size(28.dp), - painter = painterResource(id = R.drawable.core_ic_icon_upgrade), - tint = MaterialTheme.appColors.primary, - contentDescription = null - ) - } -} - -@Preview -@Composable -fun AppVersionItemAppToDatePreview() { - OpenEdXTheme { - AppVersionItem( - versionName = mockAppData.versionName, - appUpgradeEvent = null, - onClick = {} - ) - } -} - -@Preview -@Composable -fun AppVersionItemUpgradeRecommendedPreview() { - OpenEdXTheme { - AppVersionItem( - versionName = mockAppData.versionName, - appUpgradeEvent = AppUpgradeEvent.UpgradeRecommendedEvent("1.0.1"), - onClick = {} - ) - } -} - -@Preview -@Composable -fun AppVersionItemUpgradeRequiredPreview() { - OpenEdXTheme { - AppVersionItem( - versionName = mockAppData.versionName, - appUpgradeEvent = AppUpgradeEvent.UpgradeRequiredEvent, - onClick = {} - ) - } -} - -@Preview -@Composable -fun LogoutDialogPreview() { - LogoutDialog({}, {}) -} - @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) @@ -757,7 +181,7 @@ private fun ProfileScreenPreview() { uiMessage = null, refreshing = false, onAction = {}, - appUpgradeEvent = null, + onSettingsClick = {}, ) } } @@ -774,56 +198,16 @@ private fun ProfileScreenTabletPreview() { uiMessage = null, refreshing = false, onAction = {}, - appUpgradeEvent = null, + onSettingsClick = {}, ) } } -private val mockAppData = AppData( - versionName = "1.0.0", -) - -val mockAccount = Account( - username = "thom84", - bio = "He as compliment unreserved projecting. Between had observe pretend delight for believe. Do newspaper questions consulted sweetness do. Our sportsman his unwilling fulfilled departure law.", - requiresParentalConsent = true, - name = "Thomas", - country = "Ukraine", - isActive = true, - profileImage = ProfileImage("", "", "", "", false), - yearOfBirth = 2000, - levelOfEducation = "Bachelor", - goals = "130", - languageProficiencies = emptyList(), - gender = "male", - mailingAddress = "", - "", - null, - accountPrivacy = Account.Privacy.ALL_USERS -) - -private val mockConfiguration = AppConfiguration( - agreementUrls = AgreementUrls(), - faqUrl = "https://example.com/faq", - supportEmail = "test@example.com", - versionName = mockAppData.versionName, -) - private val mockUiState = ProfileUIState.Data( - account = mockAccount, - configuration = mockConfiguration, + account = mockAccount ) internal interface ProfileViewAction { - object AppVersionClick : ProfileViewAction object EditAccountClick : ProfileViewAction - object LogoutClick : ProfileViewAction - object PrivacyPolicyClick : ProfileViewAction - object CookiePolicyClick : ProfileViewAction - object DataSellClick : ProfileViewAction - object FaqClick : ProfileViewAction - object TermsClick : ProfileViewAction - object SupportClick : ProfileViewAction - object VideoSettingsClick : ProfileViewAction object SwipeRefresh : ProfileViewAction } 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 new file mode 100644 index 000000000..fbdd0b4af --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsFragment.kt @@ -0,0 +1,117 @@ +package org.openedx.profile.presentation.settings + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.Fragment +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.openedx.core.ui.rememberWindowSize +import org.openedx.core.ui.theme.OpenEdXTheme + +class SettingsFragment : Fragment() { + + private val viewModel by viewModel() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val windowSize = rememberWindowSize() + val uiState by viewModel.uiState.collectAsState() + val logoutSuccess by viewModel.successLogout.collectAsState(false) + val appUpgradeEvent by viewModel.appUpgradeEvent.collectAsState(null) + + SettingsScreen( + windowSize = windowSize, + uiState = uiState, + appUpgradeEvent = appUpgradeEvent, + onBackClick = { + requireActivity().supportFragmentManager.popBackStack() + }, + onAction = { action -> + when (action) { + SettingsScreenAction.AppVersionClick -> { + viewModel.appVersionClickedEvent(requireContext()) + } + + SettingsScreenAction.LogoutClick -> { + viewModel.logout() + } + + SettingsScreenAction.PrivacyPolicyClick -> { + viewModel.privacyPolicyClicked( + requireActivity().supportFragmentManager + ) + } + + SettingsScreenAction.CookiePolicyClick -> { + viewModel.cookiePolicyClicked( + requireActivity().supportFragmentManager + ) + } + + SettingsScreenAction.DataSellClick -> { + viewModel.dataSellClicked( + requireActivity().supportFragmentManager + ) + } + + SettingsScreenAction.FaqClick -> viewModel.faqClicked() + + SettingsScreenAction.SupportClick -> { + viewModel.emailSupportClicked(requireContext()) + } + + SettingsScreenAction.TermsClick -> { + viewModel.termsOfUseClicked( + requireActivity().supportFragmentManager + ) + } + + SettingsScreenAction.VideoSettingsClick -> { + viewModel.videoSettingsClicked( + requireActivity().supportFragmentManager + ) + } + + SettingsScreenAction.ManageAccount -> { + viewModel.manageAccountClicked( + requireActivity().supportFragmentManager + ) + } + } + } + ) + + LaunchedEffect(logoutSuccess) { + if (logoutSuccess) { + viewModel.restartApp(requireActivity().supportFragmentManager) + } + } + } + } + } +} + +internal interface SettingsScreenAction { + object AppVersionClick : SettingsScreenAction + object LogoutClick : SettingsScreenAction + object PrivacyPolicyClick : SettingsScreenAction + object CookiePolicyClick : SettingsScreenAction + object DataSellClick : SettingsScreenAction + object FaqClick : SettingsScreenAction + object TermsClick : SettingsScreenAction + object SupportClick : SettingsScreenAction + object VideoSettingsClick : SettingsScreenAction + object ManageAccount : 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 new file mode 100644 index 000000000..f5c0a7bc5 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt @@ -0,0 +1,701 @@ +package org.openedx.profile.presentation.settings + +import androidx.compose.foundation.Image +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.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.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 +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.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.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalUriHandler +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.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +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.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.domain.model.Configuration +import org.openedx.profile.presentation.ui.SettingsItem +import org.openedx.profile.R as profileR + +@Composable +internal fun SettingsScreen( + windowSize: WindowSize, + uiState: SettingsUIState, + appUpgradeEvent: AppUpgradeEvent?, + onBackClick: () -> Unit, + onAction: (SettingsScreenAction) -> Unit, +) { + var showLogoutDialog by rememberSaveable { mutableStateOf(false) } + + 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() + ) + ) + } + + Column( + modifier = Modifier + .fillMaxSize() + .settingsHeaderBackground() + .statusBarsInset() + ) { + Toolbar( + modifier = topBarWidth + .align(Alignment.CenterHorizontally) + .displayCutoutForLandscape(), + label = stringResource(id = R.string.core_settings), + canShowBackBtn = true, + labelTint = MaterialTheme.appColors.settingsTitleContent, + iconTint = MaterialTheme.appColors.settingsTitleContent, + onBackClick = onBackClick + ) + + if (showLogoutDialog) { + LogoutDialog( + onDismissRequest = { + showLogoutDialog = false + }, + onLogoutClick = { + showLogoutDialog = false + onAction(SettingsScreenAction.LogoutClick) + } + ) + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Surface( + modifier = Modifier + .fillMaxSize(), + shape = MaterialTheme.appShapes.screenBackgroundShape, + color = MaterialTheme.appColors.background + ) { + Box( + contentAlignment = Alignment.TopCenter + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .displayCutoutForLandscape(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + when (uiState) { + is SettingsUIState.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } + } + + is SettingsUIState.Data -> { + Column( + Modifier + .fillMaxHeight() + .then(contentWidth) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(Modifier.height(30.dp)) + + ManageAccountSection(onManageAccountClick = { + onAction(SettingsScreenAction.ManageAccount) + }) + + Spacer(modifier = Modifier.height(24.dp)) + + SettingsSection(onVideoSettingsClick = { + onAction(SettingsScreenAction.VideoSettingsClick) + }) + + Spacer(modifier = Modifier.height(24.dp)) + + SupportInfoSection( + uiState = uiState, + onAction = onAction, + appUpgradeEvent = appUpgradeEvent, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + LogoutButton( + onClick = { showLogoutDialog = true } + ) + + Spacer(Modifier.height(30.dp)) + } + } + } + } + } + } + } + } +} + +@Composable +private fun SettingsSection(onVideoSettingsClick: () -> Unit) { + Column { + Text( + modifier = Modifier.testTag("txt_settings"), + text = stringResource(id = R.string.core_settings), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textSecondary + ) + Spacer(modifier = Modifier.height(14.dp)) + Card( + modifier = Modifier, + shape = MaterialTheme.appShapes.cardShape, + elevation = 0.dp, + backgroundColor = MaterialTheme.appColors.cardViewBackground + ) { + Column(Modifier.fillMaxWidth()) { + SettingsItem( + text = stringResource(id = profileR.string.profile_video), + onClick = onVideoSettingsClick + ) + } + } + } +} + +@Composable +private fun ManageAccountSection(onManageAccountClick: () -> Unit) { + Column { + Card( + shape = MaterialTheme.appShapes.cardShape, + elevation = 0.dp, + backgroundColor = MaterialTheme.appColors.cardViewBackground + ) { + Column(Modifier.fillMaxWidth()) { + SettingsItem( + text = stringResource(id = R.string.core_manage_account), + onClick = onManageAccountClick + ) + } + } + } +} + +@Composable +private fun SupportInfoSection( + uiState: SettingsUIState.Data, + appUpgradeEvent: AppUpgradeEvent?, + onAction: (SettingsScreenAction) -> Unit +) { + Column { + Text( + modifier = Modifier.testTag("txt_support_info"), + text = stringResource(id = profileR.string.profile_support_info), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textSecondary + ) + Spacer(modifier = Modifier.height(14.dp)) + Card( + modifier = Modifier, + shape = MaterialTheme.appShapes.cardShape, + elevation = 0.dp, + backgroundColor = MaterialTheme.appColors.cardViewBackground + ) { + Column(Modifier.fillMaxWidth()) { + if (uiState.configuration.supportEmail.isNotBlank()) { + SettingsItem(text = stringResource(id = profileR.string.profile_contact_support)) { + onAction(SettingsScreenAction.SupportClick) + } + Divider( + modifier = Modifier.padding(vertical = 4.dp), + color = MaterialTheme.appColors.divider + ) + } + 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 + ) + } + 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 + ) + } + 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 + ) + } + 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 + ) + } + if (uiState.configuration.faqUrl.isNotBlank()) { + val uriHandler = LocalUriHandler.current + SettingsItem( + text = stringResource(id = R.string.core_faq), + external = true, + ) { + uriHandler.openUri(uiState.configuration.faqUrl) + onAction(SettingsScreenAction.FaqClick) + } + Divider( + modifier = Modifier.padding(vertical = 4.dp), + color = MaterialTheme.appColors.divider + ) + } + AppVersionItem( + versionName = uiState.configuration.versionName, + appUpgradeEvent = appUpgradeEvent, + ) { + onAction(SettingsScreenAction.AppVersionClick) + } + } + } + } +} + +@Composable +private fun LogoutButton(onClick: () -> Unit) { + Card( + modifier = Modifier + .testTag("btn_logout") + .fillMaxWidth() + .clickable { + onClick() + }, + shape = MaterialTheme.appShapes.cardShape, + elevation = 0.dp, + backgroundColor = MaterialTheme.appColors.cardViewBackground + ) { + Row( + modifier = Modifier.padding(20.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + modifier = Modifier.testTag("txt_logout"), + text = stringResource(id = profileR.string.profile_logout), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.error + ) + Icon( + painterResource(id = profileR.drawable.profile_ic_logout), + contentDescription = null, + tint = MaterialTheme.appColors.error + ) + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun LogoutDialog( + onDismissRequest: () -> Unit, + onLogoutClick: () -> Unit, +) { + Dialog( + onDismissRequest = onDismissRequest, + content = { + Column( + Modifier + .verticalScroll(rememberScrollState()) + .fillMaxWidth() + .background( + MaterialTheme.appColors.background, + MaterialTheme.appShapes.cardShape + ) + .clip(MaterialTheme.appShapes.cardShape) + .border( + 1.dp, + MaterialTheme.appColors.cardViewBorder, + MaterialTheme.appShapes.cardShape + ) + .padding(horizontal = 40.dp, vertical = 36.dp) + .semantics { testTagsAsResourceId = true }, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.CenterEnd + ) { + IconButton( + modifier = Modifier + .testTag("ib_close") + .size(24.dp), + onClick = onDismissRequest + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(id = R.string.core_cancel), + tint = MaterialTheme.appColors.primary + ) + } + } + Icon( + modifier = Modifier + .width(88.dp) + .height(85.dp), + painter = painterResource(profileR.drawable.profile_ic_exit), + contentDescription = null, + tint = MaterialTheme.appColors.onBackground + ) + Spacer(Modifier.size(36.dp)) + Text( + modifier = Modifier.testTag("txt_logout_dialog_title"), + text = stringResource(id = profileR.string.profile_logout_dialog_body), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleLarge, + textAlign = TextAlign.Center + ) + Spacer(Modifier.size(36.dp)) + OpenEdXButton( + text = stringResource(id = profileR.string.profile_logout), + backgroundColor = MaterialTheme.appColors.warning, + onClick = onLogoutClick, + content = { + Box( + Modifier + .testTag("btn_logout") + .fillMaxWidth(), + contentAlignment = Alignment.CenterEnd + ) { + Text( + modifier = Modifier + .testTag("txt_logout") + .fillMaxWidth(), + text = stringResource(id = profileR.string.profile_logout), + color = MaterialTheme.appColors.textWarning, + style = MaterialTheme.appTypography.labelLarge, + textAlign = TextAlign.Center + ) + Icon( + modifier = Modifier + .testTag("ic_logout"), + painter = painterResource(id = profileR.drawable.profile_ic_logout), + contentDescription = null, + tint = Color.Black + ) + } + } + ) + } + } + ) +} + +@Composable +private fun AppVersionItem( + versionName: String, + appUpgradeEvent: AppUpgradeEvent?, + onClick: () -> Unit +) { + Box(modifier = Modifier.padding(20.dp)) { + when (appUpgradeEvent) { + is AppUpgradeEvent.UpgradeRecommendedEvent -> { + AppVersionItemUpgradeRecommended( + versionName = versionName, + appUpgradeEvent = appUpgradeEvent, + onClick = onClick + ) + } + + is AppUpgradeEvent.UpgradeRequiredEvent -> { + AppVersionItemUpgradeRequired( + versionName = versionName, + onClick = onClick + ) + } + + else -> { + AppVersionItemAppToDate( + versionName = versionName + ) + } + } + } +} + +@Composable +private fun AppVersionItemAppToDate(versionName: String) { + Column( + Modifier + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + modifier = Modifier.testTag("txt_app_version_code"), + text = stringResource(id = R.string.core_version, versionName), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textPrimary + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + modifier = Modifier.size( + (MaterialTheme.appTypography.labelLarge.fontSize.value + 4).dp + ), + painter = painterResource(id = R.drawable.core_ic_check), + contentDescription = null, + tint = MaterialTheme.appColors.accessGreen + ) + Text( + modifier = Modifier.testTag("txt_up_to_date"), + text = stringResource(id = R.string.core_up_to_date), + color = MaterialTheme.appColors.textSecondary, + style = MaterialTheme.appTypography.labelLarge + ) + } + } +} + +@Composable +private fun AppVersionItemUpgradeRecommended( + versionName: String, + appUpgradeEvent: AppUpgradeEvent.UpgradeRecommendedEvent, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .testTag("btn_upgrade_recommended") + .fillMaxWidth() + .clickable { + onClick() + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + modifier = Modifier.testTag("txt_app_version_code"), + text = stringResource(id = R.string.core_version, versionName), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textPrimary + ) + Text( + modifier = Modifier.testTag("txt_upgrade_recommended"), + text = stringResource( + id = R.string.core_tap_to_update_to_version, + appUpgradeEvent.newVersionName + ), + color = MaterialTheme.appColors.textAccent, + style = MaterialTheme.appTypography.labelLarge + ) + } + Icon( + modifier = Modifier.size(28.dp), + painter = painterResource(id = R.drawable.core_ic_icon_upgrade), + tint = MaterialTheme.appColors.primary, + contentDescription = null + ) + } +} + +@Composable +fun AppVersionItemUpgradeRequired( + versionName: String, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .testTag("btn_upgrade_required") + .fillMaxWidth() + .clickable { + onClick() + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Image( + modifier = Modifier + .size((MaterialTheme.appTypography.labelLarge.fontSize.value + 8).dp), + painter = painterResource(id = R.drawable.core_ic_warning), + contentDescription = null + ) + Text( + modifier = Modifier.testTag("txt_app_version_code"), + text = stringResource(id = R.string.core_version, versionName), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textPrimary + ) + } + Text( + modifier = Modifier.testTag("txt_upgrade_required"), + text = stringResource(id = R.string.core_tap_to_install_required_app_update), + color = MaterialTheme.appColors.textAccent, + style = MaterialTheme.appTypography.labelLarge + ) + } + Icon( + modifier = Modifier.size(28.dp), + painter = painterResource(id = R.drawable.core_ic_icon_upgrade), + tint = MaterialTheme.appColors.primary, + contentDescription = null + ) + } +} + +private val mockAppData = AppData( + versionName = "1.0.0", +) + +private val mockConfiguration = Configuration( + agreementUrls = AgreementUrls(), + faqUrl = "https://example.com/faq", + supportEmail = "test@example.com", + versionName = mockAppData.versionName, +) + +private val mockUiState = SettingsUIState.Data( + configuration = mockConfiguration +) + + +@Preview +@Composable +private fun AppVersionItemAppToDatePreview() { + OpenEdXTheme { + AppVersionItem( + versionName = mockAppData.versionName, + appUpgradeEvent = null, + onClick = {} + ) + } +} + +@Preview +@Composable +private fun AppVersionItemUpgradeRecommendedPreview() { + OpenEdXTheme { + AppVersionItem( + versionName = mockAppData.versionName, + appUpgradeEvent = AppUpgradeEvent.UpgradeRecommendedEvent("1.0.1"), + onClick = {} + ) + } +} + +@Preview +@Composable +private fun AppVersionItemUpgradeRequiredPreview() { + OpenEdXTheme { + AppVersionItem( + versionName = mockAppData.versionName, + appUpgradeEvent = AppUpgradeEvent.UpgradeRequiredEvent, + onClick = {} + ) + } +} + +@Preview +@Composable +private fun LogoutDialogPreview() { + LogoutDialog({}, {}) +} + +@Preview +@Composable +private fun SettingsScreenPreview() { + OpenEdXTheme { + SettingsScreen( + onBackClick = {}, + windowSize = WindowSize(WindowType.Medium, WindowType.Medium), + uiState = mockUiState, + onAction = {}, + appUpgradeEvent = null, + ) + } +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsUIState.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsUIState.kt new file mode 100644 index 000000000..f2b736d45 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsUIState.kt @@ -0,0 +1,11 @@ +package org.openedx.profile.presentation.settings + +import org.openedx.profile.domain.model.Configuration + +sealed class SettingsUIState { + data class Data( + val configuration: Configuration, + ) : SettingsUIState() + + object Loading : SettingsUIState() +} 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 new file mode 100644 index 000000000..2c7471ebd --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt @@ -0,0 +1,208 @@ +package org.openedx.profile.presentation.settings + +import android.content.Context +import androidx.compose.ui.text.intl.Locale +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.openedx.core.AppUpdateState +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.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.utils.EmailUtil +import org.openedx.profile.domain.interactor.ProfileInteractor +import org.openedx.profile.domain.model.Configuration +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 + +class SettingsViewModel( + private val appData: AppData, + private val config: Config, + private val resourceManager: ResourceManager, + private val interactor: ProfileInteractor, + private val cookieManager: AppCookieManager, + private val workerController: DownloadWorkerController, + private val analytics: ProfileAnalytics, + private val router: ProfileRouter, + private val appUpgradeNotifier: AppUpgradeNotifier, + private val profileNotifier: ProfileNotifier, +) : BaseViewModel() { + + private val _uiState: MutableStateFlow = MutableStateFlow(SettingsUIState.Data(configuration)) + internal val uiState: StateFlow = _uiState.asStateFlow() + + private val _successLogout = MutableSharedFlow() + val successLogout: SharedFlow + get() = _successLogout.asSharedFlow() + + private val _uiMessage = MutableSharedFlow() + val uiMessage: SharedFlow + get() = _uiMessage.asSharedFlow() + + private val _appUpgradeEvent = MutableStateFlow(null) + val appUpgradeEvent: StateFlow + get() = _appUpgradeEvent.asStateFlow() + + val isLogistrationEnabled get() = config.isPreLoginExperienceEnabled() + + private val configuration + get() = Configuration( + agreementUrls = config.getAgreement(Locale.current.language), + faqUrl = config.getFaqUrl(), + supportEmail = config.getFeedbackEmailAddress(), + versionName = appData.versionName, + ) + + init { + collectAppUpgradeEvent() + collectProfileEvent() + } + + fun logout() { + logProfileEvent(ProfileAnalyticsEvent.LOGOUT_CLICKED) + viewModelScope.launch { + try { + workerController.removeModels() + withContext(Dispatchers.IO) { + interactor.logout() + } + logProfileEvent( + event = ProfileAnalyticsEvent.LOGGED_OUT, + params = buildMap { + put(ProfileAnalyticsKey.FORCE.key, ProfileAnalyticsKey.FALSE.key) + } + ) + } 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 { + cookieManager.clearWebViewCookie() + _successLogout.emit(true) + } + } + } + + private fun collectAppUpgradeEvent() { + viewModelScope.launch { + appUpgradeNotifier.notifier.collect { event -> + _appUpgradeEvent.value = event + } + } + } + + private fun collectProfileEvent() { + viewModelScope.launch { + profileNotifier.notifier.collect { + if (it is AccountDeactivated) { + logout() + } + } + } + } + + fun videoSettingsClicked(fragmentManager: FragmentManager) { + router.navigateToVideoSettings(fragmentManager) + logProfileEvent(ProfileAnalyticsEvent.VIDEO_SETTING_CLICKED) + } + + fun privacyPolicyClicked(fragmentManager: FragmentManager) { + router.navigateToWebContent( + fm = fragmentManager, + title = resourceManager.getString(R.string.core_privacy_policy), + url = configuration.agreementUrls.privacyPolicyUrl, + ) + logProfileEvent(ProfileAnalyticsEvent.PRIVACY_POLICY_CLICKED) + } + + fun cookiePolicyClicked(fragmentManager: FragmentManager) { + router.navigateToWebContent( + fm = fragmentManager, + title = resourceManager.getString(R.string.core_cookie_policy), + url = configuration.agreementUrls.cookiePolicyUrl, + ) + logProfileEvent(ProfileAnalyticsEvent.COOKIE_POLICY_CLICKED) + } + + fun dataSellClicked(fragmentManager: FragmentManager) { + router.navigateToWebContent( + fm = fragmentManager, + title = resourceManager.getString(R.string.core_data_sell), + url = configuration.agreementUrls.dataSellConsentUrl, + ) + logProfileEvent(ProfileAnalyticsEvent.DATA_SELL_CLICKED) + } + + fun faqClicked() { + logProfileEvent(ProfileAnalyticsEvent.FAQ_CLICKED) + } + + fun termsOfUseClicked(fragmentManager: FragmentManager) { + router.navigateToWebContent( + fm = fragmentManager, + title = resourceManager.getString(R.string.core_terms_of_use), + url = configuration.agreementUrls.tosUrl, + ) + logProfileEvent(ProfileAnalyticsEvent.TERMS_OF_USE_CLICKED) + } + + fun emailSupportClicked(context: Context) { + EmailUtil.showFeedbackScreen( + context = context, + feedbackEmailAddress = config.getFeedbackEmailAddress(), + appVersion = appData.versionName + ) + logProfileEvent(ProfileAnalyticsEvent.CONTACT_SUPPORT_CLICKED) + } + + fun appVersionClickedEvent(context: Context) { + AppUpdateState.openPlayMarket(context) + } + + fun manageAccountClicked(fragmentManager: FragmentManager) { + router.navigateToManageAccount(fragmentManager) + } + + fun restartApp(fragmentManager: FragmentManager) { + router.restartApp( + fragmentManager, + isLogistrationEnabled + ) + } + + private fun logProfileEvent( + event: ProfileAnalyticsEvent, + params: Map = emptyMap(), + ) { + analytics.logEvent( + event = event.eventName, + params = buildMap { + put(ProfileAnalyticsKey.NAME.key, event.biValue) + put(ProfileAnalyticsKey.CATEGORY.key, ProfileAnalyticsKey.PROFILE.key) + putAll(params) + } + ) + } +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoSettingsFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoSettingsFragment.kt deleted file mode 100644 index 19a19245f..000000000 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoSettingsFragment.kt +++ /dev/null @@ -1,301 +0,0 @@ -package org.openedx.profile.presentation.settings.video - -import android.content.res.Configuration.UI_MODE_NIGHT_NO -import android.content.res.Configuration.UI_MODE_NIGHT_YES -import android.os.Bundle -import android.view.LayoutInflater -import android.view.ViewGroup -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.widthIn -import androidx.compose.material.Divider -import androidx.compose.material.Icon -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.icons.Icons -import androidx.compose.material.icons.filled.ChevronRight -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 -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.tooling.preview.Preview -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.fragment.app.Fragment -import org.koin.android.ext.android.inject -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.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.profile.R -import org.openedx.profile.presentation.ProfileRouter - -class VideoSettingsFragment : Fragment() { - - private val viewModel by viewModel() - private val router by inject() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - lifecycle.addObserver(viewModel) - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ) = ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - OpenEdXTheme { - val windowSize = rememberWindowSize() - - val videoSettings by viewModel.videoSettings.observeAsState(viewModel.currentSettings) - - VideoSettingsScreen( - videoSettings = videoSettings, - windowSize = windowSize, - onBackClick = { - requireActivity().supportFragmentManager.popBackStack() - }, - wifiDownloadChanged = { - viewModel.setWifiDownloadOnly(it) - }, - videoStreamingQualityClick = { - viewModel.navigateToVideoStreamingQuality(requireActivity().supportFragmentManager) - }, - videoDownloadQualityClick = { - viewModel.navigateToVideoDownloadQuality(requireActivity().supportFragmentManager) - } - ) - } - } - } - -} - -@OptIn(ExperimentalComposeUiApi::class) -@Composable -private fun VideoSettingsScreen( - windowSize: WindowSize, - videoSettings: VideoSettings, - wifiDownloadChanged: (Boolean) -> Unit, - videoStreamingQualityClick: () -> Unit, - videoDownloadQualityClick: () -> Unit, - onBackClick: () -> Unit, -) { - val scaffoldState = rememberScaffoldState() - - var wifiDownloadOnly by rememberSaveable { - mutableStateOf(videoSettings.wifiDownloadOnly) - } - - 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 - .padding(paddingValues) - .statusBarsInset() - .displayCutoutForLandscape(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Toolbar( - modifier = topBarWidth, - label = stringResource(id = org.openedx.profile.R.string.profile_video_settings), - canShowBackBtn = true, - onBackClick = onBackClick - ) - - Column( - modifier = Modifier.then(contentWidth), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Row( - Modifier - .testTag("btn_wifi_only") - .fillMaxWidth() - .height(92.dp) - .noRippleClickable { - wifiDownloadOnly = !wifiDownloadOnly - wifiDownloadChanged(wifiDownloadOnly) - }, - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(Modifier.weight(1f)) { - Text( - modifier = Modifier.testTag("txt_wifi_only_label"), - text = stringResource(id = R.string.profile_wifi_only_download), - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.titleMedium - ) - Spacer(Modifier.height(4.dp)) - Text( - modifier = Modifier.testTag("txt_wifi_only_description"), - text = stringResource(id = R.string.profile_only_download_when_wifi_turned_on), - color = MaterialTheme.appColors.textSecondary, - style = MaterialTheme.appTypography.labelMedium - ) - } - Switch( - modifier = Modifier.testTag("sw_wifi_only"), - checked = wifiDownloadOnly, - onCheckedChange = { - wifiDownloadOnly = !wifiDownloadOnly - wifiDownloadChanged(wifiDownloadOnly) - }, - colors = SwitchDefaults.colors( - checkedThumbColor = MaterialTheme.appColors.primary, - checkedTrackColor = MaterialTheme.appColors.primary - ) - ) - } - Divider() - Row( - Modifier - .fillMaxWidth() - .height(92.dp) - .clickable { - videoStreamingQualityClick() - }, - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(Modifier.weight(1f)) { - Text( - text = stringResource(id = org.openedx.core.R.string.core_video_streaming_quality), - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.titleMedium - ) - Spacer(Modifier.height(4.dp)) - Text( - text = stringResource(id = videoSettings.videoStreamingQuality.titleResId), - color = MaterialTheme.appColors.textSecondary, - style = MaterialTheme.appTypography.labelMedium - ) - } - Icon( - imageVector = Icons.Filled.ChevronRight, - tint = MaterialTheme.appColors.onSurface, - contentDescription = "Expandable Arrow" - ) - } - Divider() - Row( - Modifier - .testTag("btn_video_quality") - .fillMaxWidth() - .height(92.dp) - .clickable { - videoDownloadQualityClick() - }, - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(Modifier.weight(1f)) { - Text( - text = stringResource(id = org.openedx.core.R.string.core_video_download_quality), - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.titleMedium - ) - Spacer(Modifier.height(4.dp)) - Text( - text = stringResource(id = videoSettings.videoDownloadQuality.titleResId), - color = MaterialTheme.appColors.textSecondary, - style = MaterialTheme.appTypography.labelMedium - ) - } - Icon( - imageVector = Icons.Filled.ChevronRight, - tint = MaterialTheme.appColors.onSurface, - contentDescription = "Expandable Arrow" - ) - } - Divider() - } - } - } - } -} - -@Preview(uiMode = UI_MODE_NIGHT_NO) -@Preview(uiMode = UI_MODE_NIGHT_YES) -@Composable -private fun VideoSettingsScreenPreview() { - OpenEdXTheme { - VideoSettingsScreen( - windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - wifiDownloadChanged = {}, - videoStreamingQualityClick = {}, - videoDownloadQualityClick = {}, - onBackClick = {}, - videoSettings = VideoSettings.default - ) - } -} - diff --git a/profile/src/main/java/org/openedx/profile/presentation/ui/ProfileUI.kt b/profile/src/main/java/org/openedx/profile/presentation/ui/ProfileUI.kt index c47820f23..d0004256f 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/ui/ProfileUI.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/ui/ProfileUI.kt @@ -1,15 +1,14 @@ package org.openedx.profile.presentation.ui import android.content.res.Configuration -import androidx.compose.foundation.border 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.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.shape.CircleShape import androidx.compose.material.Card import androidx.compose.material.MaterialTheme @@ -21,82 +20,70 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString 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.R +import org.openedx.core.domain.model.ProfileImage +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.domain.model.Account -import org.openedx.profile.presentation.profile.compose.mockAccount +import org.openedx.profile.R as ProfileR @Composable -fun ProfileTopic(account: Account) { - Column( - Modifier.fillMaxHeight(), - horizontalAlignment = Alignment.CenterHorizontally +fun ProfileTopic(image: String, title: String, subtitle: String) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically ) { - val profileImage = if (account.profileImage.hasImage) { - account.profileImage.imageUrlFull - } else { - R.drawable.core_ic_default_profile_picture - } AsyncImage( model = ImageRequest.Builder(LocalContext.current) - .data(profileImage) + .data(image) .error(R.drawable.core_ic_default_profile_picture) .placeholder(R.drawable.core_ic_default_profile_picture) .build(), contentDescription = stringResource( id = R.string.core_accessibility_user_profile_image, - account.username + title ), modifier = Modifier .testTag("img_profile") - .border( - 2.dp, - MaterialTheme.appColors.onSurface, - CircleShape - ) - .padding(2.dp) - .size(100.dp) + .size(80.dp) .clip(CircleShape) ) - if (account.name.isNotEmpty()) { - Spacer(modifier = Modifier.height(20.dp)) + Spacer(modifier = Modifier.width(12.dp)) + Column( + modifier = Modifier.fillMaxWidth() + ) { + if (title.isNotEmpty()) { + Text( + modifier = Modifier + .testTag("txt_profile_name") + .fillMaxWidth(), + text = title, + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleLarge + ) + } Text( - modifier = Modifier.testTag("txt_profile_name"), - text = account.name, + modifier = Modifier + .testTag("txt_profile_username") + .fillMaxWidth(), + text = subtitle, color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.headlineSmall + style = MaterialTheme.appTypography.bodyMedium ) } - Spacer(modifier = Modifier.height(4.dp)) - Text( - modifier = Modifier.testTag("txt_profile_username"), - text = "@${account.username}", - color = MaterialTheme.appColors.textPrimaryVariant, - style = MaterialTheme.appTypography.labelLarge - ) } } @Composable fun ProfileInfoSection(account: Account) { - - if (account.yearOfBirth != null || account.bio.isNotEmpty()) { + if (account.bio.isNotEmpty()) { Column { - Text( - modifier = Modifier.testTag("txt_profile_info_label"), - text = stringResource(id = org.openedx.profile.R.string.profile_prof_info), - style = MaterialTheme.appTypography.labelLarge, - color = MaterialTheme.appColors.textSecondary - ) - Spacer(modifier = Modifier.height(14.dp)) Card( modifier = Modifier, shape = MaterialTheme.appShapes.cardShape, @@ -107,50 +94,19 @@ fun ProfileInfoSection(account: Account) { Modifier .fillMaxWidth() .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) + verticalArrangement = Arrangement.spacedBy(8.dp) ) { - if (account.yearOfBirth != null) { + if (account.bio.isNotEmpty()) { Text( - modifier = Modifier.testTag("txt_profile_year_of_birth"), - text = buildAnnotatedString { - val value = if (account.yearOfBirth != null) { - account.yearOfBirth.toString() - } else "" - val text = stringResource( - id = org.openedx.profile.R.string.profile_year_of_birth, - value - ) - append(text) - addStyle( - style = SpanStyle( - color = MaterialTheme.appColors.textPrimaryVariant - ), - start = 0, - end = text.length - value.length - ) - }, - style = MaterialTheme.appTypography.titleMedium, + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = ProfileR.string.profile_about_me), + style = MaterialTheme.appTypography.titleSmall, color = MaterialTheme.appColors.textPrimary ) - } - if (account.bio.isNotEmpty()) { Text( modifier = Modifier.testTag("txt_profile_bio"), - text = buildAnnotatedString { - val text = stringResource( - id = org.openedx.profile.R.string.profile_bio, - account.bio - ) - append(text) - addStyle( - style = SpanStyle( - color = MaterialTheme.appColors.textPrimaryVariant - ), - start = 0, - end = text.length - account.bio.length - ) - }, - style = MaterialTheme.appTypography.titleMedium, + text = account.bio, + style = MaterialTheme.appTypography.bodyMedium, color = MaterialTheme.appColors.textPrimary ) } @@ -160,20 +116,45 @@ fun ProfileInfoSection(account: Account) { } } -@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +val mockAccount = Account( + username = "thom84", + bio = "He as compliment unreserved projecting. Between had observe pretend delight for believe. Do newspaper questions consulted sweetness do. Our sportsman his unwilling fulfilled departure law.", + requiresParentalConsent = true, + name = "Thomas", + country = "Ukraine", + isActive = true, + profileImage = ProfileImage("", "", "", "", false), + yearOfBirth = 2000, + levelOfEducation = "Bachelor", + goals = "130", + languageProficiencies = emptyList(), + gender = "male", + mailingAddress = "", + "example@email.com", + null, + accountPrivacy = Account.Privacy.ALL_USERS +) + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, showBackground = true) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -fun ProfileTopicPreview() { - ProfileTopic( - account = mockAccount - ) +private fun ProfileTopicPreview() { + OpenEdXTheme { + ProfileTopic( + image = mockAccount.profileImage.imageUrlFull, + title = mockAccount.name, + subtitle = mockAccount.username, + ) + } } @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -fun ProfileInfoSectionPreview() { - ProfileInfoSection( - account = mockAccount - ) +private fun ProfileInfoSectionPreview() { + OpenEdXTheme { + ProfileInfoSection( + account = mockAccount + ) + } } 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 new file mode 100644 index 000000000..6960a0864 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/ui/SettingsUI.kt @@ -0,0 +1,61 @@ +package org.openedx.profile.presentation.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +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.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos +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.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 + +@Composable +fun SettingsItem( + text: String, + external: Boolean = false, + onClick: () -> Unit +) { + val icon = if (external) { + Icons.AutoMirrored.Filled.OpenInNew + } else { + Icons.AutoMirrored.Filled.ArrowForwardIos + } + Row( + Modifier + .testTag("btn_${text.tagId()}") + .fillMaxWidth() + .clickable { onClick() } + .padding(20.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier + .testTag("txt_${text.tagId()}") + .weight(1f), + text = text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textPrimary + ) + Icon( + modifier = Modifier.size(16.dp), + imageVector = icon, + contentDescription = null + ) + } +} 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 new file mode 100644 index 000000000..5de93fdad --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/video/VideoSettingsFragment.kt @@ -0,0 +1,315 @@ +package org.openedx.profile.presentation.video + +import android.content.res.Configuration.UI_MODE_NIGHT_NO +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.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.widthIn +import androidx.compose.material.Divider +import androidx.compose.material.Icon +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.icons.Icons +import androidx.compose.material.icons.filled.ChevronRight +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 +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.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.fragment.app.Fragment +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.openedx.core.domain.model.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.profile.R +import org.openedx.core.R as CoreR + +class VideoSettingsFragment : Fragment() { + + private val viewModel by viewModel() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + lifecycle.addObserver(viewModel) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val windowSize = rememberWindowSize() + + val videoSettings by viewModel.videoSettings.observeAsState(viewModel.currentSettings) + + VideoSettingsScreen( + videoSettings = videoSettings, + windowSize = windowSize, + onBackClick = { + requireActivity().supportFragmentManager.popBackStack() + }, + wifiDownloadChanged = { + viewModel.setWifiDownloadOnly(it) + }, + videoStreamingQualityClick = { + viewModel.navigateToVideoStreamingQuality(requireActivity().supportFragmentManager) + }, + videoDownloadQualityClick = { + viewModel.navigateToVideoDownloadQuality(requireActivity().supportFragmentManager) + } + ) + } + } + } + +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun VideoSettingsScreen( + windowSize: WindowSize, + videoSettings: VideoSettings, + wifiDownloadChanged: (Boolean) -> Unit, + videoStreamingQualityClick: () -> Unit, + videoDownloadQualityClick: () -> Unit, + onBackClick: () -> Unit, +) { + val scaffoldState = rememberScaffoldState() + + var wifiDownloadOnly by rememberSaveable { + mutableStateOf(videoSettings.wifiDownloadOnly) + } + + 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_video), + 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 + ) { + Row( + Modifier + .testTag("btn_wifi_only") + .fillMaxWidth() + .height(92.dp) + .padding(top = 8.dp) + .noRippleClickable { + wifiDownloadOnly = !wifiDownloadOnly + wifiDownloadChanged(wifiDownloadOnly) + }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(Modifier.weight(1f)) { + Text( + modifier = Modifier.testTag("txt_wifi_only_label"), + text = stringResource(id = R.string.profile_wifi_only_download), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleMedium + ) + Spacer(Modifier.height(4.dp)) + Text( + modifier = Modifier.testTag("txt_wifi_only_description"), + text = stringResource(id = R.string.profile_only_download_when_wifi_turned_on), + color = MaterialTheme.appColors.textSecondary, + style = MaterialTheme.appTypography.labelMedium + ) + } + Switch( + modifier = Modifier.testTag("sw_wifi_only"), + checked = wifiDownloadOnly, + onCheckedChange = { + wifiDownloadOnly = !wifiDownloadOnly + wifiDownloadChanged(wifiDownloadOnly) + }, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.appColors.primary, + checkedTrackColor = MaterialTheme.appColors.primary + ) + ) + } + Divider() + Row( + Modifier + .fillMaxWidth() + .height(92.dp) + .clickable { + videoStreamingQualityClick() + }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(Modifier.weight(1f)) { + Text( + text = stringResource(id = CoreR.string.core_video_streaming_quality), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleMedium + ) + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource(id = videoSettings.videoStreamingQuality.titleResId), + color = MaterialTheme.appColors.textSecondary, + style = MaterialTheme.appTypography.labelMedium + ) + } + Icon( + imageVector = Icons.Filled.ChevronRight, + tint = MaterialTheme.appColors.onSurface, + contentDescription = stringResource(CoreR.string.core_accessibility_expandable_arrow) + ) + } + Divider() + Row( + Modifier + .testTag("btn_video_quality") + .fillMaxWidth() + .height(92.dp) + .clickable { + videoDownloadQualityClick() + }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(Modifier.weight(1f)) { + Text( + text = stringResource(id = CoreR.string.core_video_download_quality), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleMedium + ) + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource(id = videoSettings.videoDownloadQuality.titleResId), + color = MaterialTheme.appColors.textSecondary, + style = MaterialTheme.appTypography.labelMedium + ) + } + Icon( + imageVector = Icons.Filled.ChevronRight, + tint = MaterialTheme.appColors.onSurface, + contentDescription = stringResource(CoreR.string.core_accessibility_expandable_arrow) + ) + } + Divider() + } + } + } + } + } +} + +@Preview(uiMode = UI_MODE_NIGHT_NO) +@Preview(uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun VideoSettingsScreenPreview() { + OpenEdXTheme { + VideoSettingsScreen( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + wifiDownloadChanged = {}, + videoStreamingQualityClick = {}, + videoDownloadQualityClick = {}, + onBackClick = {}, + videoSettings = VideoSettings.default + ) + } +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoSettingsViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/video/VideoSettingsViewModel.kt similarity index 92% rename from profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoSettingsViewModel.kt rename to profile/src/main/java/org/openedx/profile/presentation/video/VideoSettingsViewModel.kt index 475ff228a..b98ec8709 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoSettingsViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/video/VideoSettingsViewModel.kt @@ -1,4 +1,4 @@ -package org.openedx.profile.presentation.settings.video +package org.openedx.profile.presentation.video import androidx.fragment.app.FragmentManager import androidx.lifecycle.LifecycleOwner @@ -54,10 +54,7 @@ class VideoSettingsViewModel( logProfileEvent( ProfileAnalyticsEvent.WIFI_TOGGLE, buildMap { - put( - ProfileAnalyticsKey.ACTION.key, - if (value) ProfileAnalyticsKey.TRUE.key else ProfileAnalyticsKey.FALSE.key - ) + put(ProfileAnalyticsKey.ACTION.key, value) } ) } diff --git a/profile/src/main/res/drawable/profile_ic_logout.xml b/profile/src/main/res/drawable/profile_ic_logout.xml index 2d32b1f36..b8cfda4dd 100644 --- a/profile/src/main/res/drawable/profile_ic_logout.xml +++ b/profile/src/main/res/drawable/profile_ic_logout.xml @@ -1,24 +1,9 @@ - - - - - + android:width="18dp" + android:height="18dp" + android:viewportWidth="18" + android:viewportHeight="18"> + diff --git a/profile/src/main/res/values-uk/strings.xml b/profile/src/main/res/values-uk/strings.xml index 498440ba8..fe5eb14b8 100644 --- a/profile/src/main/res/values-uk/strings.xml +++ b/profile/src/main/res/values-uk/strings.xml @@ -1,12 +1,8 @@ - Зв\'язатися з підтримкою Інформація про профіль - Інформація про підтримку - Вийти Біо: %1$s Рік народження: %1$s - Ви впевнені, що хочете вийти з профілю? Повний профіль Обмежений профіль Редагувати профіль @@ -25,15 +21,11 @@ Вибрати з галереї Видалити фото Налаштування - Налаштування відео - Завантаження тільки через Wi-Fi - Завантажуйте вміст лише тоді, коли ввімкнено wi-fi Видалити акаунт Ви впевнені, що бажаєте видалити свій акаунт? Для підтвердження цієї дії потрібно ввести пароль вашого акаунту Так, видалити акаунт - Назад до профілю Пароль невірний. Будь ласка, спробуйте знову. Пароль занадто короткий Покинути профіль? diff --git a/profile/src/main/res/values/strings.xml b/profile/src/main/res/values/strings.xml index ecc88ac6b..efdb04c30 100644 --- a/profile/src/main/res/values/strings.xml +++ b/profile/src/main/res/values/strings.xml @@ -1,23 +1,19 @@ - Contact support Profile info - Support info - Log out Bio: %1$s - Year of birth: %1$s - Are you sure you want to log out? + Year of Birth: %1$s Full profile Limited profile - Edit profile + Edit Profile Edit Save - Delete account + Delete Account You must be over 13 years old to have a profile with full access to information. - Year of birth + Year of Birth Location - About me - Spoken language + About Me + Spoken Language Switch to full profile Switch to limited profile Delete account @@ -25,7 +21,6 @@ delete your account? To confirm this action, please enter your account password. Yes, delete account - Back to profile The password is incorrect. Please try again. Password is too short Done @@ -33,12 +28,16 @@ Select from gallery Remove photo Settings - Video settings - Wi-fi only download - Only download content when wi-fi is turned on Leave without saving? Leave Keep editing Changes you have made will be discarded. + Log Out + Are you sure you want to log out? + Contact Support + Support + Video + Wi-fi only download + Only download content when wi-fi is turned on 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 990248b2e..0e299e82a 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 @@ -7,7 +7,11 @@ import io.mockk.every import io.mockk.mockk 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 @@ -19,8 +23,8 @@ 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.presentation.anothers_account.AnothersProfileUIState -import org.openedx.profile.presentation.anothers_account.AnothersProfileViewModel +import org.openedx.profile.presentation.anothersaccount.AnothersProfileUIState +import org.openedx.profile.presentation.anothersaccount.AnothersProfileViewModel import java.net.UnknownHostException @OptIn(ExperimentalCoroutinesApi::class) 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 89f6f2623..ca2ffd9bb 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 @@ -9,12 +9,14 @@ 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.emptyFlow import kotlinx.coroutines.flow.flow -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 @@ -26,11 +28,7 @@ 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.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.AppUpgradeNotifier import org.openedx.profile.domain.interactor.ProfileInteractor import org.openedx.profile.presentation.ProfileAnalytics import org.openedx.profile.presentation.ProfileRouter @@ -50,15 +48,8 @@ class ProfileViewModelTest { private val resourceManager = mockk() private val interactor = mockk() private val notifier = mockk() - private val cookieManager = mockk() - private val workerController = mockk() private val analytics = mockk() private val router = mockk() - private val appUpgradeNotifier = mockk() - - private val appData = AppData( - versionName = "1.0.0", - ) private val account = org.openedx.profile.domain.model.Account( username = "", @@ -87,7 +78,6 @@ class ProfileViewModelTest { 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 { config.isPreLoginExperienceEnabled() } returns false every { config.getFeedbackEmailAddress() } returns "" every { config.getAgreement(Locale.current.language) } returns AgreementUrls() @@ -102,24 +92,17 @@ class ProfileViewModelTest { @Test fun `getAccount no internetConnection and cache is null`() = runTest { val viewModel = ProfileViewModel( - appData, - config, interactor, resourceManager, notifier, - dispatcher, - cookieManager, - workerController, analytics, - router, - appUpgradeNotifier + router ) coEvery { interactor.getCachedAccount() } returns null coEvery { interactor.getAccount() } throws UnknownHostException() advanceUntilIdle() coVerify(exactly = 1) { interactor.getAccount() } - verify(exactly = 1) { appUpgradeNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assert(viewModel.uiState.value is ProfileUIState.Loading) @@ -129,24 +112,17 @@ class ProfileViewModelTest { @Test fun `getAccount no internetConnection and cache is not null`() = runTest { val viewModel = ProfileViewModel( - appData, - config, interactor, resourceManager, notifier, - dispatcher, - cookieManager, - workerController, analytics, - router, - appUpgradeNotifier + router ) coEvery { interactor.getCachedAccount() } returns account coEvery { interactor.getAccount() } throws UnknownHostException() advanceUntilIdle() coVerify(exactly = 1) { interactor.getAccount() } - verify(exactly = 1) { appUpgradeNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assert(viewModel.uiState.value is ProfileUIState.Data) @@ -156,24 +132,17 @@ class ProfileViewModelTest { @Test fun `getAccount unknown exception`() = runTest { val viewModel = ProfileViewModel( - appData, - config, interactor, resourceManager, notifier, - dispatcher, - cookieManager, - workerController, analytics, - router, - appUpgradeNotifier + router ) coEvery { interactor.getCachedAccount() } returns null coEvery { interactor.getAccount() } throws Exception() advanceUntilIdle() coVerify(exactly = 1) { interactor.getAccount() } - verify(exactly = 1) { appUpgradeNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assert(viewModel.uiState.value is ProfileUIState.Loading) @@ -183,137 +152,30 @@ class ProfileViewModelTest { @Test fun `getAccount success`() = runTest { val viewModel = ProfileViewModel( - appData, - config, interactor, resourceManager, notifier, - dispatcher, - cookieManager, - workerController, analytics, - router, - appUpgradeNotifier + router ) coEvery { interactor.getCachedAccount() } returns null coEvery { interactor.getAccount() } returns account advanceUntilIdle() coVerify(exactly = 1) { interactor.getAccount() } - verify(exactly = 1) { appUpgradeNotifier.notifier } assert(viewModel.uiState.value is ProfileUIState.Data) assert(viewModel.uiMessage.value == null) } - @Test - fun `logout no internet connection`() = runTest { - val viewModel = ProfileViewModel( - appData, - config, - interactor, - resourceManager, - notifier, - dispatcher, - cookieManager, - workerController, - analytics, - router, - appUpgradeNotifier - ) - coEvery { interactor.logout() } throws UnknownHostException() - coEvery { workerController.removeModels() } returns Unit - every { analytics.logEvent(any(), any()) } returns Unit - every { cookieManager.clearWebViewCookie() } returns Unit - viewModel.logout() - advanceUntilIdle() - - coVerify(exactly = 1) { interactor.logout() } - verify(exactly = 1) { appUpgradeNotifier.notifier } - - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - assertEquals(noInternet, message?.message) - assert(viewModel.successLogout.value == true) - } - - @Test - fun `logout unknown exception`() = runTest { - val viewModel = ProfileViewModel( - appData, - config, - interactor, - resourceManager, - notifier, - dispatcher, - cookieManager, - workerController, - analytics, - router, - appUpgradeNotifier - ) - coEvery { interactor.logout() } throws Exception() - coEvery { workerController.removeModels() } returns Unit - every { analytics.logEvent(any(), any()) } returns Unit - every { cookieManager.clearWebViewCookie() } returns Unit - viewModel.logout() - advanceUntilIdle() - - coVerify(exactly = 1) { interactor.logout() } - verify(exactly = 1) { appUpgradeNotifier.notifier } - verify(exactly = 1) { cookieManager.clearWebViewCookie() } - verify { analytics.logEvent(any(), any()) } - - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - assertEquals(somethingWrong, message?.message) - assert(viewModel.successLogout.value == true) - } - - @Test - fun `logout success`() = runTest { - val viewModel = ProfileViewModel( - appData, - config, - interactor, - resourceManager, - notifier, - dispatcher, - cookieManager, - workerController, - analytics, - router, - appUpgradeNotifier - ) - coEvery { interactor.getCachedAccount() } returns mockk() - coEvery { interactor.getAccount() } returns mockk() - every { analytics.logEvent(any(), any()) } returns Unit - coEvery { interactor.logout() } returns Unit - coEvery { workerController.removeModels() } returns Unit - every { cookieManager.clearWebViewCookie() } returns Unit - viewModel.logout() - advanceUntilIdle() - coVerify(exactly = 1) { interactor.logout() } - verify(exactly = 2) { analytics.logEvent(any(), any()) } - verify(exactly = 1) { appUpgradeNotifier.notifier } - verify(exactly = 1) { cookieManager.clearWebViewCookie() } - - assert(viewModel.uiMessage.value == null) - assert(viewModel.successLogout.value == true) - } - @Test fun `AccountUpdated notifier test`() = runTest { val viewModel = ProfileViewModel( - appData, - config, interactor, resourceManager, notifier, - dispatcher, - cookieManager, - workerController, analytics, - router, - appUpgradeNotifier + router ) coEvery { interactor.getCachedAccount() } returns null every { notifier.notifier } returns flow { emit(AccountUpdated()) } @@ -325,6 +187,5 @@ class ProfileViewModelTest { advanceUntilIdle() coVerify(exactly = 2) { interactor.getAccount() } - verify(exactly = 1) { appUpgradeNotifier.notifier } } }