From 2e7ead132a329926dd618c1c6db6dad925d6be8e Mon Sep 17 00:00:00 2001 From: Emad Rad Date: Sun, 5 Nov 2023 21:42:39 +0330 Subject: [PATCH 1/4] chore: use correct links inside README (#82) * [docs] adding new github project issue template * chore: use correct links inside README --------- Co-authored-by: Volodymyr Chekyrta <127732735+volodymyr-chekyrta@users.noreply.github.com> Co-authored-by: Marco Morales --- .github/ISSUE_TEMPLATE/roadmap-entry.md | 14 ++++++++++++++ README.md | 9 ++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/roadmap-entry.md diff --git a/.github/ISSUE_TEMPLATE/roadmap-entry.md b/.github/ISSUE_TEMPLATE/roadmap-entry.md new file mode 100644 index 000000000..4167e9e34 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/roadmap-entry.md @@ -0,0 +1,14 @@ +--- +name: Roadmap project entry +about: Add roadmap entry to the project +title: '[Mobile] [Android] Roadmap project name' +labels: '' +assignees: '' + +--- + +### Goal +Describe the goal for this project + +**Slack:** #wg-product-mobile +**Requirement Definition:** add Confluence project page link (or external public documentation) diff --git a/README.md b/README.md index 5c09e9f90..8e2c85867 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,14 @@ Modern vision of the mobile application for the Open EdX platform from Raccoon G [Documentation](Documentation/Documentation.md) ## Building + 1. Check out the source code: - git clone https://github.com/raccoongang/educationx-app-ios.git + git clone https://github.com/openedx/openedx-app-android.git 2. Open Android Studio and choose Open an Existing Android Studio Project. -3. Choose ``educationx-app-android``. +3. Choose ``openedx-app-android``. 4. Configure the [config.yaml](config.yaml) with URLs and OAuth credentials for your Open edX instance. @@ -20,11 +21,13 @@ Modern vision of the mobile application for the Open EdX platform from Raccoon G 6. Click the **Run** button. ## API plugin + This project uses custom APIs to improve performance and reduce the number of requests to the server. You can find the plugin with the API and installation guide [here](https://github.com/raccoongang/mobile-api-extensions). ## License + The code in this repository is licensed under the Apache-2.0 license unless otherwise noted. -Please see [LICENSE](https://github.com/raccoongang/educationx-app-android/blob/main/LICENSE) file for details. +Please see [LICENSE](https://github.com/openedx/openedx-app-android/blob/main/LICENSE) file for details. From a6fd21cb23a5a62ae9a8d646e164e50c1cdb1d35 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk <141041606+PavloNetrebchuk@users.noreply.github.com> Date: Mon, 6 Nov 2023 12:52:32 +0200 Subject: [PATCH 2/4] HLS video quality (#76) * Chromecast feature WIP * Fix after merge * Adapt casting for landscape mode * Disable casting for HLS videos * Setting the HLS video quality value selected in the settings --- .../java/org/openedx/app/di/ScreenModule.kt | 4 +- .../core/domain/model/VideoSettings.kt | 10 ++-- core/src/main/res/values-uk/strings.xml | 2 +- core/src/main/res/values/strings.xml | 2 +- course/build.gradle | 1 + .../unit/video/EncodedVideoUnitViewModel.kt | 47 +++++++++++++++-- .../unit/video/VideoFullScreenFragment.kt | 52 ++++++++++++++++++- .../unit/video/VideoUnitFragment.kt | 29 +++++++---- .../presentation/unit/video/VideoViewModel.kt | 5 +- .../unit/video/VideoViewModelTest.kt | 8 +-- .../settings/video/VideoQualityFragment.kt | 2 +- .../settings/video/VideoSettingsFragment.kt | 2 +- profile/src/main/res/values-uk/strings.xml | 2 +- profile/src/main/res/values/strings.xml | 2 +- 14 files changed, 135 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/org/openedx/app/di/ScreenModule.kt b/app/src/main/java/org/openedx/app/di/ScreenModule.kt index f61cf393f..979b9589c 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -88,9 +88,9 @@ val screenModule = module { viewModel { (courseId: String) -> CourseSectionViewModel(get(), get(), get(), get(), get(), get(), get(), get(), courseId) } viewModel { (courseId: String) -> CourseUnitContainerViewModel(get(), get(), get(), courseId) } viewModel { (courseId: String) -> CourseVideoViewModel(courseId, get(), get(), get(), get(), get(), get(), get()) } - viewModel { (courseId: String) -> VideoViewModel(courseId, get(), get()) } + viewModel { (courseId: String) -> VideoViewModel(courseId, get(), get(), get()) } viewModel { (courseId: String) -> VideoUnitViewModel(courseId, get(), get(), get(), get()) } - viewModel { (courseId: String, blockId: String) -> EncodedVideoUnitViewModel(courseId, blockId, get(), get(), get(), get(), get()) } + viewModel { (courseId: String, blockId: String) -> EncodedVideoUnitViewModel(courseId, blockId, get(), get(), get(), get(), get(), get()) } viewModel { (courseId:String, handoutsType: String) -> HandoutsViewModel(courseId, handoutsType, get()) } viewModel { CourseSearchViewModel(get(), get(), get()) } viewModel { SelectDialogViewModel(get()) } diff --git a/core/src/main/java/org/openedx/core/domain/model/VideoSettings.kt b/core/src/main/java/org/openedx/core/domain/model/VideoSettings.kt index 9e2b84ddb..07241824b 100644 --- a/core/src/main/java/org/openedx/core/domain/model/VideoSettings.kt +++ b/core/src/main/java/org/openedx/core/domain/model/VideoSettings.kt @@ -11,11 +11,11 @@ data class VideoSettings( } } -enum class VideoQuality(val titleResId: Int) { - AUTO(R.string.auto_recommended_text), - OPTION_360P(R.string.video_quality_p360), - OPTION_540P(R.string.video_quality_p540), - OPTION_720P(R.string.video_quality_p720); +enum class VideoQuality(val titleResId: Int, val width: Int, val height: Int) { + AUTO(R.string.auto_recommended_text, 0, 0), + OPTION_360P(R.string.video_quality_p360, 640, 360), + OPTION_540P(R.string.video_quality_p540, 960, 540), + OPTION_720P(R.string.video_quality_p720, 1280, 720); val value: String = this.name.replace("OPTION_", "").lowercase() } diff --git a/core/src/main/res/values-uk/strings.xml b/core/src/main/res/values-uk/strings.xml index 581b19366..d5623089b 100644 --- a/core/src/main/res/values-uk/strings.xml +++ b/core/src/main/res/values-uk/strings.xml @@ -21,7 +21,7 @@ Пароль незабаром Авто (Рекомендовано) - 360p (Найменший розмір) + 360p (Менше використання трафіку) 540p 720p (Найкраща якість) Офлайн diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index 975d96869..f2584d60a 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -27,7 +27,7 @@ Reload Downloading in progress Auto (Recommended) - 360p (Smallest file size) + 360p (Lower data usage) 540p 720p (Best quality) User account is not activated. Please activate your account first. diff --git a/course/build.gradle b/course/build.gradle index d7b0f2cab..fdf14b83d 100644 --- a/course/build.gradle +++ b/course/build.gradle @@ -62,6 +62,7 @@ dependencies { implementation project(path: ':discussion') implementation "com.pierfrancescosoffritti.androidyoutubeplayer:core:$youtubeplayer_version" implementation "androidx.media3:media3-exoplayer:$media3_version" + implementation "androidx.media3:media3-exoplayer-hls:$media3_version" implementation "androidx.media3:media3-ui:$media3_version" implementation "androidx.media3:media3-cast:$media3_version" diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt index dbb6b385e..5f613bc83 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt @@ -4,8 +4,19 @@ import android.content.Context import androidx.lifecycle.LifecycleOwner import androidx.media3.cast.CastPlayer import androidx.media3.common.Player +import androidx.media3.common.util.Clock +import androidx.media3.exoplayer.DefaultLoadControl +import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.analytics.DefaultAnalyticsCollector +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.exoplayer.trackselection.AdaptiveTrackSelection +import androidx.media3.exoplayer.trackselection.DefaultTrackSelector +import androidx.media3.exoplayer.upstream.DefaultBandwidthMeter +import androidx.media3.extractor.DefaultExtractorsFactory import com.google.android.gms.cast.framework.CastContext +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.VideoQuality import org.openedx.core.module.TranscriptManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier @@ -19,6 +30,7 @@ class EncodedVideoUnitViewModel( notifier: CourseNotifier, networkConnection: NetworkConnection, transcriptManager: TranscriptManager, + val preferencesManager: CorePreferences, private val context: Context, ) : VideoUnitViewModel( courseId, @@ -55,13 +67,10 @@ class EncodedVideoUnitViewModel( @androidx.media3.common.util.UnstableApi override fun onCreate(owner: LifecycleOwner) { super.onCreate(owner) - if (exoPlayer != null) { return } - - exoPlayer = ExoPlayer.Builder(context) - .build() + initPlayer() val executor = Executors.newSingleThreadExecutor() castContext = CastContext.getSharedInstance(context, executor).result @@ -73,6 +82,7 @@ class EncodedVideoUnitViewModel( override fun onResume(owner: LifecycleOwner) { super.onResume(owner) exoPlayer?.addListener(exoPlayerListener) + getActivePlayer()?.playWhenReady = isPlaying } override fun onPause(owner: LifecycleOwner) { @@ -96,4 +106,33 @@ class EncodedVideoUnitViewModel( exoPlayer = null castPlayer = null } + + @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) + fun initPlayer() { + val videoQuality = getVideoQuality() + val params = DefaultTrackSelector.Parameters.Builder(context) + .apply { + if (videoQuality != VideoQuality.AUTO) { + setMaxVideoSize(videoQuality.width, videoQuality.height) + setViewportSize(videoQuality.width, videoQuality.height, false) + } + } + .build() + + val factory = AdaptiveTrackSelection.Factory() + val selector = DefaultTrackSelector(context, factory) + selector.parameters = params + + exoPlayer = ExoPlayer.Builder( + context, + DefaultRenderersFactory(context), + DefaultMediaSourceFactory(context, DefaultExtractorsFactory()), + selector, + DefaultLoadControl(), + DefaultBandwidthMeter.getSingletonInstance(context), + DefaultAnalyticsCollector(Clock.DEFAULT) + ).build() + } + + private fun getVideoQuality() = preferencesManager.videoSettings.videoQuality } \ No newline at end of file diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoFullScreenFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoFullScreenFragment.kt index 20ef5629c..321f8ad8f 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoFullScreenFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoFullScreenFragment.kt @@ -10,9 +10,21 @@ import androidx.fragment.app.Fragment import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.Player +import androidx.media3.common.util.Clock +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.exoplayer.DefaultLoadControl +import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.analytics.DefaultAnalyticsCollector +import androidx.media3.exoplayer.hls.HlsMediaSource +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.exoplayer.trackselection.AdaptiveTrackSelection +import androidx.media3.exoplayer.trackselection.DefaultTrackSelector +import androidx.media3.exoplayer.upstream.DefaultBandwidthMeter +import androidx.media3.extractor.DefaultExtractorsFactory import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf +import org.openedx.core.domain.model.VideoQuality import org.openedx.core.extension.requestApplyInsetsWhenAttached import org.openedx.core.presentation.global.viewBinding import org.openedx.course.R @@ -32,6 +44,7 @@ class VideoFullScreenFragment : Fragment(R.layout.fragment_video_full_screen) { super.onPlayWhenReadyChanged(playWhenReady, reason) viewModel.isPlaying = playWhenReady } + override fun onPlaybackStateChanged(playbackState: Int) { super.onPlaybackStateChanged(playbackState) if (playbackState == Player.STATE_ENDED) { @@ -74,14 +87,35 @@ class VideoFullScreenFragment : Fragment(R.layout.fragment_video_full_screen) { private fun initPlayer() { with(binding) { if (exoPlayer == null) { - exoPlayer = ExoPlayer.Builder(requireContext()) + val videoQuality = viewModel.getVideoQuality() + val params = DefaultTrackSelector.Parameters.Builder(requireContext()) + .apply { + if (videoQuality != VideoQuality.AUTO) { + setMaxVideoSize(videoQuality.width, videoQuality.height) + setViewportSize(videoQuality.width, videoQuality.height, false) + } + } .build() + + val factory = AdaptiveTrackSelection.Factory() + val selector = DefaultTrackSelector(requireContext(), factory) + selector.parameters = params + + exoPlayer = ExoPlayer.Builder( + requireContext(), + DefaultRenderersFactory(requireContext()), + DefaultMediaSourceFactory(requireContext(), DefaultExtractorsFactory()), + selector, + DefaultLoadControl(), + DefaultBandwidthMeter.getSingletonInstance(requireContext()), + DefaultAnalyticsCollector(Clock.DEFAULT) + ).build() } playerView.player = exoPlayer playerView.setShowNextButton(false) playerView.setShowPreviousButton(false) val mediaItem = MediaItem.fromUri(viewModel.videoUrl) - exoPlayer?.setMediaItem(mediaItem, viewModel.currentVideoTime) + setPlayerMedia(mediaItem) exoPlayer?.prepare() exoPlayer?.playWhenReady = viewModel.isPlaying ?: false @@ -100,6 +134,20 @@ class VideoFullScreenFragment : Fragment(R.layout.fragment_video_full_screen) { } } + @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) + private fun setPlayerMedia(mediaItem: MediaItem) { + if (viewModel.videoUrl.endsWith(".m3u8")) { + val factory = DefaultDataSource.Factory(requireContext()) + val mediaSource: HlsMediaSource = HlsMediaSource.Factory(factory).createMediaSource(mediaItem) + exoPlayer?.setMediaSource(mediaSource, viewModel.currentVideoTime) + } else { + exoPlayer?.setMediaItem( + mediaItem, + viewModel.currentVideoTime + ) + } + } + private fun releasePlayer() { exoPlayer?.stop() exoPlayer?.release() diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitFragment.kt index 8077c537e..4218bc7e4 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitFragment.kt @@ -21,6 +21,8 @@ import androidx.media3.cast.SessionAvailabilityListener import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.exoplayer.hls.HlsMediaSource import androidx.window.layout.WindowMetricsCalculator import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel @@ -187,13 +189,9 @@ class VideoUnitFragment : Fragment(R.layout.fragment_video_unit) { .build() if (!viewModel.isPlayerSetUp) { - viewModel.getActivePlayer()?.setMediaItem( - mediaItem, - viewModel.getCurrentVideoTime() - ) + setPlayerMedia(mediaItem) viewModel.getActivePlayer()?.prepare() viewModel.getActivePlayer()?.playWhenReady = viewModel.isPlaying - viewModel.isPlayerSetUp = true } @@ -239,14 +237,11 @@ class VideoUnitFragment : Fragment(R.layout.fragment_video_unit) { } @UnstableApi - override fun onDestroyView() { - super.onDestroyView() + override fun onDestroy() { if (!requireActivity().isChangingConfigurations) { viewModel.releasePlayers() + viewModel.isPlayerSetUp = false } - } - - override fun onDestroy() { handler.removeCallbacks(videoTimeRunnable) super.onDestroy() } @@ -263,6 +258,20 @@ class VideoUnitFragment : Fragment(R.layout.fragment_video_unit) { } } + @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) + private fun setPlayerMedia(mediaItem: MediaItem) { + if (viewModel.videoUrl.endsWith(".m3u8")) { + val factory = DefaultDataSource.Factory(requireContext()) + val mediaSource: HlsMediaSource = HlsMediaSource.Factory(factory).createMediaSource(mediaItem) + viewModel.exoPlayer?.setMediaSource(mediaSource, viewModel.getCurrentVideoTime()) + } else { + viewModel.getActivePlayer()?.setMediaItem( + mediaItem, + viewModel.getCurrentVideoTime() + ) + } + } + companion object { private const val ARG_BLOCK_ID = "blockId" private const val ARG_VIDEO_URL = "videoUrl" diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoViewModel.kt index 79f4e8acc..bfcda93c8 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoViewModel.kt @@ -7,11 +7,13 @@ import org.openedx.course.data.repository.CourseRepository import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseVideoPositionChanged import kotlinx.coroutines.launch +import org.openedx.core.data.storage.CorePreferences class VideoViewModel( private val courseId: String, private val courseRepository: CourseRepository, - private val notifier: CourseNotifier + private val notifier: CourseNotifier, + private val preferencesManager: CorePreferences ) : BaseViewModel() { var videoUrl = "" @@ -45,4 +47,5 @@ class VideoViewModel( } } + fun getVideoQuality() = preferencesManager.videoSettings.videoQuality } \ No newline at end of file diff --git a/course/src/test/java/org/openedx/course/presentation/unit/video/VideoViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/unit/video/VideoViewModelTest.kt index 144698ec5..ce1799432 100644 --- a/course/src/test/java/org/openedx/course/presentation/unit/video/VideoViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/unit/video/VideoViewModelTest.kt @@ -15,6 +15,7 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule +import org.openedx.core.data.storage.CorePreferences @OptIn(ExperimentalCoroutinesApi::class) class VideoViewModelTest { @@ -26,6 +27,7 @@ class VideoViewModelTest { private val courseRepository = mockk() private val notifier = mockk() + private val preferenceManager = mockk() @Before fun setUp() { @@ -39,7 +41,7 @@ class VideoViewModelTest { @Test fun `sendTime test`() = runTest { - val viewModel = VideoViewModel("", courseRepository, notifier) + val viewModel = VideoViewModel("", courseRepository, notifier, preferenceManager) coEvery { notifier.send(CourseVideoPositionChanged("", 0, false)) } returns Unit viewModel.sendTime() advanceUntilIdle() @@ -49,7 +51,7 @@ class VideoViewModelTest { @Test fun `markBlockCompleted exception`() = runTest { - val viewModel = VideoViewModel("", courseRepository, notifier) + val viewModel = VideoViewModel("", courseRepository, notifier, preferenceManager) coEvery { courseRepository.markBlocksCompletion( any(), @@ -69,7 +71,7 @@ class VideoViewModelTest { @Test fun `markBlockCompleted success`() = runTest { - val viewModel = VideoViewModel("", courseRepository, notifier) + val viewModel = VideoViewModel("", courseRepository, notifier, preferenceManager) coEvery { courseRepository.markBlocksCompletion( any(), diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoQualityFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoQualityFragment.kt index 7acb2a9c4..46c645a76 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoQualityFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoQualityFragment.kt @@ -121,7 +121,7 @@ private fun VideoQualityScreen( Text( modifier = Modifier .fillMaxWidth(), - text = stringResource(id = profileR.string.profile_video_download_quality), + text = stringResource(id = profileR.string.profile_video_streaming_quality), color = MaterialTheme.appColors.textPrimary, textAlign = TextAlign.Center, style = MaterialTheme.appTypography.titleMedium 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 index ca86d48ec..747df792a 100644 --- 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 @@ -198,7 +198,7 @@ private fun VideoSettingsScreen( ) { Column(Modifier.weight(1f)) { Text( - text = stringResource(id = profileR.string.profile_video_download_quality), + text = stringResource(id = profileR.string.profile_video_streaming_quality), color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.titleMedium ) diff --git a/profile/src/main/res/values-uk/strings.xml b/profile/src/main/res/values-uk/strings.xml index 123d4aee6..1cbb0a60a 100644 --- a/profile/src/main/res/values-uk/strings.xml +++ b/profile/src/main/res/values-uk/strings.xml @@ -30,7 +30,7 @@ Налаштування відео Завантаження тільки через Wi-Fi Завантажуйте вміст лише тоді, коли ввімкнено wi-fi - Якість завантаження відео + Якість транслювання відео Видалити акаунт Ви впевнені, що бажаєте видалити свій акаунт? diff --git a/profile/src/main/res/values/strings.xml b/profile/src/main/res/values/strings.xml index 9ad0c47c9..03f82fa8a 100644 --- a/profile/src/main/res/values/strings.xml +++ b/profile/src/main/res/values/strings.xml @@ -38,7 +38,7 @@ Video settings Wi-fi only download Only download content when wi-fi is turned on - Video download quality + Video streaming quality Leave profile? Leave Keep editing From 64c4ea8ac6dbbadf23d5052543dde31210622bde Mon Sep 17 00:00:00 2001 From: Kirill Izmaylov Date: Wed, 8 Nov 2023 12:12:38 +0300 Subject: [PATCH 3/4] refactor: build config fields (#87) --- core/build.gradle | 77 ++++++++++++++--------------------------------- 1 file changed, 23 insertions(+), 54 deletions(-) diff --git a/core/build.gradle b/core/build.gradle index 33765d3e6..2c1fe76b2 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -40,66 +40,15 @@ android { productFlavors { prod { dimension 'env' - - def envMap = config.environments.find { it.key == "PROD" } - def clientId = envMap.value.OAUTH_CLIENT_ID - def envUrls = envMap.value.URLS - def firebase = getFirebaseConfig(envMap) - - buildConfigField "String", "BASE_URL", "\"${envUrls.API_HOST_URL}\"" - buildConfigField "String", "CLIENT_ID", "\"${clientId}\"" - buildConfigField "String", "FIREBASE_PROJECT_ID", "\"${firebase.projectId}\"" - buildConfigField "String", "FIREBASE_API_KEY", "\"${firebase.apiKey}\"" - buildConfigField "String", "FIREBASE_GCM_SENDER_ID", "\"${firebase.gcmSenderId}\"" - resValue "string", "google_app_id", firebase.appId - resValue "string", "platform_name", config.platformName - resValue "string", "platform_full_name", config.platformFullName - resValue "string", "privacy_policy_link", envUrls.privacyPolicy - resValue "string", "terms_of_service_link", envUrls.termsOfService - resValue "string", "contact_us_link", envUrls.contactUs - resValue "string", "feedback_email_address", envUrls.FEEDBACK_EMAIL_ADDRESS + insertBuildConfigFields(config, it, "PROD") } develop { dimension 'env' - - def envMap = config.environments.find { it.key == "DEV" } - def clientId = envMap.value.OAUTH_CLIENT_ID - def envUrls = envMap.value.URLS - def firebase = getFirebaseConfig(envMap) - - buildConfigField "String", "BASE_URL", "\"${envUrls.API_HOST_URL}\"" - buildConfigField "String", "CLIENT_ID", "\"${clientId}\"" - buildConfigField "String", "FIREBASE_PROJECT_ID", "\"${firebase.projectId}\"" - buildConfigField "String", "FIREBASE_API_KEY", "\"${firebase.apiKey}\"" - buildConfigField "String", "FIREBASE_GCM_SENDER_ID", "\"${firebase.gcmSenderId}\"" - resValue "string", "google_app_id", firebase.appId - resValue "string", "platform_name", config.platformName - resValue "string", "platform_full_name", config.platformFullName - resValue "string", "privacy_policy_link", envUrls.privacyPolicy - resValue "string", "terms_of_service_link", envUrls.termsOfService - resValue "string", "contact_us_link", envUrls.contactUs - resValue "string", "feedback_email_address", envUrls.FEEDBACK_EMAIL_ADDRESS + insertBuildConfigFields(config, it, "DEV") } stage { dimension 'env' - - def envMap = config.environments.find { it.key == "STAGE" } - def clientId = envMap.value.OAUTH_CLIENT_ID - def envUrls = envMap.value.URLS - def firebase = getFirebaseConfig(envMap) - - buildConfigField "String", "BASE_URL", "\"${envUrls.API_HOST_URL}\"" - buildConfigField "String", "CLIENT_ID", "\"${clientId}\"" - buildConfigField "String", "FIREBASE_PROJECT_ID", "\"${firebase.projectId}\"" - buildConfigField "String", "FIREBASE_API_KEY", "\"${firebase.apiKey}\"" - buildConfigField "String", "FIREBASE_GCM_SENDER_ID", "\"${firebase.gcmSenderId}\"" - resValue "string", "google_app_id", firebase.appId - resValue "string", "platform_name", config.platformName - resValue "string", "platform_full_name", config.platformFullName - resValue "string", "privacy_policy_link", envUrls.privacyPolicy - resValue "string", "terms_of_service_link", envUrls.termsOfService - resValue "string", "contact_us_link", envUrls.contactUs - resValue "string", "feedback_email_address", envUrls.FEEDBACK_EMAIL_ADDRESS + insertBuildConfigFields(config, it, "STAGE") } } @@ -216,3 +165,23 @@ def setValue(value) { } return result } + +def insertBuildConfigFields(config, buildType, String keyName) { + def envMap = config.environments.find { it.key == keyName } + def clientId = envMap.value.OAUTH_CLIENT_ID + def envUrls = envMap.value.URLS + def firebase = getFirebaseConfig(envMap) + + buildType.buildConfigField "String", "BASE_URL", "\"${envUrls.API_HOST_URL}\"" + buildType.buildConfigField "String", "CLIENT_ID", "\"${clientId}\"" + buildType.buildConfigField "String", "FIREBASE_PROJECT_ID", "\"${firebase.projectId}\"" + buildType.buildConfigField "String", "FIREBASE_API_KEY", "\"${firebase.apiKey}\"" + buildType.buildConfigField "String", "FIREBASE_GCM_SENDER_ID", "\"${firebase.gcmSenderId}\"" + buildType.resValue "string", "google_app_id", firebase.appId + buildType.resValue "string", "platform_name", config.platformName + buildType.resValue "string", "platform_full_name", config.platformFullName + buildType.resValue "string", "privacy_policy_link", envUrls.privacyPolicy + buildType.resValue "string", "terms_of_service_link", envUrls.termsOfService + buildType.resValue "string", "contact_us_link", envUrls.contactUs + buildType.resValue "string", "feedback_email_address", envUrls.FEEDBACK_EMAIL_ADDRESS +} From b23156f87bc3ee76a875ff19511cb6087e221a4c Mon Sep 17 00:00:00 2001 From: Farhan Arshad <43750646+farhan-arshad-dev@users.noreply.github.com> Date: Wed, 8 Nov 2023 14:28:33 +0500 Subject: [PATCH 4/4] feat: add Course Dates Tab (#80) - Add Course dates support. - Add test cases for Course dates ViewModel. Not included in this PR: - Calendar integration - PLS (shift due dates) banner - Linking of items on the dates page to specific assessments within the app. fixes: LEARNER-9664 --- .../java/org/openedx/app/AnalyticsManager.kt | 10 + .../java/org/openedx/app/di/ScreenModule.kt | 8 +- build.gradle | 2 + .../org/openedx/core/data/api/CourseApi.kt | 5 +- .../core/data/model/CourseDateBlock.kt | 28 + .../openedx/core/data/model/CourseDates.kt | 163 ++++ .../core/data/model/CourseDatesBannerInfo.kt | 36 + .../org/openedx/core/data/model/DateType.kt | 31 + .../core/domain/model/CourseDateBlock.kt | 26 + .../presentation/course/CourseDatesBadge.kt | 26 + .../java/org/openedx/core/ui/theme/Color.kt | 10 +- .../java/org/openedx/core/ui/theme/Theme.kt | 20 +- .../java/org/openedx/core/utils/TimeUtils.kt | 91 ++- core/src/main/res/values/strings.xml | 19 +- course/build.gradle | 1 + .../data/repository/CourseRepository.kt | 9 +- .../domain/interactor/CourseInteractor.kt | 2 + .../course/presentation/CourseAnalytics.kt | 3 +- .../container/CourseContainerFragment.kt | 17 +- .../container/CourseContainerViewModel.kt | 4 + .../presentation/dates/CourseDatesFragment.kt | 693 ++++++++++++++++++ .../dates/CourseDatesViewModel.kt | 71 ++ .../presentation/dates/DashboardUIState.kt | 11 + .../main/res/drawable/ic_calendar_month.xml | 5 + course/src/main/res/drawable/ic_lock.xml | 9 + .../res/menu/bottom_course_container_menu.xml | 6 + course/src/main/res/values/strings.xml | 10 +- .../dates/CourseDatesViewModelTest.kt | 135 ++++ 28 files changed, 1409 insertions(+), 42 deletions(-) create mode 100644 core/src/main/java/org/openedx/core/data/model/CourseDateBlock.kt create mode 100644 core/src/main/java/org/openedx/core/data/model/CourseDates.kt create mode 100644 core/src/main/java/org/openedx/core/data/model/CourseDatesBannerInfo.kt create mode 100644 core/src/main/java/org/openedx/core/data/model/DateType.kt create mode 100644 core/src/main/java/org/openedx/core/domain/model/CourseDateBlock.kt create mode 100644 core/src/main/java/org/openedx/core/presentation/course/CourseDatesBadge.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/dates/CourseDatesFragment.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/dates/DashboardUIState.kt create mode 100644 course/src/main/res/drawable/ic_calendar_month.xml create mode 100644 course/src/main/res/drawable/ic_lock.xml create mode 100644 course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt diff --git a/app/src/main/java/org/openedx/app/AnalyticsManager.kt b/app/src/main/java/org/openedx/app/AnalyticsManager.kt index 0efcb7d54..6ad466e90 100644 --- a/app/src/main/java/org/openedx/app/AnalyticsManager.kt +++ b/app/src/main/java/org/openedx/app/AnalyticsManager.kt @@ -320,6 +320,15 @@ class AnalyticsManager(context: Context) : DashboardAnalytics, AuthAnalytics, Ap ) } + override fun datesTabClickedEvent(courseId: String, courseName: String) { + logEvent( + Event.DATES_TAB_CLICKED, bundleOf( + Key.COURSE_ID.keyName to courseId, + Key.COURSE_NAME.keyName to courseName + ) + ) + } + override fun handoutsTabClickedEvent(courseId: String, courseName: String) { logEvent( Event.HANDOUTS_TAB_CLICKED, bundleOf( @@ -402,6 +411,7 @@ private enum class Event(val eventName: String) { COURSE_TAB_CLICKED("Course_Outline_Course_tab_Clicked"), VIDEO_TAB_CLICKED("Course_Outline_Videos_tab_Clicked"), DISCUSSION_TAB_CLICKED("Course_Outline_Discussion_tab_Clicked"), + DATES_TAB_CLICKED("Course_Outline_Dates_tab_Clicked"), HANDOUTS_TAB_CLICKED("Course_Outline_Handouts_tab_Clicked"), DISCUSSION_ALL_POSTS_CLICKED("Discussion_All_Posts_Clicked"), DISCUSSION_FOLLOWING_CLICKED("Discussion_Following_Clicked"), 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 979b9589c..94c219d88 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -15,11 +15,14 @@ import org.openedx.core.presentation.dialog.selectorbottomsheet.SelectDialogView import org.openedx.course.data.repository.CourseRepository import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.container.CourseContainerViewModel +import org.openedx.course.presentation.dates.CourseDatesViewModel import org.openedx.course.presentation.detail.CourseDetailsViewModel import org.openedx.course.presentation.handouts.HandoutsViewModel import org.openedx.course.presentation.outline.CourseOutlineViewModel import org.openedx.course.presentation.section.CourseSectionViewModel import org.openedx.course.presentation.unit.container.CourseUnitContainerViewModel +import org.openedx.course.presentation.unit.video.EncodedVideoUnitViewModel +import org.openedx.course.presentation.unit.video.VideoUnitViewModel import org.openedx.course.presentation.unit.video.VideoViewModel import org.openedx.course.presentation.videos.CourseVideoViewModel import org.openedx.dashboard.data.repository.DashboardRepository @@ -41,14 +44,12 @@ 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.delete.DeleteProfileViewModel import org.openedx.profile.presentation.edit.EditProfileViewModel import org.openedx.profile.presentation.profile.ProfileViewModel import org.openedx.profile.presentation.settings.video.VideoQualityViewModel import org.openedx.profile.presentation.settings.video.VideoSettingsViewModel -import org.openedx.course.presentation.unit.video.EncodedVideoUnitViewModel -import org.openedx.course.presentation.unit.video.VideoUnitViewModel -import org.openedx.profile.presentation.anothers_account.AnothersProfileViewModel import org.openedx.whatsnew.presentation.whatsnew.WhatsNewViewModel val screenModule = module { @@ -91,6 +92,7 @@ val screenModule = module { viewModel { (courseId: String) -> VideoViewModel(courseId, get(), get(), get()) } viewModel { (courseId: String) -> VideoUnitViewModel(courseId, get(), get(), get(), get()) } viewModel { (courseId: String, blockId: String) -> EncodedVideoUnitViewModel(courseId, blockId, get(), get(), get(), get(), get(), get()) } + viewModel { (courseId: String) -> CourseDatesViewModel(courseId, get(), get(), get()) } viewModel { (courseId:String, handoutsType: String) -> HandoutsViewModel(courseId, handoutsType, get()) } viewModel { CourseSearchViewModel(get(), get(), get()) } viewModel { SelectDialogViewModel(get()) } diff --git a/build.gradle b/build.gradle index 81792e08f..7f14613b6 100644 --- a/build.gradle +++ b/build.gradle @@ -47,6 +47,8 @@ ext { window_version = '1.1.0' + extented_spans_version = "1.3.0" + //testing mockk_version = '1.13.3' android_arch_version = '2.2.0' diff --git a/core/src/main/java/org/openedx/core/data/api/CourseApi.kt b/core/src/main/java/org/openedx/core/data/api/CourseApi.kt index 89538252d..9ea1ead5a 100644 --- a/core/src/main/java/org/openedx/core/data/api/CourseApi.kt +++ b/core/src/main/java/org/openedx/core/data/api/CourseApi.kt @@ -1,7 +1,7 @@ package org.openedx.core.data.api -import org.openedx.core.data.model.* import okhttp3.ResponseBody +import org.openedx.core.data.model.* import retrofit2.http.* interface CourseApi { @@ -65,6 +65,9 @@ interface CourseApi { blocksCompletionBody: BlocksCompletionBody ) + @GET("/api/course_home/v1/dates/{course_id}") + suspend fun getCourseDates(@Path("course_id") courseId: String): CourseDates + @GET("/api/mobile/v1/course_info/{course_id}/handouts") suspend fun getHandouts(@Path("course_id") courseId: String): HandoutsModel diff --git a/core/src/main/java/org/openedx/core/data/model/CourseDateBlock.kt b/core/src/main/java/org/openedx/core/data/model/CourseDateBlock.kt new file mode 100644 index 000000000..887112845 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseDateBlock.kt @@ -0,0 +1,28 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import java.util.* + +data class CourseDateBlock( + @SerializedName("complete") + val complete: Boolean = false, + @SerializedName("date") + val date: String = "", // ISO 8601 compliant format + @SerializedName("assignment_type") + val assignmentType: String? = "", + @SerializedName("date_type") + val dateType: DateType = DateType.NONE, + @SerializedName("description") + val description: String = "", + @SerializedName("learner_has_access") + val learnerHasAccess: Boolean = false, + @SerializedName("link") + val link: String = "", + @SerializedName("link_text") + val linkText: String = "", + @SerializedName("title") + val title: String = "", + // component blockId in-case of navigating inside the app for component available in mobile + @SerializedName("first_component_block_id") + val blockId: String = "", +) diff --git a/core/src/main/java/org/openedx/core/data/model/CourseDates.kt b/core/src/main/java/org/openedx/core/data/model/CourseDates.kt new file mode 100644 index 000000000..b1c9ca951 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseDates.kt @@ -0,0 +1,163 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.presentation.course.CourseDatesBadge +import org.openedx.core.utils.TimeUtils +import java.util.Date +import org.openedx.core.domain.model.CourseDateBlock as DomainCourseDateBlock + +data class CourseDates( + @SerializedName("dates_banner_info") + val datesBannerInfo: CourseDatesBannerInfo?, + @SerializedName("course_date_blocks") + val courseDateBlocks: List, + @SerializedName("missed_deadlines") + val missedDeadlines: Boolean = false, + @SerializedName("missed_gated_content") + val missedGatedContent: Boolean = false, + @SerializedName("learner_is_full_access") + val learnerIsFullAccess: Boolean = false, + @SerializedName("user_timezone") + val userTimezone: String? = "", + @SerializedName("verified_upgrade_link") + val verifiedUpgradeLink: String? = "", +) { + fun mapToDomain(): LinkedHashMap> { + var courseDatesDomain = organiseCourseDatesInBlock() + if (isContainToday().not()) { + // Adding today's date block manually if not present in the date + val todayBlock = DomainCourseDateBlock.getTodayDateBlock() + courseDatesDomain[TimeUtils.formatDate(TimeUtils.FORMAT_DATE, todayBlock.date)] = + arrayListOf(todayBlock) + } + // Sort the map entries date keys wise + courseDatesDomain = LinkedHashMap(courseDatesDomain.toSortedMap(compareBy { + TimeUtils.stringToDate(TimeUtils.FORMAT_DATE, it) + })) + reviseDateBlockBadge(courseDatesDomain) + return courseDatesDomain + } + + /** + * Map the date blocks according to dates and stack all the blocks of same date against one key + */ + private fun organiseCourseDatesInBlock(): LinkedHashMap> { + val courseDates = + LinkedHashMap>() + courseDateBlocks.forEach { item -> + val key = + TimeUtils.formatDate(TimeUtils.FORMAT_DATE, TimeUtils.iso8601ToDate(item.date)) + val dateBlock = DomainCourseDateBlock( + title = item.title, + description = item.description, + link = item.link, + blockId = item.blockId, + date = TimeUtils.iso8601ToDate(item.date), + complete = item.complete, + learnerHasAccess = item.learnerHasAccess, + dateType = item.dateType, + dateBlockBadge = CourseDatesBadge.BLANK + ) + if (courseDates.containsKey(key)) { + (courseDates[key] as ArrayList).add(dateBlock) + } else { + courseDates[key] = arrayListOf(dateBlock) + } + } + return courseDates + } + + /** + * Utility method to check that list contains today's date block or not. + */ + private fun isContainToday(): Boolean { + val today = Date() + return courseDateBlocks.any { blockDate -> + TimeUtils.iso8601ToDate(blockDate.date) == today + } + } + + /** + * Set the Date Block Badge based on the date block data + */ + private fun reviseDateBlockBadge(courseDatesDomain: LinkedHashMap>) { + var dueNextCount = 0 + courseDatesDomain.keys.forEach { key -> + courseDatesDomain[key]?.forEach { item -> + var dateBlockTag: CourseDatesBadge = getDateTypeBadge(item) + //Setting Due Next only for first occurrence + if (dateBlockTag == CourseDatesBadge.DUE_NEXT) { + if (dueNextCount == 0) + dueNextCount += 1 + else + dateBlockTag = CourseDatesBadge.BLANK + } + item.dateBlockBadge = dateBlockTag + } + } + } + + /** + * Return Pill/Badge type of date block based on data + */ + private fun getDateTypeBadge(item: DomainCourseDateBlock): CourseDatesBadge { + val dateBlockTag: CourseDatesBadge + val currentDate = Date() + val componentDate: Date = item.date ?: return CourseDatesBadge.BLANK + when (item.dateType) { + DateType.TODAY_DATE -> { + dateBlockTag = CourseDatesBadge.TODAY + } + + DateType.COURSE_START_DATE, + DateType.COURSE_END_DATE -> { + dateBlockTag = CourseDatesBadge.BLANK + } + + DateType.ASSIGNMENT_DUE_DATE -> { + when { + item.complete -> { + dateBlockTag = CourseDatesBadge.COMPLETED + } + + item.learnerHasAccess -> { + dateBlockTag = when { + item.link.isEmpty() -> { + CourseDatesBadge.NOT_YET_RELEASED + } + + TimeUtils.isDueDate(currentDate, componentDate) -> { + CourseDatesBadge.DUE_NEXT + } + + TimeUtils.isDatePassed(currentDate, componentDate) -> { + CourseDatesBadge.PAST_DUE + } + + else -> { + CourseDatesBadge.BLANK + } + } + } + + else -> { + dateBlockTag = CourseDatesBadge.VERIFIED_ONLY + } + } + } + + DateType.COURSE_EXPIRED_DATE -> { + dateBlockTag = CourseDatesBadge.COURSE_EXPIRED_DATE + } + + else -> { + // dateBlockTag is BLANK for all other cases + // DateTypes.CERTIFICATE_AVAILABLE_DATE, + // DateTypes.VERIFIED_UPGRADE_DEADLINE, + // DateTypes.VERIFICATION_DEADLINE_DATE + dateBlockTag = CourseDatesBadge.BLANK + } + } + return dateBlockTag + } +} diff --git a/core/src/main/java/org/openedx/core/data/model/CourseDatesBannerInfo.kt b/core/src/main/java/org/openedx/core/data/model/CourseDatesBannerInfo.kt new file mode 100644 index 000000000..f3363dfed --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseDatesBannerInfo.kt @@ -0,0 +1,36 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName + +data class CourseDatesBannerInfo( + @SerializedName("missed_deadlines") + val missedDeadlines: Boolean = false, + @SerializedName("missed_gated_content") + val missedGatedContent: Boolean = false, + @SerializedName("verified_upgrade_link") + val verifiedUpgradeLink: String = "", + @SerializedName("content_type_gating_enabled") + val contentTypeGatingEnabled: Boolean = false, +) { + fun getCourseBannerType(): CourseBannerType = when { + upgradeToGraded() -> CourseBannerType.UPGRADE_TO_GRADED + upgradeToReset() -> CourseBannerType.UPGRADE_TO_RESET + resetDates() -> CourseBannerType.RESET_DATES + showBannerInfo() -> CourseBannerType.INFO_BANNER + else -> CourseBannerType.BLANK + } + + private fun showBannerInfo(): Boolean = missedDeadlines.not() + + private fun upgradeToGraded(): Boolean = contentTypeGatingEnabled && missedDeadlines.not() + + private fun upgradeToReset(): Boolean = + upgradeToGraded().not() && missedDeadlines && missedGatedContent + + private fun resetDates(): Boolean = + upgradeToGraded().not() && missedDeadlines && missedGatedContent.not() +} + +enum class CourseBannerType { + BLANK, INFO_BANNER, UPGRADE_TO_GRADED, UPGRADE_TO_RESET, RESET_DATES; +} diff --git a/core/src/main/java/org/openedx/core/data/model/DateType.kt b/core/src/main/java/org/openedx/core/data/model/DateType.kt new file mode 100644 index 000000000..e9af5256b --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/DateType.kt @@ -0,0 +1,31 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName + +enum class DateType { + @SerializedName("todays-date") + TODAY_DATE, + + @SerializedName("course-start-date") + COURSE_START_DATE, + + @SerializedName("course-end-date") + COURSE_END_DATE, + + @SerializedName("course-expired-date") + COURSE_EXPIRED_DATE, + + @SerializedName("assignment-due-date") + ASSIGNMENT_DUE_DATE, + + @SerializedName("certificate-available-date") + CERTIFICATE_AVAILABLE_DATE, + + @SerializedName("verified-upgrade-deadline") + VERIFIED_UPGRADE_DEADLINE, + + @SerializedName("verification-deadline-date") + VERIFICATION_DEADLINE_DATE, + + NONE, +} diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseDateBlock.kt b/core/src/main/java/org/openedx/core/domain/model/CourseDateBlock.kt new file mode 100644 index 000000000..60f3a49ba --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseDateBlock.kt @@ -0,0 +1,26 @@ +package org.openedx.core.domain.model + +import org.openedx.core.data.model.DateType +import org.openedx.core.presentation.course.CourseDatesBadge +import java.util.Date + +data class CourseDateBlock( + val title: String = "", + val description: String = "", + val link: String = "", + val blockId: String = "", + val learnerHasAccess: Boolean = false, + val complete: Boolean = false, + val date: Date?, + val dateType: DateType = DateType.NONE, + var dateBlockBadge: CourseDatesBadge = CourseDatesBadge.BLANK, +) { + companion object { + fun getTodayDateBlock() = + CourseDateBlock( + date = Date(), + dateType = DateType.TODAY_DATE, + dateBlockBadge = CourseDatesBadge.TODAY + ) + } +} diff --git a/core/src/main/java/org/openedx/core/presentation/course/CourseDatesBadge.kt b/core/src/main/java/org/openedx/core/presentation/course/CourseDatesBadge.kt new file mode 100644 index 000000000..5c5da0b42 --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/course/CourseDatesBadge.kt @@ -0,0 +1,26 @@ +package org.openedx.core.presentation.course + +import org.openedx.core.R + +/** + * This enum defines the Date type of Course Dates + */ +enum class CourseDatesBadge { + TODAY, BLANK, VERIFIED_ONLY, COMPLETED, PAST_DUE, DUE_NEXT, NOT_YET_RELEASED, + COURSE_EXPIRED_DATE; + + /** + * @return The string resource's ID if it's a valid enum inside [CourseDatesBadge], otherwise -1. + */ + fun getStringResIdForDateType(): Int { + return when (this) { + TODAY -> R.string.core_date_type_today + VERIFIED_ONLY -> R.string.core_date_type_verified_only + COMPLETED -> R.string.core_date_type_completed + PAST_DUE -> R.string.core_date_type_past_due + DUE_NEXT -> R.string.core_date_type_due_next + NOT_YET_RELEASED -> R.string.core_date_type_not_yet_released + else -> -1 + } + } +} diff --git a/core/src/main/java/org/openedx/core/ui/theme/Color.kt b/core/src/main/java/org/openedx/core/ui/theme/Color.kt index b84774bd1..2cfcd1bad 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/Color.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/Color.kt @@ -31,7 +31,15 @@ data class AppColors( val warning: Color, val info: Color, - val accessGreen:Color + val accessGreen: Color, + + val datesBadgeDefault: Color, + val datesBadgeTextDefault: Color, + val datesBadgePastDue: Color, + val datesBadgeToday: Color, + val datesBadgeTextToday: Color, // also used for locked date block + val datesBadgeDue: Color, // Also used for not release date block text and stoke + val datesBadgeTextDue: Color // Also used for locked date block 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 c7ee45e61..96711ca6c 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 @@ -52,7 +52,15 @@ private val DarkColorPalette = AppColors( warning = Color(0xFFFFC248), info = Color(0xFF0095FF), - accessGreen = Color(0xFF23BCA0) + accessGreen = Color(0xFF23BCA0), + + datesBadgeDefault = Color(0xFFF2F0EF), + datesBadgeTextDefault = Color(0xFF454545), + datesBadgePastDue = Color(0xFFD7D3D1), + datesBadgeToday = Color(0xFFD6B600), + datesBadgeTextToday = Color(0xFF000000), + datesBadgeDue = Color(0xFF707070), + datesBadgeTextDue = Color(0xFFFFFFFF) ) private val LightColorPalette = AppColors( @@ -96,7 +104,15 @@ private val LightColorPalette = AppColors( warning = Color(0xFFFFC94D), info = Color(0xFF42AAFF), - accessGreen = Color(0xFF23BCA0) + accessGreen = Color(0xFF23BCA0), + + datesBadgeDefault = Color(0xFFF2F0EF), + datesBadgeTextDefault = Color(0xFF454545), + datesBadgePastDue = Color(0xFFD7D3D1), + datesBadgeToday = Color(0xFFD6B600), + datesBadgeTextToday = Color(0xFF000000), + datesBadgeDue = Color(0xFF707070), + datesBadgeTextDue = Color(0xFFFFFFFF) ) val MaterialTheme.appColors: AppColors diff --git a/core/src/main/java/org/openedx/core/utils/TimeUtils.kt b/core/src/main/java/org/openedx/core/utils/TimeUtils.kt index dc31f7102..b70d9c7b7 100644 --- a/core/src/main/java/org/openedx/core/utils/TimeUtils.kt +++ b/core/src/main/java/org/openedx/core/utils/TimeUtils.kt @@ -2,26 +2,30 @@ package org.openedx.core.utils import android.content.Context import android.text.format.DateUtils +import com.google.gson.internal.bind.util.ISO8601Utils import org.openedx.core.R import org.openedx.core.domain.model.StartType import org.openedx.core.system.ResourceManager import java.text.ParseException +import java.text.ParsePosition import java.text.SimpleDateFormat -import java.util.* +import java.util.Date +import java.util.Locale object TimeUtils { private const val FORMAT_ISO_8601 = "yyyy-MM-dd'T'HH:mm:ss'Z'" private const val FORMAT_ISO_8601_WITH_TIME_ZONE = "yyyy-MM-dd'T'HH:mm:ssXXX" private const val FORMAT_APPLICATION = "dd.MM.yyyy HH:mm" - private const val FORMAT_DATE = "dd MMM, yyyy" + const val FORMAT_DATE = "dd MMM, yyyy" + const val FORMAT_DATE_TAB = "EEE, MMM dd, yyyy" private const val SEVEN_DAYS_IN_MILLIS = 604800000L fun iso8601ToDate(text: String): Date? { return try { - val sdf = SimpleDateFormat(FORMAT_ISO_8601, Locale.getDefault()) - sdf.parse(text) + val parsePosition = ParsePosition(0) + return ISO8601Utils.parse(text, parsePosition) } catch (e: ParseException) { null } @@ -36,10 +40,21 @@ object TimeUtils { } } - fun iso8601ToDateWithTime(context: Context,text: String): String { + /** + * This method used to convert the date to ISO 8601 compliant format date string + * @param date [Date]needs to be converted + * @return The current date and time in a ISO 8601 compliant format. + */ + fun dateToIso8601(date: Date?): String { + return ISO8601Utils.format(date, true) + } + + fun iso8601ToDateWithTime(context: Context, text: String): String { return try { val courseDateFormat = SimpleDateFormat(FORMAT_ISO_8601, Locale.getDefault()) - val applicationDateFormat = SimpleDateFormat(context.getString(R.string.core_full_date_with_time), Locale.getDefault()) + val applicationDateFormat = SimpleDateFormat( + context.getString(R.string.core_full_date_with_time), Locale.getDefault() + ) applicationDateFormat.format(courseDateFormat.parse(text)!!) } catch (e: Exception) { e.printStackTrace() @@ -48,16 +63,30 @@ object TimeUtils { } fun dateToCourseDate(resourceManager: ResourceManager, date: Date?): String { + return formatDate( + format = resourceManager.getString(R.string.core_date_format_MMMM_dd), date = date + ) + } + + fun formatDate(format: String, date: String): String { + return formatDate(format, iso8601ToDate(date)) + } + + fun formatDate(format: String, date: Date?): String { if (date == null) { return "" } - val sdf = SimpleDateFormat( - resourceManager.getString(R.string.core_date_format_MMMM_dd), - Locale.getDefault() - ) + val sdf = SimpleDateFormat(format, Locale.getDefault()) return sdf.format(date) } + fun stringToDate(dateFormat: String, date: String): Date? { + if (dateFormat.isEmpty() || date.isEmpty()) { + return null + } + return SimpleDateFormat(dateFormat, Locale.getDefault()).parse(date) + } + /** * Checks if the given date is past today. * @@ -70,6 +99,25 @@ object TimeUtils { return otherDate != null && today.after(otherDate) } + /** + * This function compare the provide date with current date + * @param today Today's date. + * @param otherDate Other date to cross-match with today's date. + * @return true if the other date is due today, + */ + fun isDueDate(today: Date, otherDate: Date?): Boolean { + return otherDate != null && today.before(otherDate) + } + + /** + * This function compare the provide date are same + * @return true if the provided date are same else false + */ + fun areDatesSame(date: Date?, otherDate: Date?): Boolean { + return date != null && otherDate != null && + formatDate(FORMAT_DATE, date) == formatDate(FORMAT_DATE, otherDate) + } + fun getCourseFormattedDate( context: Context, today: Date, @@ -79,7 +127,7 @@ object TimeUtils { startType: String, startDisplay: String ): String { - var formattedDate = "" + val formattedDate: String val resourceManager = ResourceManager(context) if (isDatePassed(today, start)) { @@ -98,8 +146,10 @@ object TimeUtils { ) } else { val timeSpan = DateUtils.getRelativeTimeSpanString( - expiry.time, today.time, - DateUtils.SECOND_IN_MILLIS, DateUtils.FORMAT_ABBREV_RELATIVE + expiry.time, + today.time, + DateUtils.SECOND_IN_MILLIS, + DateUtils.FORMAT_ABBREV_RELATIVE ).toString() resourceManager.getString(R.string.core_label_expired, timeSpan) } @@ -111,8 +161,10 @@ object TimeUtils { ) } else { val timeSpan = DateUtils.getRelativeTimeSpanString( - expiry.time, today.time, - DateUtils.SECOND_IN_MILLIS, DateUtils.FORMAT_ABBREV_RELATIVE + expiry.time, + today.time, + DateUtils.SECOND_IN_MILLIS, + DateUtils.FORMAT_ABBREV_RELATIVE ).toString() resourceManager.getString(R.string.core_label_expires, timeSpan) } @@ -131,13 +183,11 @@ object TimeUtils { } } else if (isDatePassed(today, end)) { resourceManager.getString( - R.string.core_label_ended, - dateToCourseDate(resourceManager, end) + R.string.core_label_ended, dateToCourseDate(resourceManager, end) ) } else { resourceManager.getString( - R.string.core_label_ending, - dateToCourseDate(resourceManager, end) + R.string.core_label_ending, dateToCourseDate(resourceManager, end) ) } } @@ -155,5 +205,4 @@ object TimeUtils { } return formattedDate } - -} \ No newline at end of file +} diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index f2584d60a..e4ed83ff7 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -1,5 +1,5 @@ - + @string/platform_name Results @@ -54,6 +54,21 @@ Tap to update to version %1$s Tap to install required app update + + + Today + + Verified Only + + Completed + + Past Due + + Due Next + + Not Yet Released + + %1$s profile image - \ No newline at end of file + diff --git a/course/build.gradle b/course/build.gradle index fdf14b83d..f746f4d09 100644 --- a/course/build.gradle +++ b/course/build.gradle @@ -65,6 +65,7 @@ dependencies { implementation "androidx.media3:media3-exoplayer-hls:$media3_version" implementation "androidx.media3:media3-ui:$media3_version" implementation "androidx.media3:media3-cast:$media3_version" + implementation "me.saket.extendedspans:extendedspans:$extented_spans_version" androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' diff --git a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt index f31d31bdf..637a5ad7b 100644 --- a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt +++ b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt @@ -1,5 +1,7 @@ package org.openedx.course.data.repository +import kotlinx.coroutines.flow.map +import okhttp3.ResponseBody import org.openedx.core.data.api.CourseApi import org.openedx.core.data.model.BlocksCompletionBody import org.openedx.core.data.model.EnrollBody @@ -9,8 +11,6 @@ import org.openedx.core.domain.model.* import org.openedx.core.exception.NoCachedDataException import org.openedx.core.module.db.DownloadDao import org.openedx.course.data.storage.CourseDao -import kotlinx.coroutines.flow.map -import okhttp3.ResponseBody class CourseRepository( private val api: CourseApi, @@ -99,9 +99,10 @@ class CourseRepository( return api.markBlocksCompletion(blocksCompletionBody) } + suspend fun getCourseDates(courseId: String) = api.getCourseDates(courseId).mapToDomain() + suspend fun getHandouts(courseId: String) = api.getHandouts(courseId).mapToDomain() suspend fun getAnnouncements(courseId: String) = api.getAnnouncements(courseId).map { it.mapToDomain() } - -} \ No newline at end of file +} diff --git a/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt b/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt index ac8d163ec..8bcd2c40a 100644 --- a/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt +++ b/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt @@ -71,6 +71,8 @@ class CourseInteractor( suspend fun getCourseStatus(courseId: String) = repository.getCourseStatus(courseId) + suspend fun getCourseDates(courseId: String) = repository.getCourseDates(courseId) + suspend fun getHandouts(courseId: String) = repository.getHandouts(courseId) suspend fun getAnnouncements(courseId: String) = repository.getAnnouncements(courseId) 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 5cea3d63f..cdae67678 100644 --- a/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt +++ b/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt @@ -15,5 +15,6 @@ interface CourseAnalytics { fun courseTabClickedEvent(courseId: String, courseName: String) fun videoTabClickedEvent(courseId: String, courseName: String) fun discussionTabClickedEvent(courseId: String, courseName: String) + fun datesTabClickedEvent(courseId: String, courseName: String) fun handoutsTabClickedEvent(courseId: String, courseName: String) -} \ No newline at end of file +} 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 17363a01b..acefd4020 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 @@ -7,17 +7,18 @@ import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.viewpager2.widget.ViewPager2 import com.google.android.material.snackbar.Snackbar +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.presentation.global.viewBinding import org.openedx.course.R import org.openedx.course.databinding.FragmentCourseContainerBinding import org.openedx.course.presentation.CourseRouter +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.videos.CourseVideosFragment import org.openedx.discussion.presentation.topics.DiscussionTopicsFragment -import org.koin.android.ext.android.inject -import org.koin.androidx.viewmodel.ext.android.viewModel -import org.koin.core.parameter.parametersOf class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { @@ -64,9 +65,14 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { binding.viewPager.setCurrentItem(2, false) } + R.id.dates -> { + viewModel.datesTabClickedEvent() + binding.viewPager.setCurrentItem(3, false) + } + R.id.resources -> { viewModel.handoutsTabClickedEvent() - binding.viewPager.setCurrentItem(3, false) + binding.viewPager.setCurrentItem(4, false) } } true @@ -110,13 +116,14 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { private fun initViewPager() { binding.viewPager.orientation = ViewPager2.ORIENTATION_HORIZONTAL - binding.viewPager.offscreenPageLimit = 4 adapter = CourseContainerAdapter(this).apply { addFragment(CourseOutlineFragment.newInstance(viewModel.courseId, courseTitle)) addFragment(CourseVideosFragment.newInstance(viewModel.courseId, courseTitle)) addFragment(DiscussionTopicsFragment.newInstance(viewModel.courseId, courseTitle)) + addFragment(CourseDatesFragment.newInstance(viewModel.courseId, courseTitle)) addFragment(HandoutsFragment.newInstance(viewModel.courseId)) } + binding.viewPager.offscreenPageLimit = adapter?.itemCount ?: 1 binding.viewPager.adapter = adapter binding.viewPager.isUserInputEnabled = false } 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 1fb6e9aa5..33b79e9c1 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 @@ -100,6 +100,10 @@ class CourseContainerViewModel( analytics.discussionTabClickedEvent(courseId, courseName) } + fun datesTabClickedEvent() { + analytics.datesTabClickedEvent(courseId, courseName) + } + fun handoutsTabClickedEvent() { analytics.handoutsTabClickedEvent(courseId, courseName) } diff --git a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesFragment.kt b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesFragment.kt new file mode 100644 index 000000000..89607a653 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesFragment.kt @@ -0,0 +1,693 @@ +package org.openedx.course.presentation.dates + +import android.content.res.Configuration +import android.os.Bundle +import android.text.TextUtils +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +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.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent +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.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.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.graphics.Color +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.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +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.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.zIndex +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import me.saket.extendedspans.ExtendedSpans +import me.saket.extendedspans.RoundedCornerSpanPainter +import me.saket.extendedspans.drawBehind +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf +import org.openedx.core.UIMessage +import org.openedx.core.domain.model.CourseDateBlock +import org.openedx.core.presentation.course.CourseDatesBadge +import org.openedx.core.ui.BackBtn +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.statusBarsInset +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes +import org.openedx.core.ui.theme.appTypography +import org.openedx.core.ui.windowSizeValue +import org.openedx.core.utils.TimeUtils +import org.openedx.course.R +import java.util.Date + +class CourseDatesFragment : Fragment() { + + private val viewModel by viewModel { + parametersOf(requireArguments().getString(ARG_COURSE_ID, "")) + } + + 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() + val uiMessage by viewModel.uiMessage.observeAsState() + val refreshing by viewModel.updating.observeAsState(false) + + CourseDatesScreen(windowSize = windowSize, + uiState = uiState, + courseTitle = viewModel.courseTitle, + uiMessage = uiMessage, + refreshing = refreshing, + hasInternetConnection = viewModel.hasInternetConnection, + onReloadClick = { + viewModel.getCourseDates() + }, + onSwipeRefresh = { + viewModel.getCourseDates() + }, + onBackClick = { + requireActivity().supportFragmentManager.popBackStack() + }) + } + } + } + + companion object { + private const val ARG_COURSE_ID = "courseId" + private const val ARG_TITLE = "title" + fun newInstance(courseId: String, title: String): CourseDatesFragment { + val fragment = CourseDatesFragment() + fragment.arguments = bundleOf(ARG_COURSE_ID to courseId, ARG_TITLE to title) + return fragment + } + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +internal fun CourseDatesScreen( + windowSize: WindowSize, + uiState: DatesUIState?, + courseTitle: String, + uiMessage: UIMessage?, + refreshing: Boolean, + hasInternetConnection: Boolean, + onReloadClick: () -> Unit, + onSwipeRefresh: () -> Unit, + onBackClick: () -> Unit +) { + val scaffoldState = rememberScaffoldState() + val pullRefreshState = + rememberPullRefreshState(refreshing = refreshing, onRefresh = { onSwipeRefresh() }) + + var isInternetConnectionShown by rememberSaveable { + mutableStateOf(false) + } + + Scaffold( + modifier = Modifier.fillMaxSize(), + scaffoldState = scaffoldState, + backgroundColor = MaterialTheme.appColors.background + ) { + val modifierScreenWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), + compact = Modifier.fillMaxWidth() + ) + ) + } + + val listBottomPadding by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = PaddingValues(bottom = 24.dp), + compact = PaddingValues(bottom = 24.dp) + ) + ) + } + + HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) + + Box( + modifier = Modifier + .fillMaxSize() + .padding(it) + .statusBarsInset() + .displayCutoutForLandscape(), contentAlignment = Alignment.TopCenter + ) { + Column( + modifierScreenWidth + ) { + Box( + Modifier + .fillMaxWidth() + .zIndex(1f), contentAlignment = Alignment.CenterStart + ) { + BackBtn { + onBackClick() + } + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 56.dp), + text = courseTitle, + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center + ) + } + Spacer(Modifier.height(6.dp)) + Surface( + color = MaterialTheme.appColors.background, + shape = MaterialTheme.appShapes.screenBackgroundShape + ) { + Box( + Modifier + .fillMaxWidth() + .pullRefresh(pullRefreshState) + ) { + uiState?.let { + when (uiState) { + is DatesUIState.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } + } + + is DatesUIState.Dates -> { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(10.dp), + contentPadding = listBottomPadding + ) { + itemsIndexed(uiState.courseDates.keys.toList()) { dateIndex, _ -> + CourseDateBlockSection( + courseDates = uiState.courseDates, + dateIndex = dateIndex + ) + } + } + } + + DatesUIState.Empty -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.course_dates_unavailable_message), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleMedium, + textAlign = TextAlign.Center + ) + } + } + } + } + PullRefreshIndicator( + refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter) + ) + if (!isInternetConnectionShown && !hasInternetConnection) { + OfflineModeDialog( + Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + onDismissCLick = { + isInternetConnectionShown = true + }, + onReloadClick = { + isInternetConnectionShown = true + onReloadClick() + }) + } + } + } + } + } + } +} + +@Composable +private fun CourseDateBlockSection( + courseDates: LinkedHashMap>, dateIndex: Int +) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(intrinsicSize = IntrinsicSize.Min) // this make height of all cards to the tallest card. + .background(MaterialTheme.appColors.background) + ) { + val dateBlockKey = courseDates.keys.toList()[dateIndex] + val dateBlocks = courseDates[dateBlockKey] + dateBlocks?.let { + val dateBlockItem = courseDates[dateBlockKey]?.get(0) + dateBlockItem?.let { + DateBullet( + isFirstIndex = dateIndex == 0, + isLastIndex = dateIndex == courseDates.size - 1, + dateBlock = dateBlockItem + ) + DateBlock(dateBlocks) + } + } + } +} + +@Composable +private fun DateBullet( + isFirstIndex: Boolean = false, + isLastIndex: Boolean = false, + dateBlock: CourseDateBlock +) { + Column( + modifier = Modifier + .width(40.dp) + .padding(start = 6.dp) + ) { + if (!isFirstIndex) { + Box( + modifier = Modifier + .width(1.dp) + .height(6.dp) + .background(color = MaterialTheme.appColors.datesBadgeTextToday) + .align(Alignment.CenterHorizontally) + ) + } else { + Spacer(modifier = Modifier.height(6.dp)) + } + var circleColor: Color = MaterialTheme.appColors.datesBadgeDefault + var circleSize: Dp = 10.dp + when (dateBlock.dateBlockBadge) { + + CourseDatesBadge.TODAY -> { + circleColor = MaterialTheme.appColors.datesBadgeToday + circleSize = 14.dp + } + + CourseDatesBadge.PAST_DUE -> { + circleColor = MaterialTheme.appColors.datesBadgePastDue + } + + CourseDatesBadge.BLANK, + CourseDatesBadge.COMPLETED, + CourseDatesBadge.DUE_NEXT, + CourseDatesBadge.NOT_YET_RELEASED, + CourseDatesBadge.COURSE_EXPIRED_DATE, + CourseDatesBadge.VERIFIED_ONLY -> { + var isDatePassed = false + dateBlock.date?.let { + isDatePassed = TimeUtils.isDatePassed(Date(), it) + } + circleColor = + if (isDatePassed && (dateBlock.dateBlockBadge == CourseDatesBadge.VERIFIED_ONLY).not()) { + MaterialTheme.appColors.datesBadgePastDue + } else { + MaterialTheme.appColors.datesBadgeTextToday + } + } + + else -> {} + } + Box( + modifier = Modifier + .size(circleSize) + .border(1.dp, MaterialTheme.appColors.datesBadgeTextToday, CircleShape) + .clip(CircleShape) + .background(circleColor) + .align(Alignment.CenterHorizontally) + ) + if (!isLastIndex) { + Box( + modifier = Modifier + .width(1.dp) + .fillMaxHeight() + .background(color = MaterialTheme.appColors.datesBadgeTextToday) + .align(Alignment.CenterHorizontally) + ) + } + } +} + +@Composable +private fun DateBlock(dateBlocks: ArrayList) { + Column( + modifier = Modifier + .wrapContentHeight() + .fillMaxWidth() + .padding(start = 16.dp, bottom = 30.dp) + ) { + val firstDateBlock = dateBlocks[0] + PlaceDateBadge( + title = TimeUtils.formatDate(TimeUtils.FORMAT_DATE_TAB, firstDateBlock.date), + titleSize = 18.sp, + blockBadge = firstDateBlock.dateBlockBadge + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + ) { + val parentBadgeAdded = hasSameDateTypes(dateBlocks) + dateBlocks.forEach { courseDateItem -> + CourseDateItem(courseDateItem, parentBadgeAdded) + } + } + } +} + +/** + * Method to create the Date badge as per given DateType + */ +@Composable +private fun PlaceDateBadge(title: String, titleSize: TextUnit, blockBadge: CourseDatesBadge) { + var badgeBackground: Color = Color.Transparent + var textAppearance: Color = Color.Transparent + var badgeStrokeColor: Color = Color.Transparent + var badgeIcon: Painter? = null + when (blockBadge) { + CourseDatesBadge.TODAY -> { + badgeBackground = MaterialTheme.appColors.datesBadgeToday + textAppearance = MaterialTheme.appColors.datesBadgeTextToday + } + + CourseDatesBadge.VERIFIED_ONLY -> { + badgeBackground = MaterialTheme.appColors.datesBadgeTextToday + textAppearance = MaterialTheme.appColors.datesBadgeTextDue + badgeIcon = painterResource(R.drawable.ic_lock) + } + + CourseDatesBadge.COMPLETED -> { + badgeBackground = MaterialTheme.appColors.datesBadgeDefault + textAppearance = MaterialTheme.appColors.datesBadgeTextDefault + } + + CourseDatesBadge.PAST_DUE -> { + badgeBackground = MaterialTheme.appColors.datesBadgePastDue + textAppearance = MaterialTheme.appColors.datesBadgeTextDefault + } + + CourseDatesBadge.DUE_NEXT -> { + badgeBackground = MaterialTheme.appColors.datesBadgeDue + textAppearance = MaterialTheme.appColors.datesBadgeTextDue + } + + CourseDatesBadge.NOT_YET_RELEASED -> { + badgeBackground = Color.Transparent + textAppearance = MaterialTheme.appColors.datesBadgeDue + badgeStrokeColor = MaterialTheme.appColors.datesBadgeDue + } + + else -> {} + } + val extendedSpans = remember { + ExtendedSpans( + RoundedCornerSpanPainter( + cornerRadius = 6.sp, + padding = RoundedCornerSpanPainter.TextPaddingValues( + horizontal = 8.sp, + vertical = 6.sp + ), topMargin = 5.sp, + bottomMargin = 4.sp, + stroke = RoundedCornerSpanPainter.Stroke( + color = badgeStrokeColor + ) + ) + ) + } + val titleWithBadge = buildAnnotatedString { + append(title) + append(" ") + withStyle( + SpanStyle( + color = textAppearance, + background = badgeBackground, + fontWeight = FontWeight.SemiBold, + fontStyle = FontStyle.Italic, + fontSize = 16.sp + ) + ) { + if (badgeIcon != null) { + appendInlineContent("icon_id") + append(" ") + } + val badgeTitle = blockBadge.getStringResIdForDateType() + if (badgeTitle != -1) { + append(stringResource(id = badgeTitle)) + } + } + } + val inlineContent = HashMap() + badgeIcon?.let { + inlineContent["icon_id"] = InlineTextContent( + Placeholder( + width = 16.sp, + height = 16.sp, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center + ) + ) { + Icon(badgeIcon, "", tint = MaterialTheme.appColors.datesBadgeTextDue) + } + } + Text( + modifier = Modifier.drawBehind(extendedSpans), + text = remember(titleWithBadge) { + extendedSpans.extend(titleWithBadge) + }, + onTextLayout = { result -> + extendedSpans.onTextLayout(result) + }, + inlineContent = inlineContent, + fontSize = titleSize, + fontWeight = FontWeight.SemiBold, + lineHeight = 22.sp + ) +} + +@Composable +private fun CourseDateItem(courseDateItem: CourseDateBlock, parentBadgeAdded: Boolean) { + Column( + modifier = Modifier + .wrapContentHeight() + .fillMaxWidth() + .padding(vertical = 4.dp) + ) { + if (!parentBadgeAdded) { + // Set update badge with sub date items + PlaceDateBadge(courseDateItem.title, 16.sp, courseDateItem.dateBlockBadge) + } else { + Text( + text = courseDateItem.title, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + lineHeight = 20.sp + ) + } + if (!TextUtils.isEmpty(courseDateItem.description)) { + Text( + text = courseDateItem.description, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + lineHeight = 18.sp + ) + } + } +} + +/** + * Method to check that all Date Items have same badge status or not + * + * @return true if all the date items have update badge status else false + * */ +private fun hasSameDateTypes(dateBlockItems: ArrayList?): Boolean { + if (!dateBlockItems.isNullOrEmpty() && dateBlockItems.size > 1) { + val dateType = dateBlockItems.first().dateBlockBadge + for (i in 1 until dateBlockItems.size) { + if (dateBlockItems[i].dateBlockBadge != dateType && dateBlockItems[i].dateBlockBadge != CourseDatesBadge.BLANK) { + return false + } + } + } + return true +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun CourseDatesScreenPreview() { + OpenEdXTheme { + CourseDatesScreen(windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + uiState = DatesUIState.Dates(mockedCourseDates), + courseTitle = "Course Dates", + uiMessage = null, + hasInternetConnection = true, + refreshing = false, + onSwipeRefresh = {}, + onReloadClick = {}, + onBackClick = {}) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, device = Devices.NEXUS_9) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.NEXUS_9) +@Composable +private fun CourseDatesScreenTabletPreview() { + OpenEdXTheme { + CourseDatesScreen(windowSize = WindowSize(WindowType.Medium, WindowType.Medium), + uiState = DatesUIState.Dates(mockedCourseDates), + courseTitle = "Course Dates", + uiMessage = null, + hasInternetConnection = true, + refreshing = false, + onSwipeRefresh = {}, + onReloadClick = {}, + onBackClick = {}) + } +} + +private var mockedCourseDates = linkedMapOf( + Pair( + "2023-10-20T15:08:07Z", arrayListOf( + CourseDateBlock( + title = "Course Start", + description = "After this date, course content will be archived", + date = TimeUtils.iso8601ToDate("2023-10-20T15:08:07Z"), + dateBlockBadge = CourseDatesBadge.PAST_DUE + ) + ) + ), Pair( + "2023-10-21T15:08:07Z", arrayListOf( + CourseDateBlock( + title = "Today", + description = "After this date, course content will be archived", + date = TimeUtils.iso8601ToDate("2023-10-21T15:08:07Z"), + dateBlockBadge = CourseDatesBadge.TODAY + ) + ) + ), + Pair( + "2023-10-22T15:08:07Z", arrayListOf( + CourseDateBlock( + title = "Due Next", + description = "After this date, course content will be archived", + date = TimeUtils.iso8601ToDate("2023-10-22T15:08:07Z"), + dateBlockBadge = CourseDatesBadge.DUE_NEXT + ) + ) + ), Pair( + "2023-10-23T15:08:07Z", arrayListOf( + CourseDateBlock( + title = "Assignment Due", + description = "After this date, course content will be archived", + date = TimeUtils.iso8601ToDate("2023-10-23T15:08:07Z"), + dateBlockBadge = CourseDatesBadge.VERIFIED_ONLY + ) + ) + ), Pair( + "2023-10-24T15:08:07Z", arrayListOf( + CourseDateBlock( + title = "Not Yet Released", + description = "After this date, course content will be archived", + date = TimeUtils.iso8601ToDate("2023-10-24T15:08:07Z"), + dateBlockBadge = CourseDatesBadge.NOT_YET_RELEASED + ) + ) + ), Pair( + "2023-10-25T15:08:07Z", arrayListOf( + CourseDateBlock( + title = "Blank", + description = "After this date, course content will be archived", + date = TimeUtils.iso8601ToDate("2023-10-25T15:08:07Z"), + dateBlockBadge = CourseDatesBadge.BLANK + ) + ) + ), Pair( + "2023-10-26T15:08:07Z", arrayListOf( + CourseDateBlock( + title = "Course End", + description = "After this date, course content will be archived", + date = TimeUtils.iso8601ToDate("2023-10-26T15:08:07Z"), + dateBlockBadge = CourseDatesBadge.COMPLETED + ) + ) + ) +) 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 new file mode 100644 index 000000000..9f29c223b --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt @@ -0,0 +1,71 @@ +package org.openedx.course.presentation.dates + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch +import org.openedx.core.BaseViewModel +import org.openedx.core.R +import org.openedx.core.SingleEventLiveData +import org.openedx.core.UIMessage +import org.openedx.core.extension.isInternetError +import org.openedx.core.system.ResourceManager +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.course.domain.interactor.CourseInteractor + +class CourseDatesViewModel( + val courseId: String, + private val interactor: CourseInteractor, + private val networkConnection: NetworkConnection, + private val resourceManager: ResourceManager, +) : BaseViewModel() { + + private val _uiState = MutableLiveData(DatesUIState.Loading) + val uiState: LiveData + get() = _uiState + + private val _uiMessage = SingleEventLiveData() + val uiMessage: LiveData + get() = _uiMessage + + private val _updating = MutableLiveData() + val updating: LiveData + get() = _updating + + var courseTitle = "" + + val hasInternetConnection: Boolean + get() = networkConnection.isOnline() + + init { + getCourseDates() + } + + fun getCourseDates() { + _uiState.value = DatesUIState.Loading + loadingCourseDatesInternal() + } + + private fun loadingCourseDatesInternal() { + viewModelScope.launch { + try { + _updating.value = true + val datesResponse = interactor.getCourseDates(courseId = courseId) + if (datesResponse.isEmpty()) { + _uiState.value = DatesUIState.Empty + } else { + _uiState.value = DatesUIState.Dates(datesResponse) + } + } 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)) + } + } + _updating.value = false + } + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/dates/DashboardUIState.kt b/course/src/main/java/org/openedx/course/presentation/dates/DashboardUIState.kt new file mode 100644 index 000000000..975748ae4 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/dates/DashboardUIState.kt @@ -0,0 +1,11 @@ +package org.openedx.course.presentation.dates + +import org.openedx.core.domain.model.CourseDateBlock + +sealed class DatesUIState { + data class Dates(val courseDates: LinkedHashMap>) : + DatesUIState() + + object Empty : DatesUIState() + object Loading : DatesUIState() +} diff --git a/course/src/main/res/drawable/ic_calendar_month.xml b/course/src/main/res/drawable/ic_calendar_month.xml new file mode 100644 index 000000000..434cf9907 --- /dev/null +++ b/course/src/main/res/drawable/ic_calendar_month.xml @@ -0,0 +1,5 @@ + + + diff --git a/course/src/main/res/drawable/ic_lock.xml b/course/src/main/res/drawable/ic_lock.xml new file mode 100644 index 000000000..68cb9c1f5 --- /dev/null +++ b/course/src/main/res/drawable/ic_lock.xml @@ -0,0 +1,9 @@ + + + diff --git a/course/src/main/res/menu/bottom_course_container_menu.xml b/course/src/main/res/menu/bottom_course_container_menu.xml index 8ee927041..e65c6ea5e 100644 --- a/course/src/main/res/menu/bottom_course_container_menu.xml +++ b/course/src/main/res/menu/bottom_course_container_menu.xml @@ -19,6 +19,12 @@ android:enabled="true" android:icon="@drawable/ic_course_navigation_discussions"/> + + - + Enroll now View course Course details @@ -47,4 +47,10 @@ Continue with: Continue To proceed with \"%s\" press \"Next section\". - \ No newline at end of file + Dates + + + + Course dates are not currently available. + + 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 new file mode 100644 index 000000000..d65f1fa4b --- /dev/null +++ b/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt @@ -0,0 +1,135 @@ +package org.openedx.course.presentation.dates + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.openedx.core.R +import org.openedx.core.UIMessage +import org.openedx.core.data.model.DateType +import org.openedx.core.domain.model.CourseDateBlock +import org.openedx.core.system.ResourceManager +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.course.domain.interactor.CourseInteractor +import java.net.UnknownHostException +import java.util.Date + +@OptIn(ExperimentalCoroutinesApi::class) +class CourseDatesViewModelTest { + @get:Rule + val testInstantTaskExecutorRule: TestRule = InstantTaskExecutorRule() + + private val dispatcher = StandardTestDispatcher() + + private val resourceManager = mockk() + private val interactor = mockk() + private val networkConnection = mockk() + + private val noInternet = "Slow or no internet connection" + private val somethingWrong = "Something went wrong" + + private val dateBlock = CourseDateBlock( + complete = false, + date = Date(), + dateType = DateType.TODAY_DATE, + description = "Mocked Course Date Description" + ) + private val mockDateBlocks = linkedMapOf( + Pair( + "2023-10-20T15:08:07Z", + arrayListOf(dateBlock, dateBlock) + ), + Pair( + "2023-10-30T15:08:07Z", + arrayListOf(dateBlock, dateBlock) + ) + ) + + @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 + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `getCourseDates no internet connection exception`() = runTest { + val viewModel = CourseDatesViewModel("", interactor, networkConnection, resourceManager) + every { networkConnection.isOnline() } returns true + coEvery { interactor.getCourseDates(any()) } throws UnknownHostException() + advanceUntilIdle() + + coVerify(exactly = 1) { interactor.getCourseDates(any()) } + + val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage + + Assert.assertEquals(noInternet, message?.message) + assert(viewModel.updating.value == false) + assert(viewModel.uiState.value is DatesUIState.Loading) + } + + @Test + fun `getCourseDates unknown exception`() = runTest { + val viewModel = CourseDatesViewModel("", interactor, networkConnection, resourceManager) + every { networkConnection.isOnline() } returns true + coEvery { interactor.getCourseDates(any()) } throws Exception() + 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(viewModel.uiState.value is DatesUIState.Loading) + } + + @Test + fun `getCourseDates success with internet`() = runTest { + val viewModel = CourseDatesViewModel("", interactor, networkConnection, resourceManager) + every { networkConnection.isOnline() } returns true + coEvery { interactor.getCourseDates(any()) } returns mockDateBlocks + + advanceUntilIdle() + + coVerify(exactly = 1) { interactor.getCourseDates(any()) } + + assert(viewModel.uiMessage.value == null) + assert(viewModel.updating.value == false) + assert(viewModel.uiState.value is DatesUIState.Dates) + } + + @Test + fun `getCourseDates success with EmptyList`() = runTest { + val viewModel = CourseDatesViewModel("", interactor, networkConnection, resourceManager) + every { networkConnection.isOnline() } returns true + coEvery { interactor.getCourseDates(any()) } returns linkedMapOf() + + advanceUntilIdle() + + coVerify(exactly = 1) { interactor.getCourseDates(any()) } + + assert(viewModel.uiMessage.value == null) + assert(viewModel.updating.value == false) + assert(viewModel.uiState.value is DatesUIState.Empty) + } +}