diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index fe5aa5869..3d93935a0 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -42,7 +42,7 @@ jobs: run: ./gradlew testProdDebugUnitTest $CI_GRADLE_ARG_PROPERTIES - name: Upload reports - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: failure() with: name: failures diff --git a/.github/workflows/validate-english-strings.yml b/.github/workflows/validate-english-strings.yml new file mode 100644 index 000000000..43935b05e --- /dev/null +++ b/.github/workflows/validate-english-strings.yml @@ -0,0 +1,32 @@ +name: Validate English strings.xml + +on: + pull_request: { } + push: + branches: [ main, develop ] + +jobs: + translation_strings: + name: Validate strings.xml + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Use Python + uses: actions/setup-python@v5 + with: + python-version: 3.11 + + - name: Install translations requirements + run: make translation_requirements + + - name: Validate English plurals in strings.xml + run: make validate_english_plurals + + - name: Test extract strings + run: | + make extract_translations + # Ensure the file is extracted + test -f i18n/src/main/res/values/strings.xml diff --git a/.gitignore b/.gitignore index 1cc8ec083..1152644c7 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,6 @@ local.properties /.idea/ *.log /config_settings.yaml +.venv/ +i18n/ +**/values-*/strings.xml diff --git a/Documentation/ConfigurationManagement.md b/Documentation/ConfigurationManagement.md index b1e21a50b..548e84759 100644 --- a/Documentation/ConfigurationManagement.md +++ b/Documentation/ConfigurationManagement.md @@ -49,7 +49,6 @@ TOKEN_TYPE: "JWT" FIREBASE: ENABLED: false - ANALYTICS_SOURCE: '' CLOUD_MESSAGING_ENABLED: false PROJECT_NUMBER: '' PROJECT_ID: '' @@ -82,14 +81,14 @@ android: - **Facebook:** Sign in and Sign up via Facebook - **Branch:** Deeplinks - **Braze:** Cloud Messaging -- **SegmentIO:** Analytics ## Available Feature Flags - **PRE_LOGIN_EXPERIENCE_ENABLED:** Enables the pre login courses discovery experience. - **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_UNIT_PROGRESS_ENABLED:** Enables the display of the unit progress within the courseware. +- **COURSE_DROPDOWN_NAVIGATION_ENABLED:** Enables an alternative navigation through units. +- **COURSE_UNIT_PROGRESS_ENABLED:** Enables the display of the unit progress within the courseware. +- **REGISTRATION_ENABLED:** Enables user registration from the app. ## Future Support - To add config related to some other service, create a class, e.g. `ServiceNameConfig.kt`, to be able to populate related fields. diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..a0ba67b45 --- /dev/null +++ b/Makefile @@ -0,0 +1,25 @@ +clean_translations_temp_directory: + rm -rf i18n/ + +translation_requirements: + pip3 install -r i18n_scripts/requirements.txt + +pull_translations: clean_translations_temp_directory + atlas pull $(ATLAS_OPTIONS) translations/openedx-app-android/i18n:i18n + python3 i18n_scripts/translation.py --split --replace-underscore + +extract_translations: clean_translations_temp_directory + python3 i18n_scripts/translation.py --combine + +validate_english_plurals: + @if git grep 'quantity' -- '**/res/values/strings.xml' | grep -E 'quantity=.(zero|two|few|many)'; then \ + echo ""; \ + echo ""; \ + echo "Error: Found invalid plurals in the files listed above."; \ + echo " Please only use 'one' and 'other' in English strings.xml files,"; \ + echo " otherwise Transifex fails to parse them."; \ + echo ""; \ + exit 1; \ + else \ + echo "strings.xml files are valid."; \ + fi diff --git a/README.md b/README.md index c8453877a..a3ecff99f 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,48 @@ Modern vision of the mobile application for the Open edX platform from Raccoon G 6. Click the **Run** button. +## Translations + +### Getting Translations for the App +Translations aren't included in the source code of this repository as of [OEP-58](https://docs.openedx.org/en/latest/developers/concepts/oep58.html). Therefore, they need to be pulled before testing or publishing to App Store. + +Before retrieving the translations for the app, we need to install the requirements listed in the requirements.txt file located in the i18n_scripts directory. This can be done easily by running the following make command: +```bash +make translation_requirements +``` + +Then, to get the latest translations for all languages use the following command: +```bash +make pull_translations +``` +This command runs [`atlas pull`](https://github.com/openedx/openedx-atlas) to download the latest translations files from the [openedx/openedx-translations](https://github.com/openedx/openedx-translations) repository. These files contain the latest translations for all languages. In the [openedx/openedx-translations](https://github.com/openedx/openedx-translations) repository each language's translations are saved as a single file e.g. `i18n/src/main/res/values-uk/strings.xml` ([example](https://github.com/openedx/openedx-translations/blob/04ccea36b8e6a9889646dfb5a5acb99686fa9ae0/translations/openedx-app-android/i18n/src/main/res/values-uk/strings.xml)). After these are pulled, each language's translation file is split into the App's modules e.g. `auth/src/main/res/values-uk/strings.xml`. + + After this command is run the application can load the translations by changing the device (or the emulator) language in the settings. + +### Using Custom Translations + +By default, the command `make pull_translations` runs [`atlas pull`](https://github.com/openedx/openedx-atlas) with no arguments which pulls translations from the [openedx-translations repository](https://github.com/openedx/openedx-translations). + +You can use custom translations on your fork of the openedx-translations repository by setting the following configuration parameters: + +- `--revision` (default: `"main"`): Branch or git tag to pull translations from. +- `--repository` (default: `"openedx/openedx-translations"`): GitHub repository slug. There's a feature request to [support GitLab and other providers](https://github.com/openedx/openedx-atlas/issues/20). + +Arguments can be passed via the `ATLAS_OPTIONS` environment variable as shown below: +``` bash +make ATLAS_OPTIONS='--repository=/ --revision=' pull_translations +``` +Additional arguments can be passed to `atlas pull`. Refer to the [atlas documentations ](https://github.com/openedx/openedx-atlas) for more information. + +### How to Translate the App + +Translations are managed in the [open-edx/openedx-translations](https://app.transifex.com/open-edx/openedx-translations/dashboard/) Transifex project. + +To translate the app join the [Transifex project](https://app.transifex.com/open-edx/openedx-translations/dashboard/) and add your translations to the +[`openedx-app-android`](https://app.transifex.com/open-edx/openedx-translations/openedx-app-android/) resource. + +Once the resource is both 100% translated and reviewed the [Transifex integration](https://github.com/apps/transifex-integration) will automatically push it to the [openedx-translations](https://github.com/openedx/openedx-translations) repository and developers can use the translations in their app. + ## API This project targets on the latest Open edX release and rely on the relevant mobile APIs. diff --git a/app/build.gradle b/app/build.gradle index 2b0ab4f74..baabb18d2 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,13 +1,15 @@ def config = configHelper.fetchConfig() def appId = config.getOrDefault("APPLICATION_ID", "org.openedx.app") -def platformName = config.getOrDefault("PLATFORM_NAME", "OpenEdx").toLowerCase() +def themeDirectory = config.getOrDefault("THEME_DIRECTORY", "openedx") def firebaseConfig = config.get('FIREBASE') def firebaseEnabled = firebaseConfig?.getOrDefault('ENABLED', false) apply plugin: 'com.android.application' apply plugin: 'org.jetbrains.kotlin.android' apply plugin: 'kotlin-parcelize' -apply plugin: 'kotlin-kapt' +apply plugin: 'com.google.devtools.ksp' +apply plugin: 'org.jetbrains.kotlin.plugin.compose' + if (firebaseEnabled) { apply plugin: 'com.google.gms.google-services' apply plugin: 'com.google.firebase.crashlytics' @@ -63,13 +65,13 @@ android { sourceSets { prod { - res.srcDirs = ["src/$platformName/res"] + res.srcDirs = ["src/$themeDirectory/res"] } develop { - res.srcDirs = ["src/$platformName/res"] + res.srcDirs = ["src/$themeDirectory/res"] } stage { - res.srcDirs = ["src/$platformName/res"] + res.srcDirs = ["src/$themeDirectory/res"] } } @@ -91,15 +93,13 @@ android { } kotlinOptions { jvmTarget = JavaVersion.VERSION_17 + freeCompilerArgs = List.of("-Xstring-concat=inline") } buildFeatures { viewBinding true compose true buildConfig true } - composeOptions { - kotlinCompilerExtensionVersion = "$compose_compiler_version" - } bundle { language { enableSplit = false @@ -126,34 +126,24 @@ dependencies { implementation project(path: ':discussion') implementation project(path: ':whatsnew') - kapt "androidx.room:room-compiler:$room_version" + ksp "androidx.room:room-compiler:$room_version" implementation 'androidx.core:core-splashscreen:1.0.1' - // Segment Library - implementation "com.segment.analytics.kotlin:android:1.14.2" - // Segment's Firebase integration - implementation 'com.segment.analytics.kotlin.destinations:firebase:1.5.2' + api platform("com.google.firebase:firebase-bom:$firebase_version") + api "com.google.firebase:firebase-messaging" + // Braze SDK Integration - implementation "com.braze:braze-segment-kotlin:1.4.2" implementation "com.braze:android-sdk-ui:30.2.0" - // Firebase Cloud Messaging Integration for Braze - implementation 'com.google.firebase:firebase-messaging-ktx:23.4.1' - - // Branch SDK Integration - implementation 'io.branch.sdk.android:library:5.9.0' - implementation 'com.google.android.gms:play-services-ads-identifier:18.0.1' - implementation "com.android.installreferrer:installreferrer:2.2" + // Plugins + implementation("com.github.openedx:openedx-app-firebase-analytics-android:1.0.0") - androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' - androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version" - debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version" + androidTestImplementation 'androidx.test.ext:junit:1.2.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' testImplementation "junit:junit:$junit_version" testImplementation "io.mockk:mockk:$mockk_version" testImplementation "io.mockk:mockk-android:$mockk_version" - testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version" testImplementation "androidx.arch.core:core-testing:$android_arch_version" } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index dc403e8f7..825176c61 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,66 +1,3 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile - -#====================/////Retrofit Rules\\\\\=============== -# Retrofit does reflection on generic parameters. InnerClasses is required to use Signature and -# EnclosingMethod is required to use InnerClasses. --keepattributes Signature, InnerClasses, EnclosingMethod - -# Retrofit does reflection on method and parameter annotations. --keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations - -# Keep annotation default values (e.g., retrofit2.http.Field.encoded). --keepattributes AnnotationDefault - -# Retain service method parameters when optimizing. --keepclassmembers,allowshrinking,allowobfuscation interface * { - @retrofit2.http.* ; -} - -# Ignore JSR 305 annotations for embedding nullability information. --dontwarn javax.annotation.** - -# Guarded by a NoClassDefFoundError try/catch and only used when on the classpath. --dontwarn kotlin.Unit - -# Top-level functions that can only be used by Kotlin. --dontwarn retrofit2.KotlinExtensions --dontwarn retrofit2.KotlinExtensions$* - -# With R8 full mode, it sees no subtypes of Retrofit interfaces since they are created with a Proxy -# and replaces all potential values with null. Explicitly keeping the interfaces prevents this. --if interface * { @retrofit2.http.* ; } --keep,allowobfuscation interface <1> - -# Keep generic signature of Call, Response (R8 full mode strips signatures from non-kept items). --keep,allowobfuscation,allowshrinking interface retrofit2.Call --keep,allowobfuscation,allowshrinking class retrofit2.Response - -# With R8 full mode generic signatures are stripped for classes that are not -# kept. Suspend functions are wrapped in continuations where the type argument -# is used. --keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation - -#===============/////GSON RULES \\\\\\\============ ##---------------Begin: proguard configuration for Gson ---------- # Gson uses generic type information stored in a class file when working with fields. Proguard # removes such information by default, so configure it to keep all of it. @@ -69,12 +6,8 @@ # For using GSON @Expose annotation -keepattributes *Annotation* -# Gson specific classes --dontwarn sun.misc.** -#-keep class com.google.gson.stream.** { *; } - # Application classes that will be serialized/deserialized over Gson --keep class org.openedx.*.data.model.** { ; } +-keepclassmembers class org.openedx.**.data.model.** { *; } # Prevent proguard from stripping interface information from TypeAdapter, TypeAdapterFactory, # JsonSerializer, JsonDeserializer instances (so they can be used in @JsonAdapter) @@ -85,13 +18,13 @@ # Prevent R8 from leaving Data object members always null -keepclassmembers,allowobfuscation class * { + (); @com.google.gson.annotations.SerializedName ; } # Retain generic signatures of TypeToken and its subclasses with R8 version 3.0 and higher. -keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken -keep,allowobfuscation,allowshrinking class * extends com.google.gson.reflect.TypeToken - ##---------------End: proguard configuration for Gson ---------- -keepclassmembers class * extends java.lang.Enum { @@ -108,4 +41,29 @@ -dontwarn org.conscrypt.ConscryptHostnameVerifier -dontwarn org.openjsse.javax.net.ssl.SSLParameters -dontwarn org.openjsse.javax.net.ssl.SSLSocket --dontwarn org.openjsse.net.ssl.OpenJSSE \ No newline at end of file +-dontwarn org.openjsse.net.ssl.OpenJSSE +-dontwarn com.google.crypto.tink.subtle.Ed25519Sign$KeyPair +-dontwarn com.google.crypto.tink.subtle.Ed25519Sign +-dontwarn com.google.crypto.tink.subtle.Ed25519Verify +-dontwarn com.google.crypto.tink.subtle.X25519 +-dontwarn edu.umd.cs.findbugs.annotations.NonNull +-dontwarn edu.umd.cs.findbugs.annotations.Nullable +-dontwarn edu.umd.cs.findbugs.annotations.SuppressFBWarnings +-dontwarn org.bouncycastle.asn1.ASN1Encodable +-dontwarn org.bouncycastle.asn1.pkcs.PrivateKeyInfo +-dontwarn org.bouncycastle.asn1.x509.AlgorithmIdentifier +-dontwarn org.bouncycastle.asn1.x509.SubjectPublicKeyInfo +-dontwarn org.bouncycastle.cert.X509CertificateHolder +-dontwarn org.bouncycastle.cert.jcajce.JcaX509CertificateHolder +-dontwarn org.bouncycastle.crypto.BlockCipher +-dontwarn org.bouncycastle.crypto.CipherParameters +-dontwarn org.bouncycastle.crypto.InvalidCipherTextException +-dontwarn org.bouncycastle.crypto.engines.AESEngine +-dontwarn org.bouncycastle.crypto.modes.GCMBlockCipher +-dontwarn org.bouncycastle.crypto.params.AEADParameters +-dontwarn org.bouncycastle.crypto.params.KeyParameter +-dontwarn org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider +-dontwarn org.bouncycastle.jce.provider.BouncyCastleProvider +-dontwarn org.bouncycastle.openssl.PEMKeyPair +-dontwarn org.bouncycastle.openssl.PEMParser +-dontwarn org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8020f6b74..831fe4a86 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,6 +6,8 @@ + + @@ -39,7 +41,8 @@ android:exported="true" android:fitsSystemWindows="true" android:theme="@style/Theme.App.Starting" - android:windowSoftInputMode="adjustPan"> + android:windowSoftInputMode="adjustPan" + android:launchMode="singleInstance"> @@ -102,15 +105,16 @@ android:foregroundServiceType="dataSync" tools:node="merge" /> - + + diff --git a/app/src/main/java/org/openedx/app/AnalyticsManager.kt b/app/src/main/java/org/openedx/app/AnalyticsManager.kt index 356a23459..aa78f8d04 100644 --- a/app/src/main/java/org/openedx/app/AnalyticsManager.kt +++ b/app/src/main/java/org/openedx/app/AnalyticsManager.kt @@ -1,58 +1,46 @@ package org.openedx.app -import android.content.Context -import org.openedx.app.analytics.Analytics -import org.openedx.app.analytics.FirebaseAnalytics -import org.openedx.app.analytics.SegmentAnalytics import org.openedx.auth.presentation.AuthAnalytics -import org.openedx.core.config.Config import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.presentation.dialog.appreview.AppReviewAnalytics import org.openedx.course.presentation.CourseAnalytics import org.openedx.dashboard.presentation.DashboardAnalytics import org.openedx.discovery.presentation.DiscoveryAnalytics import org.openedx.discussion.presentation.DiscussionAnalytics +import org.openedx.foundation.interfaces.Analytics import org.openedx.profile.presentation.ProfileAnalytics import org.openedx.whatsnew.presentation.WhatsNewAnalytics -class AnalyticsManager( - context: Context, - config: Config, -) : AppAnalytics, AppReviewAnalytics, AuthAnalytics, CoreAnalytics, CourseAnalytics, - DashboardAnalytics, DiscoveryAnalytics, DiscussionAnalytics, ProfileAnalytics, - WhatsNewAnalytics { +class AnalyticsManager : AppAnalytics, AppReviewAnalytics, AuthAnalytics, CoreAnalytics, + CourseAnalytics, DashboardAnalytics, DiscoveryAnalytics, DiscussionAnalytics, + ProfileAnalytics, WhatsNewAnalytics { - private val services: ArrayList = arrayListOf() + private val analytics: MutableList = mutableListOf() - init { - // Initialise all the analytics libraries here - if (config.getFirebaseConfig().enabled) { - addAnalyticsTracker(FirebaseAnalytics(context = context)) - } - val segmentConfig = config.getSegmentConfig() - if (segmentConfig.enabled && segmentConfig.segmentWriteKey.isNotBlank()) { - addAnalyticsTracker(SegmentAnalytics(context = context, config = config)) - } - } - - private fun addAnalyticsTracker(analytic: Analytics) { - services.add(analytic) + fun addAnalyticsTracker(analytic: Analytics) { + analytics.add(analytic) } private fun logEvent(event: Event, params: Map = mapOf()) { - services.forEach { analytics -> + analytics.forEach { analytics -> analytics.logEvent(event.eventName, params) } } + override fun logScreenEvent(screenName: String, params: Map) { + analytics.forEach { analytics -> + analytics.logScreenEvent(screenName, params) + } + } + override fun logEvent(event: String, params: Map) { - services.forEach { analytics -> + analytics.forEach { analytics -> analytics.logEvent(event, params) } } private fun setUserId(userId: Long) { - services.forEach { analytics -> + analytics.forEach { analytics -> analytics.logUserId(userId) } } diff --git a/app/src/main/java/org/openedx/app/AppActivity.kt b/app/src/main/java/org/openedx/app/AppActivity.kt index 5ab0d0b0e..b736e937c 100644 --- a/app/src/main/java/org/openedx/app/AppActivity.kt +++ b/app/src/main/java/org/openedx/app/AppActivity.kt @@ -12,21 +12,27 @@ import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope import androidx.window.layout.WindowMetricsCalculator +import com.braze.support.toStringMap import io.branch.referral.Branch import io.branch.referral.Branch.BranchUniversalReferralInitListener +import kotlinx.coroutines.launch import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.openedx.app.databinding.ActivityAppBinding +import org.openedx.app.deeplink.DeepLink import org.openedx.auth.presentation.logistration.LogistrationFragment import org.openedx.auth.presentation.signin.SignInFragment import org.openedx.core.data.storage.CorePreferences -import org.openedx.core.extension.requestApplyInsetsWhenAttached import org.openedx.core.presentation.global.InsetHolder import org.openedx.core.presentation.global.WindowSizeHolder -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType import org.openedx.core.utils.Logger +import org.openedx.core.worker.CalendarSyncScheduler +import org.openedx.course.presentation.download.DownloadDialogManager +import org.openedx.foundation.extension.requestApplyInsetsWhenAttached +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType import org.openedx.profile.presentation.ProfileRouter import org.openedx.whatsnew.WhatsNewManager import org.openedx.whatsnew.presentation.whatsnew.WhatsNewFragment @@ -48,6 +54,8 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { private val whatsNewManager by inject() private val corePreferencesManager by inject() private val profileRouter by inject() + private val downloadDialogManager by inject() + private val calendarSyncScheduler by inject() private val branchLogger = Logger(BRANCH_TAG) @@ -57,6 +65,20 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { private var _windowSize = WindowSize(WindowType.Compact, WindowType.Compact) + private val branchCallback = + BranchUniversalReferralInitListener { branchUniversalObject, _, error -> + if (branchUniversalObject?.contentMetadata?.customMetadata != null) { + branchLogger.i { "Branch init complete." } + branchLogger.i { branchUniversalObject.contentMetadata.customMetadata.toString() } + viewModel.makeExternalRoute( + fm = supportFragmentManager, + deepLink = DeepLink(branchUniversalObject.contentMetadata.customMetadata) + ) + } else if (error != null) { + branchLogger.e { "Branch init failed. Caused by -" + error.message } + } + } + override fun onSaveInstanceState(outState: Bundle) { outState.putInt(TOP_INSET, topInset) outState.putInt(BOTTOM_INSET, bottomInset) @@ -134,28 +156,35 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { addFragment(MainFragment.newInstance()) } } + + val extras = intent.extras + if (extras?.containsKey(DeepLink.Keys.NOTIFICATION_TYPE.value) == true) { + handlePushNotification(extras) + } } viewModel.logoutUser.observe(this) { profileRouter.restartApp(supportFragmentManager, viewModel.isLogistrationEnabled) } + + lifecycleScope.launch { + viewModel.downloadFailedDialog.collect { + downloadDialogManager.showDownloadFailedPopup( + downloadModel = it.downloadModel, + fragmentManager = supportFragmentManager, + ) + } + } + + calendarSyncScheduler.scheduleDailySync() } override fun onStart() { super.onStart() if (viewModel.isBranchEnabled) { - val callback = BranchUniversalReferralInitListener { _, linkProperties, error -> - if (linkProperties != null) { - branchLogger.i { "Branch init complete." } - branchLogger.i { linkProperties.controlParams.toString() } - } else if (error != null) { - branchLogger.e { "Branch init failed. Caused by -" + error.message } - } - } - Branch.sessionBuilder(this) - .withCallback(callback) + .withCallback(branchCallback) .withData(this.intent.data) .init() } @@ -165,15 +194,16 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { super.onNewIntent(intent) this.intent = intent + val extras = intent?.extras + if (extras?.containsKey(DeepLink.Keys.NOTIFICATION_TYPE.value) == true) { + handlePushNotification(extras) + } + if (viewModel.isBranchEnabled) { if (intent?.getBooleanExtra(BRANCH_FORCE_NEW_SESSION, false) == true) { - Branch.sessionBuilder(this).withCallback { referringParams, error -> - if (error != null) { - branchLogger.e { error.message } - } else if (referringParams != null) { - branchLogger.i { referringParams.toString() } - } - }.reInit() + Branch.sessionBuilder(this) + .withCallback(branchCallback) + .reInit() } } } @@ -213,6 +243,11 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { } } + private fun handlePushNotification(data: Bundle) { + val deepLink = DeepLink(data.toStringMap()) + viewModel.makeExternalRoute(supportFragmentManager, deepLink) + } + companion object { const val TOP_INSET = "topInset" const val BOTTOM_INSET = "bottomInset" diff --git a/app/src/main/java/org/openedx/app/AppAnalytics.kt b/app/src/main/java/org/openedx/app/AppAnalytics.kt index 51278ef13..a122e79c1 100644 --- a/app/src/main/java/org/openedx/app/AppAnalytics.kt +++ b/app/src/main/java/org/openedx/app/AppAnalytics.kt @@ -4,6 +4,7 @@ interface AppAnalytics { fun logoutEvent(force: Boolean) fun setUserIdForSession(userId: Long) fun logEvent(event: String, params: Map) + fun logScreenEvent(screenName: String, params: Map) } enum class AppAnalyticsEvent(val eventName: String, val biValue: String) { @@ -15,14 +16,6 @@ enum class AppAnalyticsEvent(val eventName: String, val biValue: String) { "MainDashboard:Discover", "edx.bi.app.main_dashboard.discover" ), - MY_COURSES( - "MainDashboard:My Courses", - "edx.bi.app.main_dashboard.my_course" - ), - MY_PROGRAMS( - "MainDashboard:My Programs", - "edx.bi.app.main_dashboard.my_program" - ), PROFILE( "MainDashboard:Profile", "edx.bi.app.main_dashboard.profile" diff --git a/app/src/main/java/org/openedx/app/AppRouter.kt b/app/src/main/java/org/openedx/app/AppRouter.kt index 21f3b5aee..4d4d38182 100644 --- a/app/src/main/java/org/openedx/app/AppRouter.kt +++ b/app/src/main/java/org/openedx/app/AppRouter.kt @@ -3,18 +3,20 @@ package org.openedx.app import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentTransaction +import org.openedx.app.deeplink.HomeTab import org.openedx.auth.presentation.AuthRouter import org.openedx.auth.presentation.logistration.LogistrationFragment import org.openedx.auth.presentation.restore.RestorePasswordFragment import org.openedx.auth.presentation.signin.SignInFragment import org.openedx.auth.presentation.signup.SignUpFragment +import org.openedx.core.CalendarRouter import org.openedx.core.FragmentViewType import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.presentation.global.app_upgrade.AppUpgradeRouter import org.openedx.core.presentation.global.app_upgrade.UpgradeRequiredFragment import org.openedx.core.presentation.global.webview.WebContentFragment -import org.openedx.core.presentation.settings.VideoQualityFragment -import org.openedx.core.presentation.settings.VideoQualityType +import org.openedx.core.presentation.settings.video.VideoQualityFragment +import org.openedx.core.presentation.settings.video.VideoQualityType import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.container.CourseContainerFragment import org.openedx.course.presentation.container.NoAccessCourseContainerFragment @@ -25,6 +27,7 @@ import org.openedx.course.presentation.unit.container.CourseUnitContainerFragmen import org.openedx.course.presentation.unit.video.VideoFullScreenFragment import org.openedx.course.presentation.unit.video.YoutubeVideoFullScreenFragment import org.openedx.course.settings.download.DownloadQueueFragment +import org.openedx.courses.presentation.AllEnrolledCoursesFragment import org.openedx.dashboard.presentation.DashboardRouter import org.openedx.discovery.presentation.DiscoveryRouter import org.openedx.discovery.presentation.NativeDiscoveryFragment @@ -44,6 +47,8 @@ import org.openedx.discussion.presentation.threads.DiscussionThreadsFragment import org.openedx.profile.domain.model.Account import org.openedx.profile.presentation.ProfileRouter import org.openedx.profile.presentation.anothersaccount.AnothersProfileFragment +import org.openedx.profile.presentation.calendar.CalendarFragment +import org.openedx.profile.presentation.calendar.CoursesToSyncFragment import org.openedx.profile.presentation.delete.DeleteProfileFragment import org.openedx.profile.presentation.edit.EditProfileFragment import org.openedx.profile.presentation.manageaccount.ManageAccountFragment @@ -54,14 +59,23 @@ import org.openedx.whatsnew.WhatsNewRouter import org.openedx.whatsnew.presentation.whatsnew.WhatsNewFragment class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, DiscussionRouter, - ProfileRouter, AppUpgradeRouter, WhatsNewRouter { + ProfileRouter, AppUpgradeRouter, WhatsNewRouter, CalendarRouter { //region AuthRouter - override fun navigateToMain(fm: FragmentManager, courseId: String?, infoType: String?) { - fm.popBackStack() - fm.beginTransaction() - .replace(R.id.container, MainFragment.newInstance(courseId, infoType)) - .commit() + override fun navigateToMain( + fm: FragmentManager, + courseId: String?, + infoType: String?, + openTab: String + ) { + try { + fm.popBackStack() + fm.beginTransaction() + .replace(R.id.container, MainFragment.newInstance(courseId, infoType, openTab)) + .commit() + } catch (e: Exception) { + e.printStackTrace() + } } override fun navigateToSignIn(fm: FragmentManager, courseId: String?, infoType: String?) { @@ -93,18 +107,26 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di } override fun navigateToWhatsNew(fm: FragmentManager, courseId: String?, infoType: String?) { - fm.popBackStack() - fm.beginTransaction() - .replace(R.id.container, WhatsNewFragment.newInstance(courseId, infoType)) - .commit() + try { + fm.popBackStack() + fm.beginTransaction() + .replace(R.id.container, WhatsNewFragment.newInstance(courseId, infoType)) + .commit() + } catch (e: Exception) { + e.printStackTrace() + } } override fun clearBackStack(fm: FragmentManager) { fm.apply { - for (fragment in fragments) { - beginTransaction().remove(fragment).commit() + try { + for (fragment in fragments) { + beginTransaction().remove(fragment).commit() + } + popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE) + } catch (e: Exception) { + e.printStackTrace() } - popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE) } } //endregion @@ -122,6 +144,14 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di replaceFragmentWithBackStack(fm, UpgradeRequiredFragment()) } + override fun navigateToAllEnrolledCourses(fm: FragmentManager) { + replaceFragmentWithBackStack(fm, AllEnrolledCoursesFragment()) + } + + override fun getProgramFragment(): Fragment { + return ProgramFragment.newInstance(isNestedFragment = true) + } + override fun navigateToCourseInfo( fm: FragmentManager, courseId: String, @@ -129,6 +159,17 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di ) { replaceFragmentWithBackStack(fm, CourseInfoFragment.newInstance(courseId, infoType)) } + + override fun navigateToCourseOutline( + fm: FragmentManager, + courseId: String, + courseTitle: String, + ) { + replaceFragmentWithBackStack( + fm, + CourseContainerFragment.newInstance(courseId, courseTitle) + ) + } //endregion //region DashboardRouter @@ -137,21 +178,30 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di fm: FragmentManager, courseId: String, courseTitle: String, - enrollmentMode: String, + openTab: String, + resumeBlockId: String, ) { replaceFragmentWithBackStack( fm, - CourseContainerFragment.newInstance(courseId, courseTitle, enrollmentMode) + CourseContainerFragment.newInstance( + courseId, + courseTitle, + openTab, + resumeBlockId + ) ) } override fun navigateToEnrolledProgramInfo(fm: FragmentManager, pathId: String) { - replaceFragmentWithBackStack(fm, ProgramFragment.newInstance(pathId)) + replaceFragmentWithBackStack( + fm, + ProgramFragment.newInstance(pathId = pathId, isNestedFragment = false) + ) } override fun navigateToNoAccess( fm: FragmentManager, - title: String + title: String, ) { replaceFragment(fm, NoAccessCourseContainerFragment.newInstance(title)) } @@ -165,7 +215,7 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di subSectionId: String, unitId: String, componentId: String, - mode: CourseViewMode + mode: CourseViewMode, ) { replaceFragmentWithBackStack( fm, @@ -184,7 +234,7 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di courseId: String, unitId: String, componentId: String, - mode: CourseViewMode + mode: CourseViewMode, ) { replaceFragmentWithBackStack( fm, @@ -202,7 +252,7 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di courseId: String, unitId: String, componentId: String, - mode: CourseViewMode + mode: CourseViewMode, ) { replaceFragment( fm, @@ -222,7 +272,7 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di videoTime: Long, blockId: String, courseId: String, - isPlaying: Boolean + isPlaying: Boolean, ) { replaceFragmentWithBackStack( fm, @@ -236,7 +286,7 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di videoTime: Long, blockId: String, courseId: String, - isPlaying: Boolean + isPlaying: Boolean, ) { replaceFragmentWithBackStack( fm, @@ -253,12 +303,11 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di override fun navigateToHandoutsWebView( fm: FragmentManager, courseId: String, - title: String, - type: HandoutsType + type: HandoutsType, ) { replaceFragmentWithBackStack( fm, - HandoutsWebViewFragment.newInstance(title, type.name, courseId) + HandoutsWebViewFragment.newInstance(type.name, courseId) ) } //endregion @@ -270,7 +319,7 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di courseId: String, topicId: String, title: String, - viewType: FragmentViewType + viewType: FragmentViewType, ) { replaceFragmentWithBackStack( fm, @@ -288,7 +337,7 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di override fun navigateToDiscussionResponses( fm: FragmentManager, comment: DiscussionComment, - isClosed: Boolean + isClosed: Boolean, ) { replaceFragmentWithBackStack( fm, @@ -316,7 +365,7 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di override fun navigateToAnothersProfile( fm: FragmentManager, - username: String + username: String, ) { replaceFragmentWithBackStack( fm, @@ -360,6 +409,12 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di replaceFragmentWithBackStack(fm, VideoQualityFragment.newInstance(videoQualityType.name)) } + override fun navigateToDiscover(fm: FragmentManager) { + fm.beginTransaction() + .replace(R.id.container, MainFragment.newInstance("", "", HomeTab.DISCOVER.name)) + .commit() + } + override fun navigateToWebContent(fm: FragmentManager, title: String, url: String) { replaceFragmentWithBackStack( fm, @@ -370,32 +425,56 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di override fun navigateToManageAccount(fm: FragmentManager) { replaceFragmentWithBackStack(fm, ManageAccountFragment()) } + + override fun navigateToCalendarSettings(fm: FragmentManager) { + replaceFragmentWithBackStack(fm, CalendarFragment()) + } + + override fun navigateToCoursesToSync(fm: FragmentManager) { + replaceFragmentWithBackStack(fm, CoursesToSyncFragment()) + } //endregion + fun getVisibleFragment(fm: FragmentManager): Fragment? { + return fm.fragments.firstOrNull { it.isVisible } + } + private fun replaceFragmentWithBackStack(fm: FragmentManager, fragment: Fragment) { - fm.beginTransaction() - .replace(R.id.container, fragment, fragment.javaClass.simpleName) - .addToBackStack(fragment.javaClass.simpleName) - .commit() + try { + fm.beginTransaction() + .replace(R.id.container, fragment, fragment.javaClass.simpleName) + .addToBackStack(fragment.javaClass.simpleName) + .commit() + } catch (e: Exception) { + e.printStackTrace() + } } private fun replaceFragment( fm: FragmentManager, fragment: Fragment, - transaction: Int = FragmentTransaction.TRANSIT_NONE + transaction: Int = FragmentTransaction.TRANSIT_NONE, ) { - fm.beginTransaction() - .setTransition(transaction) - .replace(R.id.container, fragment, fragment.javaClass.simpleName) - .commit() + try { + fm.beginTransaction() + .setTransition(transaction) + .replace(R.id.container, fragment, fragment.javaClass.simpleName) + .commit() + } catch (e: Exception) { + e.printStackTrace() + } } //App upgrade override fun navigateToUserProfile(fm: FragmentManager) { - fm.popBackStack() - fm.beginTransaction() - .replace(R.id.container, ProfileFragment()) - .commit() + try { + fm.popBackStack() + fm.beginTransaction() + .replace(R.id.container, ProfileFragment()) + .commit() + } catch (e: Exception) { + e.printStackTrace() + } } //endregion } diff --git a/app/src/main/java/org/openedx/app/AppViewModel.kt b/app/src/main/java/org/openedx/app/AppViewModel.kt index 1febbd15a..e191a49c6 100644 --- a/app/src/main/java/org/openedx/app/AppViewModel.kt +++ b/app/src/main/java/org/openedx/app/AppViewModel.kt @@ -1,51 +1,94 @@ package org.openedx.app +import android.annotation.SuppressLint +import android.app.NotificationManager +import android.content.Context +import androidx.fragment.app.FragmentManager import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import androidx.lifecycle.viewModelScope import androidx.room.RoomDatabase import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.openedx.app.system.notifier.AppNotifier -import org.openedx.app.system.notifier.LogoutEvent -import org.openedx.core.BaseViewModel -import org.openedx.core.SingleEventLiveData +import org.openedx.app.deeplink.DeepLink +import org.openedx.app.deeplink.DeepLinkRouter +import org.openedx.app.system.push.RefreshFirebaseTokenWorker +import org.openedx.app.system.push.SyncFirebaseTokenWorker import org.openedx.core.config.Config +import org.openedx.core.data.model.User import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.system.notifier.DownloadFailed +import org.openedx.core.system.notifier.DownloadNotifier +import org.openedx.core.system.notifier.app.AppNotifier +import org.openedx.core.system.notifier.app.LogoutEvent +import org.openedx.core.system.notifier.app.SignInEvent +import org.openedx.core.utils.Directories +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.SingleEventLiveData +import org.openedx.foundation.utils.FileUtil +@SuppressLint("StaticFieldLeak") class AppViewModel( private val config: Config, - private val notifier: AppNotifier, + private val appNotifier: AppNotifier, private val room: RoomDatabase, private val preferencesManager: CorePreferences, private val dispatcher: CoroutineDispatcher, private val analytics: AppAnalytics, + private val deepLinkRouter: DeepLinkRouter, + private val fileUtil: FileUtil, + private val downloadNotifier: DownloadNotifier, + private val context: Context ) : BaseViewModel() { private val _logoutUser = SingleEventLiveData() val logoutUser: LiveData get() = _logoutUser + private val _downloadFailedDialog = MutableSharedFlow() + val downloadFailedDialog: SharedFlow + get() = _downloadFailedDialog.asSharedFlow() + + val isLogistrationEnabled get() = config.isPreLoginExperienceEnabled() private var logoutHandledAt: Long = 0 val isBranchEnabled get() = config.getBranchConfig().enabled + private val canResetAppDirectory get() = preferencesManager.canResetAppDirectory override fun onCreate(owner: LifecycleOwner) { super.onCreate(owner) - setUserId() + + val user = preferencesManager.user + + setUserId(user) + + if (user != null && preferencesManager.pushToken.isNotEmpty()) { + SyncFirebaseTokenWorker.schedule(context) + } + + if (canResetAppDirectory) { + resetAppDirectory() + } + + viewModelScope.launch { + appNotifier.notifier.collect { event -> + if (event is SignInEvent && config.getFirebaseConfig().isCloudMessagingEnabled) { + SyncFirebaseTokenWorker.schedule(context) + } else if (event is LogoutEvent) { + handleLogoutEvent(event) + } + } + } viewModelScope.launch { - notifier.notifier.collect { event -> - if (event is LogoutEvent && System.currentTimeMillis() - logoutHandledAt > 5000) { - logoutHandledAt = System.currentTimeMillis() - preferencesManager.clear() - withContext(dispatcher) { - room.clearAllTables() - } - analytics.logoutEvent(true) - _logoutUser.value = Unit + downloadNotifier.notifier.collect { event -> + if (event is DownloadFailed) { + _downloadFailedDialog.emit(event) } } } @@ -60,9 +103,39 @@ class AppViewModel( ) } - private fun setUserId() { - preferencesManager.user?.let { + private fun resetAppDirectory() { + fileUtil.deleteOldAppDirectory(Directories.VIDEOS.name) + preferencesManager.canResetAppDirectory = false + } + + fun makeExternalRoute(fm: FragmentManager, deepLink: DeepLink) { + deepLinkRouter.makeRoute(fm, deepLink) + } + + private fun setUserId(user: User?) { + user?.let { analytics.setUserIdForSession(it.id) } } + + private suspend fun handleLogoutEvent(event: LogoutEvent) { + if (System.currentTimeMillis() - logoutHandledAt > 5000) { + if (event.isForced) { + logoutHandledAt = System.currentTimeMillis() + preferencesManager.clearCorePreferences() + withContext(dispatcher) { + room.clearAllTables() + } + analytics.logoutEvent(true) + _logoutUser.value = Unit + } + + if (config.getFirebaseConfig().isCloudMessagingEnabled) { + RefreshFirebaseTokenWorker.schedule(context) + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.cancelAll() + } + } + } } diff --git a/app/src/main/java/org/openedx/app/InDevelopmentFragment.kt b/app/src/main/java/org/openedx/app/InDevelopmentFragment.kt deleted file mode 100644 index d8ca717d4..000000000 --- a/app/src/main/java/org/openedx/app/InDevelopmentFragment.kt +++ /dev/null @@ -1,56 +0,0 @@ -package org.openedx.app - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Scaffold -import androidx.compose.material.Text -import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.testTagsAsResourceId -import androidx.fragment.app.Fragment -import org.openedx.core.ui.theme.appColors -import org.openedx.core.ui.theme.appTypography - -class InDevelopmentFragment : Fragment() { - - @OptIn(ExperimentalComposeUiApi::class) - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ) = ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - Scaffold( - modifier = Modifier.semantics { - testTagsAsResourceId = true - }, - ) { - Box( - modifier = Modifier - .fillMaxSize() - .padding(it) - .background(MaterialTheme.appColors.secondary), - contentAlignment = Alignment.Center - ) { - Text( - modifier = Modifier.testTag("txt_in_development"), - text = "Will be available soon", - style = MaterialTheme.appTypography.headlineMedium - ) - } - } - } - } -} diff --git a/app/src/main/java/org/openedx/app/MainFragment.kt b/app/src/main/java/org/openedx/app/MainFragment.kt index a798c4a3f..4011b3a04 100644 --- a/app/src/main/java/org/openedx/app/MainFragment.kt +++ b/app/src/main/java/org/openedx/app/MainFragment.kt @@ -11,15 +11,14 @@ import androidx.viewpager2.widget.ViewPager2 import kotlinx.coroutines.launch import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel -import org.openedx.app.adapter.MainNavigationFragmentAdapter import org.openedx.app.databinding.FragmentMainBinding -import org.openedx.core.config.Config +import org.openedx.app.deeplink.HomeTab +import org.openedx.core.adapter.NavigationFragmentAdapter import org.openedx.core.presentation.global.app_upgrade.UpgradeRequiredFragment import org.openedx.core.presentation.global.viewBinding -import org.openedx.dashboard.presentation.DashboardFragment -import org.openedx.discovery.presentation.DiscoveryNavigator import org.openedx.discovery.presentation.DiscoveryRouter -import org.openedx.discovery.presentation.program.ProgramFragment +import org.openedx.learn.presentation.LearnFragment +import org.openedx.learn.presentation.LearnTab import org.openedx.profile.presentation.profile.ProfileFragment class MainFragment : Fragment(R.layout.fragment_main) { @@ -27,9 +26,8 @@ class MainFragment : Fragment(R.layout.fragment_main) { private val binding by viewBinding(FragmentMainBinding::bind) private val viewModel by viewModel() private val router by inject() - private val config by inject() - private lateinit var adapter: MainNavigationFragmentAdapter + private lateinit var adapter: NavigationFragmentAdapter override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -47,30 +45,22 @@ class MainFragment : Fragment(R.layout.fragment_main) { binding.bottomNavView.setOnItemSelectedListener { when (it.itemId) { - R.id.fragmentHome -> { - viewModel.logDiscoveryTabClickedEvent() + R.id.fragmentLearn -> { binding.viewPager.setCurrentItem(0, false) } - R.id.fragmentDashboard -> { - viewModel.logMyCoursesTabClickedEvent() + R.id.fragmentDiscover -> { + viewModel.logDiscoveryTabClickedEvent() binding.viewPager.setCurrentItem(1, false) } - R.id.fragmentPrograms -> { - viewModel.logMyProgramsTabClickedEvent() - binding.viewPager.setCurrentItem(2, false) - } - R.id.fragmentProfile -> { viewModel.logProfileTabClickedEvent() - binding.viewPager.setCurrentItem(3, false) + binding.viewPager.setCurrentItem(2, false) } } true } - // Trigger click event for the first tab on initial load - binding.bottomNavView.selectedItemId = binding.bottomNavView.selectedItemId viewModel.isBottomBarEnabled.observe(viewLifecycleOwner) { isBottomBarEnabled -> enableBottomBar(isBottomBarEnabled) @@ -79,7 +69,7 @@ class MainFragment : Fragment(R.layout.fragment_main) { viewLifecycleOwner.lifecycleScope.launch { viewModel.navigateToDiscovery.collect { shouldNavigateToDiscovery -> if (shouldNavigateToDiscovery) { - binding.bottomNavView.selectedItemId = R.id.fragmentHome + binding.bottomNavView.selectedItemId = R.id.fragmentDiscover } } } @@ -88,7 +78,7 @@ class MainFragment : Fragment(R.layout.fragment_main) { getString(ARG_COURSE_ID).takeIf { it.isNullOrBlank().not() }?.let { courseId -> val infoType = getString(ARG_INFO_TYPE) - if (config.getDiscoveryConfig().isViewTypeWebView() && infoType != null) { + if (viewModel.isDiscoveryTypeWebView && infoType != null) { router.navigateToCourseInfo(parentFragmentManager, courseId, infoType) } else { router.navigateToCourseDetail(parentFragmentManager, courseId) @@ -98,6 +88,22 @@ class MainFragment : Fragment(R.layout.fragment_main) { putString(ARG_COURSE_ID, "") putString(ARG_INFO_TYPE, "") } + + when (requireArguments().getString(ARG_OPEN_TAB, HomeTab.LEARN.name)) { + HomeTab.LEARN.name, + HomeTab.PROGRAMS.name -> { + binding.bottomNavView.selectedItemId = R.id.fragmentLearn + } + + HomeTab.DISCOVER.name -> { + binding.bottomNavView.selectedItemId = R.id.fragmentDiscover + } + + HomeTab.PROFILE.name -> { + binding.bottomNavView.selectedItemId = R.id.fragmentProfile + } + } + requireArguments().remove(ARG_OPEN_TAB) } } @@ -105,18 +111,15 @@ class MainFragment : Fragment(R.layout.fragment_main) { binding.viewPager.orientation = ViewPager2.ORIENTATION_HORIZONTAL binding.viewPager.offscreenPageLimit = 4 - val discoveryFragment = DiscoveryNavigator(viewModel.isDiscoveryTypeWebView) - .getDiscoveryFragment() - val programFragment = if (viewModel.isProgramTypeWebView) { - ProgramFragment(true) + val openTab = requireArguments().getString(ARG_OPEN_TAB, HomeTab.LEARN.name) + val learnTab = if (openTab == HomeTab.PROGRAMS.name) { + LearnTab.PROGRAMS } else { - InDevelopmentFragment() + LearnTab.COURSES } - - adapter = MainNavigationFragmentAdapter(this).apply { - addFragment(discoveryFragment) - addFragment(DashboardFragment()) - addFragment(programFragment) + adapter = NavigationFragmentAdapter(this).apply { + addFragment(LearnFragment.newInstance(openTab = learnTab.name)) + addFragment(viewModel.getDiscoveryFragment) addFragment(ProfileFragment()) } binding.viewPager.adapter = adapter @@ -132,11 +135,17 @@ class MainFragment : Fragment(R.layout.fragment_main) { companion object { private const val ARG_COURSE_ID = "courseId" private const val ARG_INFO_TYPE = "info_type" - fun newInstance(courseId: String? = null, infoType: String? = null): MainFragment { + private const val ARG_OPEN_TAB = "open_tab" + fun newInstance( + courseId: String? = null, + infoType: String? = null, + openTab: String = HomeTab.LEARN.name + ): MainFragment { val fragment = MainFragment() fragment.arguments = bundleOf( ARG_COURSE_ID to courseId, - ARG_INFO_TYPE to infoType + ARG_INFO_TYPE to infoType, + ARG_OPEN_TAB to openTab ) return fragment } diff --git a/app/src/main/java/org/openedx/app/MainViewModel.kt b/app/src/main/java/org/openedx/app/MainViewModel.kt index 6a30533ea..ff24f4ff8 100644 --- a/app/src/main/java/org/openedx/app/MainViewModel.kt +++ b/app/src/main/java/org/openedx/app/MainViewModel.kt @@ -10,10 +10,11 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import org.openedx.core.BaseViewModel import org.openedx.core.config.Config import org.openedx.core.system.notifier.DiscoveryNotifier import org.openedx.core.system.notifier.NavigationToDiscovery +import org.openedx.discovery.presentation.DiscoveryNavigator +import org.openedx.foundation.presentation.BaseViewModel class MainViewModel( private val config: Config, @@ -30,16 +31,18 @@ class MainViewModel( get() = _navigateToDiscovery.asSharedFlow() val isDiscoveryTypeWebView get() = config.getDiscoveryConfig().isViewTypeWebView() - - val isProgramTypeWebView get() = config.getProgramConfig().isViewTypeWebView() + val getDiscoveryFragment get() = DiscoveryNavigator(isDiscoveryTypeWebView).getDiscoveryFragment() override fun onCreate(owner: LifecycleOwner) { super.onCreate(owner) - notifier.notifier.onEach { - if (it is NavigationToDiscovery) { - _navigateToDiscovery.emit(true) + notifier.notifier + .onEach { + if (it is NavigationToDiscovery) { + _navigateToDiscovery.emit(true) + } } - }.distinctUntilChanged().launchIn(viewModelScope) + .distinctUntilChanged() + .launchIn(viewModelScope) } fun enableBottomBar(enable: Boolean) { @@ -47,24 +50,17 @@ class MainViewModel( } fun logDiscoveryTabClickedEvent() { - logEvent(AppAnalyticsEvent.DISCOVER) - } - - fun logMyCoursesTabClickedEvent() { - logEvent(AppAnalyticsEvent.MY_COURSES) - } - - fun logMyProgramsTabClickedEvent() { - logEvent(AppAnalyticsEvent.MY_PROGRAMS) + logScreenEvent(AppAnalyticsEvent.DISCOVER) } fun logProfileTabClickedEvent() { - logEvent(AppAnalyticsEvent.PROFILE) + logScreenEvent(AppAnalyticsEvent.PROFILE) } - private fun logEvent(event: AppAnalyticsEvent) { - analytics.logEvent(event.eventName, - buildMap { + private fun logScreenEvent(event: AppAnalyticsEvent) { + analytics.logScreenEvent( + screenName = event.eventName, + params = buildMap { put(AppAnalyticsKey.NAME.key, event.biValue) } ) diff --git a/app/src/main/java/org/openedx/app/OpenEdXApp.kt b/app/src/main/java/org/openedx/app/OpenEdXApp.kt index 7d1b81d32..6524cde5d 100644 --- a/app/src/main/java/org/openedx/app/OpenEdXApp.kt +++ b/app/src/main/java/org/openedx/app/OpenEdXApp.kt @@ -3,19 +3,23 @@ package org.openedx.app import android.app.Application import com.braze.Braze import com.braze.configuration.BrazeConfig +import com.braze.ui.BrazeDeeplinkHandler import com.google.firebase.FirebaseApp import io.branch.referral.Branch import org.koin.android.ext.android.inject import org.koin.android.ext.koin.androidContext import org.koin.core.context.startKoin +import org.openedx.app.deeplink.BranchBrazeDeeplinkHandler import org.openedx.app.di.appModule import org.openedx.app.di.networkingModule import org.openedx.app.di.screenModule import org.openedx.core.config.Config +import org.openedx.firebase.OEXFirebaseAnalytics class OpenEdXApp : Application() { private val config by inject() + private val pluginManager by inject() override fun onCreate() { super.onCreate() @@ -36,6 +40,7 @@ class OpenEdXApp : Application() { Branch.enableTestMode() Branch.enableLogging() } + Branch.expectDelayedSessionInitialization(true) Branch.getAutoInstance(this) } @@ -50,6 +55,18 @@ class OpenEdXApp : Application() { .setIsFirebaseMessagingServiceOnNewTokenRegistrationEnabled(true) .build() Braze.configure(this, brazeConfig) + + if (config.getBranchConfig().enabled) { + BrazeDeeplinkHandler.setBrazeDeeplinkHandler(BranchBrazeDeeplinkHandler()) + } + } + + initPlugins() + } + + private fun initPlugins() { + if (config.getFirebaseConfig().enabled) { + pluginManager.addPlugin(OEXFirebaseAnalytics(context = this)) } } } diff --git a/app/src/main/java/org/openedx/app/PluginManager.kt b/app/src/main/java/org/openedx/app/PluginManager.kt new file mode 100644 index 000000000..651dbc8cb --- /dev/null +++ b/app/src/main/java/org/openedx/app/PluginManager.kt @@ -0,0 +1,12 @@ +package org.openedx.app + +import org.openedx.foundation.interfaces.Analytics + +class PluginManager( + private val analyticsManager: AnalyticsManager +) { + + fun addPlugin(analytics: Analytics) { + analyticsManager.addAnalyticsTracker(analytics) + } +} diff --git a/app/src/main/java/org/openedx/app/analytics/Analytics.kt b/app/src/main/java/org/openedx/app/analytics/Analytics.kt deleted file mode 100644 index 01ac01860..000000000 --- a/app/src/main/java/org/openedx/app/analytics/Analytics.kt +++ /dev/null @@ -1,7 +0,0 @@ -package org.openedx.app.analytics - -interface Analytics { - fun logScreenEvent(screenName: String, params: Map) - fun logEvent(eventName: String, params: Map) - fun logUserId(userId: Long) -} diff --git a/app/src/main/java/org/openedx/app/analytics/FirebaseAnalytics.kt b/app/src/main/java/org/openedx/app/analytics/FirebaseAnalytics.kt deleted file mode 100644 index 503f3d1ef..000000000 --- a/app/src/main/java/org/openedx/app/analytics/FirebaseAnalytics.kt +++ /dev/null @@ -1,35 +0,0 @@ -package org.openedx.app.analytics - -import android.content.Context -import com.google.firebase.analytics.FirebaseAnalytics -import org.openedx.core.extension.toBundle -import org.openedx.core.utils.Logger - -class FirebaseAnalytics(context: Context) : Analytics { - - private val logger = Logger(TAG) - private var tracker: FirebaseAnalytics - - init { - tracker = FirebaseAnalytics.getInstance(context) - logger.d { "Firebase Analytics Builder Initialised" } - } - - override fun logScreenEvent(screenName: String, params: Map) { - logger.d { "Firebase Analytics log Screen Event: $screenName + $params" } - } - - override fun logEvent(eventName: String, params: Map) { - tracker.logEvent(eventName, params.toBundle()) - logger.d { "Firebase Analytics log Event $eventName: $params" } - } - - override fun logUserId(userId: Long) { - tracker.setUserId(userId.toString()) - logger.d { "Firebase Analytics User Id log Event" } - } - - private companion object { - const val TAG = "FirebaseAnalytics" - } -} diff --git a/app/src/main/java/org/openedx/app/analytics/SegmentAnalytics.kt b/app/src/main/java/org/openedx/app/analytics/SegmentAnalytics.kt deleted file mode 100644 index 3a9532a71..000000000 --- a/app/src/main/java/org/openedx/app/analytics/SegmentAnalytics.kt +++ /dev/null @@ -1,56 +0,0 @@ -package org.openedx.app.analytics - -import android.content.Context -import com.segment.analytics.kotlin.destinations.braze.BrazeDestination -import com.segment.analytics.kotlin.destinations.firebase.FirebaseDestination -import org.openedx.app.BuildConfig -import org.openedx.core.config.Config -import org.openedx.core.utils.Logger -import com.segment.analytics.kotlin.android.Analytics as SegmentAnalyticsBuilder -import com.segment.analytics.kotlin.core.Analytics as SegmentTracker - -class SegmentAnalytics(context: Context, config: Config) : Analytics { - - private val logger = Logger(TAG) - private var tracker: SegmentTracker - - init { - // Create an analytics client with the given application context and Segment write key. - tracker = SegmentAnalyticsBuilder(config.getSegmentConfig().segmentWriteKey, context) { - // Automatically track Lifecycle events - trackApplicationLifecycleEvents = true - flushAt = 20 - flushInterval = 30 - } - if (config.getFirebaseConfig().isSegmentAnalyticsSource()) { - tracker.add(plugin = FirebaseDestination(context = context)) - } - - if (config.getFirebaseConfig() - .isSegmentAnalyticsSource() && config.getBrazeConfig().isEnabled - ) { - tracker.add(plugin = BrazeDestination(context)) - } - SegmentTracker.debugLogsEnabled = BuildConfig.DEBUG - logger.d { "Segment Analytics Builder Initialised" } - } - - override fun logScreenEvent(screenName: String, params: Map) { - logger.d { "Segment Analytics log Screen Event: $screenName + $params" } - tracker.screen(screenName, params) - } - - override fun logEvent(eventName: String, params: Map) { - logger.d { "Segment Analytics log Event $eventName: $params" } - tracker.track(eventName, params) - } - - override fun logUserId(userId: Long) { - logger.d { "Segment Analytics User Id log Event: $userId" } - tracker.identify(userId.toString()) - } - - private companion object { - const val TAG = "SegmentAnalytics" - } -} diff --git a/app/src/main/java/org/openedx/app/data/api/NotificationsApi.kt b/app/src/main/java/org/openedx/app/data/api/NotificationsApi.kt new file mode 100644 index 000000000..9106944c3 --- /dev/null +++ b/app/src/main/java/org/openedx/app/data/api/NotificationsApi.kt @@ -0,0 +1,14 @@ +package org.openedx.app.data.api + +import retrofit2.http.Field +import retrofit2.http.FormUrlEncoded +import retrofit2.http.POST + +interface NotificationsApi { + @POST("/api/mobile/v4/notifications/create-token/") + @FormUrlEncoded + suspend fun syncFirebaseToken( + @Field("registration_id") token: String, + @Field("active") active: Boolean = true + ) +} diff --git a/app/src/main/java/org/openedx/app/data/networking/AppUpgradeInterceptor.kt b/app/src/main/java/org/openedx/app/data/networking/AppUpgradeInterceptor.kt index 4e88eec42..abf90d7a2 100644 --- a/app/src/main/java/org/openedx/app/data/networking/AppUpgradeInterceptor.kt +++ b/app/src/main/java/org/openedx/app/data/networking/AppUpgradeInterceptor.kt @@ -4,13 +4,13 @@ import kotlinx.coroutines.runBlocking import okhttp3.Interceptor import okhttp3.Response import org.openedx.app.BuildConfig -import org.openedx.core.system.notifier.AppUpgradeEvent -import org.openedx.core.system.notifier.AppUpgradeNotifier +import org.openedx.core.system.notifier.app.AppNotifier +import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.core.utils.TimeUtils import java.util.Date class AppUpgradeInterceptor( - private val appUpgradeNotifier: AppUpgradeNotifier + private val appNotifier: AppNotifier ) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val response = chain.proceed(chain.request()) @@ -21,15 +21,15 @@ class AppUpgradeInterceptor( runBlocking { when { responseCode == 426 -> { - appUpgradeNotifier.send(AppUpgradeEvent.UpgradeRequiredEvent) + appNotifier.send(AppUpgradeEvent.UpgradeRequiredEvent) } BuildConfig.VERSION_NAME != latestAppVersion && lastSupportedDateTime > Date().time -> { - appUpgradeNotifier.send(AppUpgradeEvent.UpgradeRecommendedEvent(latestAppVersion)) + appNotifier.send(AppUpgradeEvent.UpgradeRecommendedEvent(latestAppVersion)) } latestAppVersion.isNotEmpty() && BuildConfig.VERSION_NAME != latestAppVersion && lastSupportedDateTime < Date().time -> { - appUpgradeNotifier.send(AppUpgradeEvent.UpgradeRequiredEvent) + appNotifier.send(AppUpgradeEvent.UpgradeRequiredEvent) } } } diff --git a/app/src/main/java/org/openedx/app/data/networking/OauthRefreshTokenAuthenticator.kt b/app/src/main/java/org/openedx/app/data/networking/OauthRefreshTokenAuthenticator.kt index 3cc6b82ae..38305c007 100644 --- a/app/src/main/java/org/openedx/app/data/networking/OauthRefreshTokenAuthenticator.kt +++ b/app/src/main/java/org/openedx/app/data/networking/OauthRefreshTokenAuthenticator.kt @@ -9,8 +9,7 @@ import okhttp3.ResponseBody.Companion.toResponseBody import okhttp3.logging.HttpLoggingInterceptor import org.json.JSONException import org.json.JSONObject -import org.openedx.app.system.notifier.AppNotifier -import org.openedx.app.system.notifier.LogoutEvent +import org.openedx.core.system.notifier.app.LogoutEvent import org.openedx.auth.data.api.AuthApi import org.openedx.auth.domain.model.AuthResponse import org.openedx.core.ApiConstants @@ -18,6 +17,7 @@ import org.openedx.core.ApiConstants.TOKEN_TYPE_JWT import org.openedx.core.BuildConfig import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.core.utils.TimeUtils import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory @@ -119,7 +119,7 @@ class OauthRefreshTokenAuthenticator( } runBlocking { - appNotifier.send(LogoutEvent()) + appNotifier.send(LogoutEvent(true)) } } @@ -128,7 +128,7 @@ class OauthRefreshTokenAuthenticator( JWT_USER_EMAIL_MISMATCH, -> { runBlocking { - appNotifier.send(LogoutEvent()) + appNotifier.send(LogoutEvent(true)) } } } diff --git a/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt b/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt index 603876d54..ab18b7e23 100644 --- a/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt +++ b/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt @@ -4,19 +4,21 @@ import android.content.Context import com.google.gson.Gson import org.openedx.app.BuildConfig import org.openedx.core.data.model.User +import org.openedx.core.data.storage.CalendarPreferences import org.openedx.core.data.storage.CorePreferences import org.openedx.core.data.storage.InAppReviewPreferences import org.openedx.core.domain.model.AppConfig import org.openedx.core.domain.model.VideoQuality import org.openedx.core.domain.model.VideoSettings -import org.openedx.core.extension.replaceSpace +import org.openedx.core.system.CalendarManager import org.openedx.course.data.storage.CoursePreferences +import org.openedx.foundation.extension.replaceSpace import org.openedx.profile.data.model.Account import org.openedx.profile.data.storage.ProfilePreferences import org.openedx.whatsnew.data.storage.WhatsNewPreferences class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences, - WhatsNewPreferences, InAppReviewPreferences, CoursePreferences { + WhatsNewPreferences, InAppReviewPreferences, CoursePreferences, CalendarPreferences { private val sharedPreferences = context.getSharedPreferences(BuildConfig.APPLICATION_ID, Context.MODE_PRIVATE) @@ -37,7 +39,7 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences }.apply() } - private fun getLong(key: String): Long = sharedPreferences.getLong(key, 0L) + private fun getLong(key: String, defValue: Long = 0): Long = sharedPreferences.getLong(key, defValue) private fun saveBoolean(key: String, value: Boolean) { sharedPreferences.edit().apply { @@ -49,15 +51,24 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences return sharedPreferences.getBoolean(key, defValue) } - override fun clear() { + override fun clearCorePreferences() { sharedPreferences.edit().apply { remove(ACCESS_TOKEN) remove(REFRESH_TOKEN) remove(USER) + remove(ACCOUNT) remove(EXPIRES_IN) }.apply() } + override fun clearCalendarPreferences() { + sharedPreferences.edit().apply { + remove(CALENDAR_ID) + remove(IS_CALENDAR_SYNC_ENABLED) + remove(HIDE_INACTIVE_COURSES) + }.apply() + } + override var accessToken: String set(value) { saveString(ACCESS_TOKEN, value) @@ -70,12 +81,24 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences } get() = getString(REFRESH_TOKEN) + override var pushToken: String + set(value) { + saveString(PUSH_TOKEN, value) + } + get() = getString(PUSH_TOKEN) + override var accessTokenExpiresAt: Long set(value) { saveLong(EXPIRES_IN, value) } get() = getLong(EXPIRES_IN) + override var calendarId: Long + set(value) { + saveLong(CALENDAR_ID, value) + } + get() = getLong(CALENDAR_ID, CalendarManager.CALENDAR_DOES_NOT_EXIST) + override var user: User? set(value) { val userJson = Gson().toJson(value) @@ -122,10 +145,12 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences saveString(APP_CONFIG, appConfigJson) } get() { - val appConfigString = getString(APP_CONFIG) + val appConfigString = getString(APP_CONFIG, getDefaultAppConfig()) return Gson().fromJson(appConfigString, AppConfig::class.java) } + private fun getDefaultAppConfig() = Gson().toJson(AppConfig()) + override var lastWhatsNewVersion: String set(value) { saveString(LAST_WHATS_NEW_VERSION, value) @@ -152,6 +177,36 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences } get() = getBoolean(APP_WAS_POSITIVE_RATED) + override var canResetAppDirectory: Boolean + set(value) { + saveBoolean(RESET_APP_DIRECTORY, value) + } + get() = getBoolean(RESET_APP_DIRECTORY, true) + + override var isCalendarSyncEnabled: Boolean + set(value) { + saveBoolean(IS_CALENDAR_SYNC_ENABLED, value) + } + get() = getBoolean(IS_CALENDAR_SYNC_ENABLED, true) + + override var calendarUser: String + set(value) { + saveString(CALENDAR_USER, value) + } + get() = getString(CALENDAR_USER) + + override var isRelativeDatesEnabled: Boolean + set(value) { + saveBoolean(IS_RELATIVE_DATES_ENABLED, value) + } + get() = getBoolean(IS_RELATIVE_DATES_ENABLED, true) + + override var isHideInactiveCourses: Boolean + set(value) { + saveBoolean(HIDE_INACTIVE_COURSES, value) + } + get() = getBoolean(HIDE_INACTIVE_COURSES, true) + override fun setCalendarSyncEventsDialogShown(courseName: String) { saveBoolean(courseName.replaceSpace("_"), true) } @@ -162,6 +217,7 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences companion object { private const val ACCESS_TOKEN = "access_token" private const val REFRESH_TOKEN = "refresh_token" + private const val PUSH_TOKEN = "push_token" private const val EXPIRES_IN = "expires_in" private const val USER = "user" private const val ACCOUNT = "account" @@ -172,5 +228,11 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences private const val VIDEO_SETTINGS_STREAMING_QUALITY = "video_settings_streaming_quality" private const val VIDEO_SETTINGS_DOWNLOAD_QUALITY = "video_settings_download_quality" private const val APP_CONFIG = "app_config" + private const val CALENDAR_ID = "CALENDAR_ID" + private const val RESET_APP_DIRECTORY = "reset_app_directory" + private const val IS_CALENDAR_SYNC_ENABLED = "IS_CALENDAR_SYNC_ENABLED" + private const val IS_RELATIVE_DATES_ENABLED = "IS_RELATIVE_DATES_ENABLED" + private const val HIDE_INACTIVE_COURSES = "HIDE_INACTIVE_COURSES" + private const val CALENDAR_USER = "CALENDAR_USER" } } diff --git a/app/src/main/java/org/openedx/app/deeplink/BranchBrazeDeeplinkHandler.kt b/app/src/main/java/org/openedx/app/deeplink/BranchBrazeDeeplinkHandler.kt new file mode 100644 index 000000000..967c3768b --- /dev/null +++ b/app/src/main/java/org/openedx/app/deeplink/BranchBrazeDeeplinkHandler.kt @@ -0,0 +1,26 @@ +package org.openedx.app.deeplink + +import android.content.Context +import android.content.Intent +import android.net.Uri +import com.braze.ui.BrazeDeeplinkHandler +import com.braze.ui.actions.UriAction +import org.openedx.app.AppActivity + +internal class BranchBrazeDeeplinkHandler : BrazeDeeplinkHandler() { + override fun gotoUri(context: Context, uriAction: UriAction) { + val deeplink = uriAction.uri.toString() + + if (deeplink.contains("app.link")) { + val intent = Intent(context, AppActivity::class.java).apply { + action = Intent.ACTION_VIEW + data = Uri.parse(deeplink) + flags = Intent.FLAG_ACTIVITY_NEW_TASK + putExtra("branch_force_new_session", true) + } + context.startActivity(intent) + } else { + super.gotoUri(context, uriAction) + } + } +} diff --git a/app/src/main/java/org/openedx/app/deeplink/DeepLink.kt b/app/src/main/java/org/openedx/app/deeplink/DeepLink.kt new file mode 100644 index 000000000..ac494df06 --- /dev/null +++ b/app/src/main/java/org/openedx/app/deeplink/DeepLink.kt @@ -0,0 +1,59 @@ +package org.openedx.app.deeplink + +class DeepLink(params: Map) { + + private val screenName = params[Keys.SCREEN_NAME.value] + private val notificationType = params[Keys.NOTIFICATION_TYPE.value] + val courseId = params[Keys.COURSE_ID.value] + val pathId = params[Keys.PATH_ID.value] + val componentId = params[Keys.COMPONENT_ID.value] + val topicId = params[Keys.TOPIC_ID.value] + val threadId = params[Keys.THREAD_ID.value] + val commentId = params[Keys.COMMENT_ID.value] + val parentId = params[Keys.PARENT_ID.value] + val type = DeepLinkType.typeOf(screenName ?: notificationType ?: "") + + enum class Keys(val value: String) { + SCREEN_NAME("screen_name"), + NOTIFICATION_TYPE("notification_type"), + COURSE_ID("course_id"), + PATH_ID("path_id"), + COMPONENT_ID("component_id"), + TOPIC_ID("topic_id"), + THREAD_ID("thread_id"), + COMMENT_ID("comment_id"), + PARENT_ID("parent_id"), + } +} + +enum class DeepLinkType(val type: String) { + DISCOVERY("discovery"), + DISCOVERY_COURSE_DETAIL("discovery_course_detail"), + DISCOVERY_PROGRAM_DETAIL("discovery_program_detail"), + COURSE_DASHBOARD("course_dashboard"), + COURSE_VIDEOS("course_videos"), + COURSE_DISCUSSION("course_discussion"), + COURSE_DATES("course_dates"), + COURSE_HANDOUT("course_handout"), + COURSE_ANNOUNCEMENT("course_announcement"), + COURSE_COMPONENT("course_component"), + PROGRAM("program"), + DISCUSSION_TOPIC("discussion_topic"), + DISCUSSION_POST("discussion_post"), + DISCUSSION_COMMENT("discussion_comment"), + PROFILE("profile"), + USER_PROFILE("user_profile"), + ENROLL("enroll"), + UNENROLL("unenroll"), + ADD_BETA_TESTER("add_beta_tester"), + REMOVE_BETA_TESTER("remove_beta_tester"), + FORUM_RESPONSE("forum_response"), + FORUM_COMMENT("forum_comment"), + NONE(""); + + companion object { + fun typeOf(type: String): DeepLinkType { + return entries.firstOrNull { it.type == type } ?: NONE + } + } +} diff --git a/app/src/main/java/org/openedx/app/deeplink/DeepLinkRouter.kt b/app/src/main/java/org/openedx/app/deeplink/DeepLinkRouter.kt new file mode 100644 index 000000000..a55d45ff6 --- /dev/null +++ b/app/src/main/java/org/openedx/app/deeplink/DeepLinkRouter.kt @@ -0,0 +1,604 @@ +package org.openedx.app.deeplink + +import androidx.fragment.app.FragmentManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.openedx.app.AppRouter +import org.openedx.app.MainFragment +import org.openedx.app.R +import org.openedx.auth.presentation.signin.SignInFragment +import org.openedx.core.FragmentViewType +import org.openedx.core.config.Config +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.presentation.course.CourseViewMode +import org.openedx.course.domain.interactor.CourseInteractor +import org.openedx.course.presentation.handouts.HandoutsType +import org.openedx.discovery.domain.interactor.DiscoveryInteractor +import org.openedx.discovery.domain.model.Course +import org.openedx.discovery.presentation.catalog.WebViewLink +import org.openedx.discussion.domain.interactor.DiscussionInteractor +import org.openedx.discussion.presentation.topics.DiscussionTopicsViewModel +import kotlin.coroutines.CoroutineContext + +class DeepLinkRouter( + private val config: Config, + private val appRouter: AppRouter, + private val corePreferences: CorePreferences, + private val discoveryInteractor: DiscoveryInteractor, + private val courseInteractor: CourseInteractor, + private val discussionInteractor: DiscussionInteractor +) : CoroutineScope { + + override val coroutineContext: CoroutineContext + get() = Dispatchers.Default + + private val isUserLoggedIn + get() = corePreferences.user != null + + fun makeRoute(fm: FragmentManager, deepLink: DeepLink) { + when (deepLink.type) { + // Discovery + DeepLinkType.DISCOVERY -> { + navigateToDiscoveryScreen(fm = fm) + return + } + + DeepLinkType.DISCOVERY_COURSE_DETAIL -> { + navigateToCourseDetail( + fm = fm, + deepLink = deepLink + ) + return + } + + DeepLinkType.DISCOVERY_PROGRAM_DETAIL -> { + navigateToProgramDetail( + fm = fm, + deepLink = deepLink + ) + return + } + + else -> { + //ignore + } + } + + if (!isUserLoggedIn) { + navigateToSignIn(fm = fm) + return + } + + when (deepLink.type) { + // Program + DeepLinkType.PROGRAM -> { + navigateToProgram( + fm = fm, + deepLink = deepLink + ) + return + } + // Profile + DeepLinkType.PROFILE, + DeepLinkType.USER_PROFILE -> { + navigateToProfile(fm = fm) + return + } + else -> { + //ignore + } + } + + launch(Dispatchers.Main) { + val courseId = deepLink.courseId ?: return@launch navigateToDashboard(fm = fm) + val course = getCourseDetails(courseId) ?: return@launch navigateToDashboard(fm = fm) + if (!course.isEnrolled) { + navigateToDashboard(fm = fm) + return@launch + } + + when (deepLink.type) { + // Course + DeepLinkType.COURSE_DASHBOARD, DeepLinkType.ENROLL, DeepLinkType.ADD_BETA_TESTER -> { + navigateToDashboard(fm = fm) + navigateToCourseDashboard( + fm = fm, + deepLink = deepLink, + courseTitle = course.name + ) + } + + DeepLinkType.UNENROLL, DeepLinkType.REMOVE_BETA_TESTER -> { + navigateToDashboard(fm = fm) + } + + DeepLinkType.COURSE_VIDEOS -> { + navigateToDashboard(fm = fm) + navigateToCourseVideos( + fm = fm, + deepLink = deepLink + ) + } + + DeepLinkType.COURSE_DATES -> { + navigateToDashboard(fm = fm) + navigateToCourseDates( + fm = fm, + deepLink = deepLink + ) + } + + DeepLinkType.COURSE_DISCUSSION -> { + navigateToDashboard(fm = fm) + navigateToCourseDiscussion( + fm = fm, + deepLink = deepLink + ) + } + + DeepLinkType.COURSE_HANDOUT -> { + navigateToDashboard(fm = fm) + navigateToCourseMore( + fm = fm, + deepLink = deepLink + ) + navigateToCourseHandout( + fm = fm, + deepLink = deepLink + ) + } + + DeepLinkType.COURSE_ANNOUNCEMENT -> { + navigateToDashboard(fm = fm) + navigateToCourseMore( + fm = fm, + deepLink = deepLink + ) + navigateToCourseAnnouncement( + fm = fm, + deepLink = deepLink + ) + } + + DeepLinkType.COURSE_COMPONENT -> { + navigateToDashboard(fm = fm) + navigateToCourseDashboard( + fm = fm, + deepLink = deepLink, + courseTitle = course.name + ) + navigateToCourseComponent( + fm = fm, + deepLink = deepLink + ) + } + + // Discussions + DeepLinkType.DISCUSSION_TOPIC -> { + navigateToDashboard(fm = fm) + navigateToCourseDiscussion( + fm = fm, + deepLink = deepLink + ) + navigateToDiscussionTopic( + fm = fm, + deepLink = deepLink + ) + } + + DeepLinkType.DISCUSSION_POST -> { + navigateToDashboard(fm = fm) + navigateToCourseDiscussion( + fm = fm, + deepLink = deepLink + ) + navigateToDiscussionPost( + fm = fm, + deepLink = deepLink + ) + } + + DeepLinkType.DISCUSSION_COMMENT, DeepLinkType.FORUM_RESPONSE -> { + navigateToDashboard(fm = fm) + navigateToCourseDiscussion( + fm = fm, + deepLink = deepLink + ) + navigateToDiscussionResponse( + fm = fm, + deepLink = deepLink + ) + } + + DeepLinkType.FORUM_COMMENT -> { + navigateToDashboard(fm = fm) + navigateToCourseDiscussion( + fm = fm, + deepLink = deepLink + ) + navigateToDiscussionComment( + fm = fm, + deepLink = deepLink + ) + } + + else -> { + //ignore + } + } + } + } + + // Returns true if there was a successful redirect to the discovery screen + private fun navigateToDiscoveryScreen(fm: FragmentManager): Boolean { + return if (isUserLoggedIn) { + fm.popBackStack() + fm.beginTransaction() + .replace(R.id.container, MainFragment.newInstance(openTab = "DISCOVER")) + .commitNow() + true + } else if (!config.isPreLoginExperienceEnabled()) { + navigateToSignIn(fm = fm) + false + } else if (config.getDiscoveryConfig().isViewTypeWebView()) { + appRouter.navigateToWebDiscoverCourses( + fm = fm, + querySearch = "" + ) + true + } else { + appRouter.navigateToNativeDiscoverCourses( + fm = fm, + querySearch = "" + ) + true + } + } + + private fun navigateToCourseDetail(fm: FragmentManager, deepLink: DeepLink) { + deepLink.courseId?.let { courseId -> + if (navigateToDiscoveryScreen(fm = fm)) { + appRouter.navigateToCourseInfo( + fm = fm, + courseId = courseId, + infoType = WebViewLink.Authority.COURSE_INFO.name + ) + } + } + } + + private fun navigateToProgramDetail(fm: FragmentManager, deepLink: DeepLink) { + deepLink.pathId?.let { pathId -> + if (navigateToDiscoveryScreen(fm = fm)) { + appRouter.navigateToCourseInfo( + fm = fm, + courseId = pathId, + infoType = WebViewLink.Authority.PROGRAM_INFO.name + ) + } + } + } + + private fun navigateToSignIn(fm: FragmentManager) { + if (appRouter.getVisibleFragment(fm = fm) !is SignInFragment) { + appRouter.navigateToSignIn( + fm = fm, + courseId = null, + infoType = null + ) + } + } + + private fun navigateToCourseDashboard( + fm: FragmentManager, + deepLink: DeepLink, + courseTitle: String + ) { + deepLink.courseId?.let { courseId -> + appRouter.navigateToCourseOutline( + fm = fm, + courseId = courseId, + courseTitle = courseTitle, + ) + } + } + + private fun navigateToCourseVideos(fm: FragmentManager, deepLink: DeepLink) { + deepLink.courseId?.let { courseId -> + appRouter.navigateToCourseOutline( + fm = fm, + courseId = courseId, + courseTitle = "", + openTab = "VIDEOS" + ) + } + } + + private fun navigateToCourseDates(fm: FragmentManager, deepLink: DeepLink) { + deepLink.courseId?.let { courseId -> + appRouter.navigateToCourseOutline( + fm = fm, + courseId = courseId, + courseTitle = "", + openTab = "DATES" + ) + } + } + + private fun navigateToCourseDiscussion(fm: FragmentManager, deepLink: DeepLink) { + deepLink.courseId?.let { courseId -> + appRouter.navigateToCourseOutline( + fm = fm, + courseId = courseId, + courseTitle = "", + openTab = "DISCUSSIONS" + ) + } + } + + private fun navigateToCourseMore(fm: FragmentManager, deepLink: DeepLink) { + deepLink.courseId?.let { courseId -> + appRouter.navigateToCourseOutline( + fm = fm, + courseId = courseId, + courseTitle = "", + openTab = "MORE" + ) + } + } + + private fun navigateToCourseHandout(fm: FragmentManager, deepLink: DeepLink) { + deepLink.courseId?.let { courseId -> + appRouter.navigateToHandoutsWebView( + fm = fm, + courseId = courseId, + type = HandoutsType.Handouts + ) + } + } + + private fun navigateToCourseAnnouncement(fm: FragmentManager, deepLink: DeepLink) { + deepLink.courseId?.let { courseId -> + appRouter.navigateToHandoutsWebView( + fm = fm, + courseId = courseId, + type = HandoutsType.Announcements + ) + } + } + + private fun navigateToCourseComponent(fm: FragmentManager, deepLink: DeepLink) { + deepLink.courseId?.let { courseId -> + deepLink.componentId?.let { componentId -> + launch { + try { + val courseStructure = courseInteractor.getCourseStructure(courseId) + courseStructure.blockData + .find { it.descendants.contains(componentId) }?.let { block -> + appRouter.navigateToCourseContainer( + fm = fm, + courseId = courseId, + unitId = block.id, + componentId = componentId, + mode = CourseViewMode.FULL + ) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + } + } + + private fun navigateToProgram(fm: FragmentManager, deepLink: DeepLink) { + val pathId = deepLink.pathId + if (pathId == null) { + navigateToPrograms(fm = fm) + } else { + appRouter.navigateToEnrolledProgramInfo( + fm = fm, + pathId = pathId + ) + } + } + + private fun navigateToDiscussionTopic(fm: FragmentManager, deepLink: DeepLink) { + deepLink.courseId?.let { courseId -> + deepLink.topicId?.let { topicId -> + launch { + try { + discussionInteractor.getCourseTopics(courseId) + .find { it.id == topicId }?.let { topic -> + launch(Dispatchers.Main) { + appRouter.navigateToDiscussionThread( + fm = fm, + action = DiscussionTopicsViewModel.TOPIC, + courseId = courseId, + topicId = topicId, + title = topic.name, + viewType = FragmentViewType.FULL_CONTENT + ) + } + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + } + } + + private fun navigateToDiscussionPost(fm: FragmentManager, deepLink: DeepLink) { + deepLink.courseId?.let { courseId -> + deepLink.topicId?.let { topicId -> + deepLink.threadId?.let { threadId -> + launch { + try { + discussionInteractor.getCourseTopics(courseId) + .find { it.id == topicId }?.let { topic -> + launch(Dispatchers.Main) { + appRouter.navigateToDiscussionThread( + fm = fm, + action = DiscussionTopicsViewModel.TOPIC, + courseId = courseId, + topicId = topicId, + title = topic.name, + viewType = FragmentViewType.FULL_CONTENT + ) + } + } + val thread = discussionInteractor.getThread( + threadId, + courseId, + topicId + ) + launch(Dispatchers.Main) { + appRouter.navigateToDiscussionComments( + fm = fm, + thread = thread + ) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + } + } + } + + private fun navigateToDiscussionResponse(fm: FragmentManager, deepLink: DeepLink) { + val courseId = deepLink.courseId + val topicId = deepLink.topicId + val threadId = deepLink.threadId + val commentId = deepLink.commentId + if (courseId == null || topicId == null || threadId == null || commentId == null) { + return + } + launch { + try { + discussionInteractor.getCourseTopics(courseId) + .find { it.id == topicId }?.let { topic -> + launch(Dispatchers.Main) { + appRouter.navigateToDiscussionThread( + fm = fm, + action = DiscussionTopicsViewModel.TOPIC, + courseId = courseId, + topicId = topicId, + title = topic.name, + viewType = FragmentViewType.FULL_CONTENT + ) + } + } + val thread = discussionInteractor.getThread( + threadId, + courseId, + topicId + ) + launch(Dispatchers.Main) { + appRouter.navigateToDiscussionComments( + fm = fm, + thread = thread + ) + } + val response = discussionInteractor.getResponse(commentId) + launch(Dispatchers.Main) { + appRouter.navigateToDiscussionResponses( + fm = fm, + comment = response, + isClosed = false + ) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + private fun navigateToDiscussionComment(fm: FragmentManager, deepLink: DeepLink) { + val courseId = deepLink.courseId + val topicId = deepLink.topicId + val threadId = deepLink.threadId + val commentId = deepLink.commentId + val parentId = deepLink.parentId + if (courseId == null || topicId == null || threadId == null || commentId == null || parentId == null) { + return + } + launch { + try { + discussionInteractor.getCourseTopics(courseId) + .find { it.id == topicId }?.let { topic -> + launch(Dispatchers.Main) { + appRouter.navigateToDiscussionThread( + fm = fm, + action = DiscussionTopicsViewModel.TOPIC, + courseId = courseId, + topicId = topicId, + title = topic.name, + viewType = FragmentViewType.FULL_CONTENT + ) + } + } + val thread = discussionInteractor.getThread( + threadId, + courseId, + topicId + ) + launch(Dispatchers.Main) { + appRouter.navigateToDiscussionComments( + fm = fm, + thread = thread + ) + } + val comment = discussionInteractor.getResponse(parentId) + launch(Dispatchers.Main) { + appRouter.navigateToDiscussionResponses( + fm = fm, + comment = comment, + isClosed = false + ) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + private fun navigateToDashboard(fm: FragmentManager) { + appRouter.navigateToMain( + fm = fm, + courseId = null, + infoType = null, + openTab = "LEARN" + ) + } + + private fun navigateToPrograms(fm: FragmentManager) { + appRouter.navigateToMain( + fm = fm, + courseId = null, + infoType = null, + openTab = "PROGRAMS" + ) + } + + private fun navigateToProfile(fm: FragmentManager) { + appRouter.navigateToMain( + fm = fm, + courseId = null, + infoType = null, + openTab = "PROFILE" + ) + } + + private suspend fun getCourseDetails(courseId: String): Course? { + return try { + discoveryInteractor.getCourseDetails(courseId) + } catch (e: Exception) { + e.printStackTrace() + null + } + } +} diff --git a/app/src/main/java/org/openedx/app/deeplink/HomeTab.kt b/app/src/main/java/org/openedx/app/deeplink/HomeTab.kt new file mode 100644 index 000000000..c020cf636 --- /dev/null +++ b/app/src/main/java/org/openedx/app/deeplink/HomeTab.kt @@ -0,0 +1,8 @@ +package org.openedx.app.deeplink + +enum class HomeTab { + LEARN, + PROGRAMS, + DISCOVER, + PROFILE +} diff --git a/app/src/main/java/org/openedx/app/di/AppModule.kt b/app/src/main/java/org/openedx/app/di/AppModule.kt index 16a30c0c6..05d68cc49 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -14,10 +14,12 @@ import org.openedx.app.AnalyticsManager import org.openedx.app.AppAnalytics import org.openedx.app.AppRouter import org.openedx.app.BuildConfig +import org.openedx.app.PluginManager import org.openedx.app.data.storage.PreferencesManager +import org.openedx.app.deeplink.DeepLinkRouter import org.openedx.app.room.AppDatabase import org.openedx.app.room.DATABASE_NAME -import org.openedx.app.system.notifier.AppNotifier +import org.openedx.app.room.DatabaseManager import org.openedx.auth.presentation.AgreementProvider import org.openedx.auth.presentation.AuthAnalytics import org.openedx.auth.presentation.AuthRouter @@ -25,13 +27,17 @@ 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.CalendarRouter +import org.openedx.core.R import org.openedx.core.config.Config +import org.openedx.core.data.model.CourseEnrollmentDetails import org.openedx.core.data.model.CourseEnrollments +import org.openedx.core.data.storage.CalendarPreferences import org.openedx.core.data.storage.CorePreferences import org.openedx.core.data.storage.InAppReviewPreferences import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.TranscriptManager +import org.openedx.core.module.download.DownloadHelper import org.openedx.core.module.download.FileDownloader import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.presentation.dialog.appreview.AppReviewAnalytics @@ -40,17 +46,21 @@ import org.openedx.core.presentation.global.AppData import org.openedx.core.presentation.global.WhatsNewGlobalManager import org.openedx.core.presentation.global.app_upgrade.AppUpgradeRouter import org.openedx.core.system.AppCookieManager -import org.openedx.core.system.ResourceManager +import org.openedx.core.system.CalendarManager import org.openedx.core.system.connection.NetworkConnection -import org.openedx.core.system.notifier.AppUpgradeNotifier import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.DiscoveryNotifier import org.openedx.core.system.notifier.DownloadNotifier import org.openedx.core.system.notifier.VideoNotifier +import org.openedx.core.system.notifier.app.AppNotifier +import org.openedx.core.system.notifier.calendar.CalendarNotifier +import org.openedx.core.worker.CalendarSyncScheduler import org.openedx.course.data.storage.CoursePreferences import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseRouter -import org.openedx.course.presentation.calendarsync.CalendarManager +import org.openedx.course.presentation.download.DownloadDialogManager +import org.openedx.course.utils.ImageProcessor +import org.openedx.course.worker.OfflineProgressSyncScheduler import org.openedx.dashboard.presentation.DashboardAnalytics import org.openedx.dashboard.presentation.DashboardRouter import org.openedx.discovery.presentation.DiscoveryAnalytics @@ -58,14 +68,17 @@ import org.openedx.discovery.presentation.DiscoveryRouter import org.openedx.discussion.presentation.DiscussionAnalytics import org.openedx.discussion.presentation.DiscussionRouter import org.openedx.discussion.system.notifier.DiscussionNotifier +import org.openedx.foundation.system.ResourceManager +import org.openedx.foundation.utils.FileUtil import org.openedx.profile.data.storage.ProfilePreferences import org.openedx.profile.presentation.ProfileAnalytics import org.openedx.profile.presentation.ProfileRouter -import org.openedx.profile.system.notifier.ProfileNotifier +import org.openedx.profile.system.notifier.profile.ProfileNotifier import org.openedx.whatsnew.WhatsNewManager import org.openedx.whatsnew.WhatsNewRouter import org.openedx.whatsnew.data.storage.WhatsNewPreferences import org.openedx.whatsnew.presentation.WhatsNewAnalytics +import org.openedx.core.DatabaseManager as IDatabaseManager val appModule = module { @@ -76,11 +89,15 @@ val appModule = module { single { get() } single { get() } single { get() } + single { get() } single { ResourceManager(get()) } single { AppCookieManager(get(), get()) } single { ReviewManagerFactory.create(get()) } - single { CalendarManager(get(), get(), get()) } + single { CalendarManager(get(), get()) } + single { DownloadDialogManager(get(), get(), get(), get()) } + single { DatabaseManager(get(), get(), get(), get()) } + single { get() } single { ImageProcessor(get()) } @@ -94,10 +111,10 @@ val appModule = module { single { CourseNotifier() } single { DiscussionNotifier() } single { ProfileNotifier() } - single { AppUpgradeNotifier() } single { DownloadNotifier() } single { VideoNotifier() } single { DiscoveryNotifier() } + single { CalendarNotifier() } single { AppRouter() } single { get() } @@ -108,6 +125,8 @@ val appModule = module { single { get() } single { get() } single { get() } + single { DeepLinkRouter(get(), get(), get(), get(), get(), get()) } + single { get() } single { NetworkConnection(get()) } @@ -149,6 +168,11 @@ val appModule = module { room.downloadDao() } + single { + val room = get() + room.calendarDao() + } + single { FileDownloader() } @@ -160,11 +184,10 @@ val appModule = module { single { AppData(versionName = BuildConfig.VERSION_NAME) } factory { (activity: AppCompatActivity) -> AppReviewManager(activity, get(), get()) } - single { TranscriptManager(get()) } + single { TranscriptManager(get(), get()) } single { WhatsNewManager(get(), get(), get(), get()) } single { get() } - single { AnalyticsManager(get(), get()) } single { get() } single { get() } single { get() } @@ -181,4 +204,18 @@ val appModule = module { factory { GoogleAuthHelper(get()) } factory { MicrosoftAuthHelper() } factory { OAuthHelper(get(), get(), get()) } + + factory { FileUtil(get(), get().getString(R.string.app_name)) } + single { DownloadHelper(get(), get()) } + + factory { OfflineProgressSyncScheduler(get()) } + + single { CalendarSyncScheduler(get()) } + + single { AnalyticsManager() } + single { + PluginManager( + analyticsManager = get() + ) + } } diff --git a/app/src/main/java/org/openedx/app/di/NetworkingModule.kt b/app/src/main/java/org/openedx/app/di/NetworkingModule.kt index c281d0465..aae32b433 100644 --- a/app/src/main/java/org/openedx/app/di/NetworkingModule.kt +++ b/app/src/main/java/org/openedx/app/di/NetworkingModule.kt @@ -3,6 +3,7 @@ package org.openedx.app.di import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import org.koin.dsl.module +import org.openedx.app.data.api.NotificationsApi import org.openedx.app.data.networking.AppUpgradeInterceptor import org.openedx.app.data.networking.HandleErrorInterceptor import org.openedx.app.data.networking.HeadersInterceptor @@ -53,6 +54,7 @@ val networkingModule = module { single { provideApi(get()) } single { provideApi(get()) } single { provideApi(get()) } + single { provideApi(get()) } } diff --git a/app/src/main/java/org/openedx/app/di/ScreenModule.kt b/app/src/main/java/org/openedx/app/di/ScreenModule.kt index 4efd1a19e..31ebf741e 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -12,13 +12,16 @@ import org.openedx.auth.presentation.restore.RestorePasswordViewModel import org.openedx.auth.presentation.signin.SignInViewModel import org.openedx.auth.presentation.signup.SignUpViewModel import org.openedx.core.Validator +import org.openedx.core.domain.interactor.CalendarInteractor import org.openedx.core.presentation.dialog.selectorbottomsheet.SelectDialogViewModel -import org.openedx.core.presentation.settings.VideoQualityViewModel +import org.openedx.core.presentation.settings.video.VideoQualityViewModel +import org.openedx.core.repository.CalendarRepository 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.handouts.HandoutsViewModel +import org.openedx.course.presentation.offline.CourseOfflineViewModel import org.openedx.course.presentation.outline.CourseOutlineViewModel import org.openedx.course.presentation.section.CourseSectionViewModel import org.openedx.course.presentation.unit.container.CourseUnitContainerViewModel @@ -29,9 +32,11 @@ import org.openedx.course.presentation.unit.video.VideoUnitViewModel import org.openedx.course.presentation.unit.video.VideoViewModel import org.openedx.course.presentation.videos.CourseVideoViewModel import org.openedx.course.settings.download.DownloadQueueViewModel +import org.openedx.courses.presentation.AllEnrolledCoursesViewModel +import org.openedx.courses.presentation.DashboardGalleryViewModel import org.openedx.dashboard.data.repository.DashboardRepository import org.openedx.dashboard.domain.interactor.DashboardInteractor -import org.openedx.dashboard.presentation.DashboardViewModel +import org.openedx.dashboard.presentation.DashboardListViewModel import org.openedx.discovery.data.repository.DiscoveryRepository import org.openedx.discovery.domain.interactor.DiscoveryInteractor import org.openedx.discovery.presentation.NativeDiscoveryViewModel @@ -49,10 +54,16 @@ import org.openedx.discussion.presentation.search.DiscussionSearchThreadViewMode import org.openedx.discussion.presentation.threads.DiscussionAddThreadViewModel import org.openedx.discussion.presentation.threads.DiscussionThreadsViewModel import org.openedx.discussion.presentation.topics.DiscussionTopicsViewModel +import org.openedx.foundation.presentation.WindowSize +import org.openedx.learn.presentation.LearnViewModel import org.openedx.profile.data.repository.ProfileRepository import org.openedx.profile.domain.interactor.ProfileInteractor import org.openedx.profile.domain.model.Account import org.openedx.profile.presentation.anothersaccount.AnothersProfileViewModel +import org.openedx.profile.presentation.calendar.CalendarViewModel +import org.openedx.profile.presentation.calendar.CoursesToSyncViewModel +import org.openedx.profile.presentation.calendar.DisableCalendarSyncDialogViewModel +import org.openedx.profile.presentation.calendar.NewCalendarDialogViewModel import org.openedx.profile.presentation.delete.DeleteProfileViewModel import org.openedx.profile.presentation.edit.EditProfileViewModel import org.openedx.profile.presentation.manageaccount.ManageAccountViewModel @@ -63,7 +74,20 @@ import org.openedx.whatsnew.presentation.whatsnew.WhatsNewViewModel val screenModule = module { - viewModel { AppViewModel(get(), get(), get(), get(), get(named("IODispatcher")), get()) } + viewModel { + AppViewModel( + get(), + get(), + get(), + get(), + get(named("IODispatcher")), + get(), + get(), + get(), + get(), + get(), + ) + } viewModel { MainViewModel(get(), get(), get()) } factory { AuthRepository(get(), get(), get()) } @@ -92,6 +116,8 @@ val screenModule = module { get(), get(), get(), + get(), + get(), courseId, infoType, ) @@ -114,9 +140,24 @@ val screenModule = module { } viewModel { RestorePasswordViewModel(get(), get(), get(), get()) } - factory { DashboardRepository(get(), get(), get()) } + factory { DashboardRepository(get(), get(), get(), get()) } factory { DashboardInteractor(get()) } - viewModel { DashboardViewModel(get(), get(), get(), get(), get(), get(), get()) } + viewModel { DashboardListViewModel(get(), get(), get(), get(), get(), get(), get()) } + viewModel { (windowSize: WindowSize) -> + DashboardGalleryViewModel( + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + windowSize + ) + } + viewModel { AllEnrolledCoursesViewModel(get(), get(), get(), get(), get(), get(), get()) } + viewModel { LearnViewModel(get(), get(), get()) } factory { DiscoveryRepository(get(), get(), get()) } factory { DiscoveryInteractor(get()) } @@ -148,11 +189,32 @@ val screenModule = module { viewModel { (qualityType: String) -> VideoQualityViewModel(qualityType, get(), get(), get()) } viewModel { DeleteProfileViewModel(get(), get(), get(), get(), get()) } viewModel { (username: String) -> AnothersProfileViewModel(get(), get(), username) } - viewModel { SettingsViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } + viewModel { + SettingsViewModel( + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + ) + } viewModel { ManageAccountViewModel(get(), get(), get(), get(), get()) } + viewModel { CalendarViewModel(get(), get(), get(), get(), get(), get(), get(), get()) } + viewModel { CoursesToSyncViewModel(get(), get(), get(), get()) } + viewModel { NewCalendarDialogViewModel(get(), get(), get(), get(), get(), get()) } + viewModel { DisableCalendarSyncDialogViewModel(get(), get(), get(), get()) } + factory { CalendarRepository(get(), get(), get()) } + factory { CalendarInteractor(get()) } - single { CourseRepository(get(), get(), get(), get()) } + single { CourseRepository(get(), get(), get(), get(), get()) } factory { CourseInteractor(get()) } + viewModel { (pathId: String, infoType: String) -> CourseInfoViewModel( pathId, @@ -176,14 +238,15 @@ val screenModule = module { get(), get(), get(), - get() + get(), + get(), ) } - viewModel { (courseId: String, courseTitle: String, enrollmentMode: String) -> + viewModel { (courseId: String, courseTitle: String, resumeBlockId: String) -> CourseContainerViewModel( courseId, courseTitle, - enrollmentMode, + resumeBlockId, get(), get(), get(), @@ -194,7 +257,6 @@ val screenModule = module { get(), get(), get(), - get() ) } viewModel { (courseId: String, courseTitle: String) -> @@ -211,6 +273,10 @@ val screenModule = module { get(), get(), get(), + get(), + get(), + get(), + get(), ) } viewModel { (courseId: String) -> @@ -220,11 +286,6 @@ val screenModule = module { get(), get(), get(), - get(), - get(), - get(), - get(), - get(), ) } viewModel { (courseId: String, unitId: String) -> @@ -235,6 +296,7 @@ val screenModule = module { get(), get(), get(), + get(), ) } viewModel { (courseId: String, courseTitle: String) -> @@ -252,6 +314,10 @@ val screenModule = module { get(), get(), get(), + get(), + get(), + get(), + get(), ) } viewModel { (courseId: String) -> BaseVideoViewModel(courseId, get()) } @@ -279,8 +345,9 @@ val screenModule = module { get(), ) } - viewModel { (enrollmentMode: String) -> + viewModel { (courseId: String, enrollmentMode: String) -> CourseDatesViewModel( + courseId, enrollmentMode, get(), get(), @@ -289,6 +356,9 @@ val screenModule = module { get(), get(), get(), + get(), + get(), + get(), ) } viewModel { (courseId: String, handoutsType: String) -> @@ -305,8 +375,10 @@ val screenModule = module { single { DiscussionRepository(get(), get(), get()) } factory { DiscussionInteractor(get()) } - viewModel { + viewModel { (courseId: String, courseTitle: String) -> DiscussionTopicsViewModel( + courseId, + courseTitle, get(), get(), get(), @@ -370,10 +442,38 @@ val screenModule = module { get(), get(), get(), + get(), + ) + } + viewModel { (blockId: String, courseId: String) -> + HtmlUnitViewModel( + blockId, + courseId, + get(), + get(), + get(), + get(), + get(), + get(), ) } - viewModel { HtmlUnitViewModel(get(), get(), get(), get()) } viewModel { ProgramViewModel(get(), get(), get(), get(), get(), get(), get()) } + viewModel { (courseId: String, courseTitle: String) -> + CourseOfflineViewModel( + courseId, + courseTitle, + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + ) + } + } diff --git a/app/src/main/java/org/openedx/app/room/AppDatabase.kt b/app/src/main/java/org/openedx/app/room/AppDatabase.kt index be320bae7..6aa46ed1f 100644 --- a/app/src/main/java/org/openedx/app/room/AppDatabase.kt +++ b/app/src/main/java/org/openedx/app/room/AppDatabase.kt @@ -3,8 +3,12 @@ package org.openedx.app.room import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters +import org.openedx.core.data.model.room.CourseCalendarEventEntity +import org.openedx.core.data.model.room.CourseCalendarStateEntity import org.openedx.core.data.model.room.CourseStructureEntity +import org.openedx.core.data.model.room.OfflineXBlockProgress import org.openedx.core.data.model.room.discovery.EnrolledCourseEntity +import org.openedx.core.module.db.CalendarDao import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.db.DownloadModelEntity import org.openedx.course.data.storage.CourseConverter @@ -22,7 +26,10 @@ const val DATABASE_NAME = "OpenEdX_db" CourseEntity::class, EnrolledCourseEntity::class, CourseStructureEntity::class, - DownloadModelEntity::class + DownloadModelEntity::class, + OfflineXBlockProgress::class, + CourseCalendarEventEntity::class, + CourseCalendarStateEntity::class ], version = DATABASE_VERSION, exportSchema = false @@ -33,4 +40,5 @@ abstract class AppDatabase : RoomDatabase() { abstract fun courseDao(): CourseDao abstract fun dashboardDao(): DashboardDao abstract fun downloadDao(): DownloadDao + abstract fun calendarDao(): CalendarDao } diff --git a/app/src/main/java/org/openedx/app/room/DatabaseManager.kt b/app/src/main/java/org/openedx/app/room/DatabaseManager.kt new file mode 100644 index 000000000..5d5415854 --- /dev/null +++ b/app/src/main/java/org/openedx/app/room/DatabaseManager.kt @@ -0,0 +1,26 @@ +package org.openedx.app.room + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.openedx.core.DatabaseManager +import org.openedx.core.module.db.DownloadDao +import org.openedx.course.data.storage.CourseDao +import org.openedx.dashboard.data.DashboardDao +import org.openedx.discovery.data.storage.DiscoveryDao + +class DatabaseManager( + private val courseDao: CourseDao, + private val dashboardDao: DashboardDao, + private val downloadDao: DownloadDao, + private val discoveryDao: DiscoveryDao +) : DatabaseManager { + override fun clearTables() { + CoroutineScope(Dispatchers.IO).launch { + courseDao.clearCachedData() + dashboardDao.clearCachedData() + downloadDao.clearOfflineProgress() + discoveryDao.clearCachedData() + } + } +} diff --git a/app/src/main/java/org/openedx/app/system/notifier/AppEvent.kt b/app/src/main/java/org/openedx/app/system/notifier/AppEvent.kt deleted file mode 100644 index 1a6f750f4..000000000 --- a/app/src/main/java/org/openedx/app/system/notifier/AppEvent.kt +++ /dev/null @@ -1,3 +0,0 @@ -package org.openedx.app.system.notifier - -interface AppEvent \ No newline at end of file diff --git a/app/src/main/java/org/openedx/app/system/notifier/LogoutEvent.kt b/app/src/main/java/org/openedx/app/system/notifier/LogoutEvent.kt deleted file mode 100644 index 209ac8815..000000000 --- a/app/src/main/java/org/openedx/app/system/notifier/LogoutEvent.kt +++ /dev/null @@ -1,3 +0,0 @@ -package org.openedx.app.system.notifier - -class LogoutEvent : AppEvent diff --git a/app/src/main/java/org/openedx/app/system/push/OpenEdXFirebaseMessagingService.kt b/app/src/main/java/org/openedx/app/system/push/OpenEdXFirebaseMessagingService.kt new file mode 100644 index 000000000..60917940e --- /dev/null +++ b/app/src/main/java/org/openedx/app/system/push/OpenEdXFirebaseMessagingService.kt @@ -0,0 +1,95 @@ +package org.openedx.app.system.push + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.media.RingtoneManager +import android.os.Build +import android.os.SystemClock +import androidx.core.app.NotificationCompat +import com.braze.push.BrazeFirebaseMessagingService +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import org.koin.android.ext.android.inject +import org.openedx.app.AppActivity +import org.openedx.app.R +import org.openedx.core.config.Config +import org.openedx.core.data.storage.CorePreferences + +class OpenEdXFirebaseMessagingService : FirebaseMessagingService() { + + private val preferences: CorePreferences by inject() + private val config: Config by inject() + + override fun onMessageReceived(message: RemoteMessage) { + super.onMessageReceived(message) + if (BrazeFirebaseMessagingService.handleBrazeRemoteMessage(this, message)) { + // This Remote Message originated from Braze and a push notification was displayed. + // No further action is needed. + return + } else { + // This Remote Message did not originate from Braze. + handlePushNotification(message) + } + } + + override fun onNewToken(token: String) { + super.onNewToken(token) + preferences.pushToken = token + if (preferences.user != null) { + SyncFirebaseTokenWorker.schedule(this) + } + } + + private fun handlePushNotification(message: RemoteMessage) { + val notification = message.notification ?: return + val data = message.data + + val intent = Intent(this, AppActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + data.forEach { (k, v) -> + intent.putExtra(k, v) + } + + val code = createId() + val pendingIntent = PendingIntent.getActivity( + this, + code, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + val channelId = "${config.getPlatformName()}_channel" + val defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) + val notificationBuilder = NotificationCompat.Builder(this, channelId) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle(notification.title) + .setStyle( + NotificationCompat.BigTextStyle() + .bigText(notification.body)) + .setAutoCancel(true) + .setSound(defaultSoundUri) + .setContentIntent(pendingIntent) + + val notificationManager = + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + // Since android Oreo notification channel is needed. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + channelId, + config.getPlatformName(), + NotificationManager.IMPORTANCE_HIGH, + ) + notificationManager.createNotificationChannel(channel) + } + + notificationManager.notify(code, notificationBuilder.build()) + } + + private fun createId(): Int { + return SystemClock.uptimeMillis().toInt() + } +} diff --git a/app/src/main/java/org/openedx/app/system/push/RefreshFirebaseTokenWorker.kt b/app/src/main/java/org/openedx/app/system/push/RefreshFirebaseTokenWorker.kt new file mode 100644 index 000000000..0f37f36e3 --- /dev/null +++ b/app/src/main/java/org/openedx/app/system/push/RefreshFirebaseTokenWorker.kt @@ -0,0 +1,46 @@ +package org.openedx.app.system.push + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.google.firebase.messaging.FirebaseMessaging +import kotlinx.coroutines.tasks.await +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.openedx.core.data.storage.CorePreferences + +class RefreshFirebaseTokenWorker(context: Context, params: WorkerParameters) : + CoroutineWorker(context, params), + KoinComponent { + + private val preferences: CorePreferences by inject() + + override suspend fun doWork(): Result { + FirebaseMessaging.getInstance().deleteToken().await() + + val newPushToken = FirebaseMessaging.getInstance().getToken().await() + + preferences.pushToken = newPushToken + + return Result.success() + } + + companion object { + private const val WORKER_TAG = "RefreshFirebaseTokenWorker" + + fun schedule(context: Context) { + val work = OneTimeWorkRequest + .Builder(RefreshFirebaseTokenWorker::class.java) + .addTag(WORKER_TAG) + .build() + WorkManager.getInstance(context).beginUniqueWork( + WORKER_TAG, + ExistingWorkPolicy.REPLACE, + work + ).enqueue() + } + } +} diff --git a/app/src/main/java/org/openedx/app/system/push/SyncFirebaseTokenWorker.kt b/app/src/main/java/org/openedx/app/system/push/SyncFirebaseTokenWorker.kt new file mode 100644 index 000000000..ed4d841eb --- /dev/null +++ b/app/src/main/java/org/openedx/app/system/push/SyncFirebaseTokenWorker.kt @@ -0,0 +1,47 @@ +package org.openedx.app.system.push + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.openedx.app.data.api.NotificationsApi +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.module.DownloadWorker + +class SyncFirebaseTokenWorker(context: Context, params: WorkerParameters) : + CoroutineWorker(context, params), + KoinComponent { + + private val preferences: CorePreferences by inject() + private val api: NotificationsApi by inject() + + override suspend fun doWork(): Result { + if (preferences.user != null && preferences.pushToken.isNotEmpty()) { + + api.syncFirebaseToken(preferences.pushToken) + + return Result.success() + } + return Result.failure() + } + + companion object { + private const val WORKER_TAG = "SyncFirebaseTokenWorker" + + fun schedule(context: Context) { + val work = OneTimeWorkRequest + .Builder(SyncFirebaseTokenWorker::class.java) + .addTag(WORKER_TAG) + .build() + WorkManager.getInstance(context).beginUniqueWork( + WORKER_TAG, + ExistingWorkPolicy.REPLACE, + work + ).enqueue() + } + } +} diff --git a/app/src/main/res/color/bottom_nav_color.xml b/app/src/main/res/color/bottom_nav_color.xml new file mode 100644 index 000000000..4e2851e90 --- /dev/null +++ b/app/src/main/res/color/bottom_nav_color.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/app_ic_rows.xml b/app/src/main/res/drawable/app_ic_rows.xml index 41b74e9b4..eabe550d3 100644 --- a/app/src/main/res/drawable/app_ic_rows.xml +++ b/app/src/main/res/drawable/app_ic_rows.xml @@ -1,38 +1,10 @@ - - - - - - - + android:width="20dp" + android:height="17dp" + android:viewportWidth="20" + android:viewportHeight="17"> + diff --git a/app/src/main/res/layout/fragment_main.xml b/app/src/main/res/layout/fragment_main.xml index eb6f37a6f..9794b7bd7 100644 --- a/app/src/main/res/layout/fragment_main.xml +++ b/app/src/main/res/layout/fragment_main.xml @@ -14,11 +14,13 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> - - - \ No newline at end of file + diff --git a/app/src/main/res/menu/bottom_view_menu.xml b/app/src/main/res/menu/bottom_view_menu.xml index 60ba4f78c..f97e849f7 100644 --- a/app/src/main/res/menu/bottom_view_menu.xml +++ b/app/src/main/res/menu/bottom_view_menu.xml @@ -2,27 +2,21 @@ + android:icon="@drawable/app_ic_rows" + android:title="@string/app_navigation_learn" /> - - + android:icon="@drawable/app_ic_home" + android:title="@string/app_navigation_discovery" /> + android:icon="@drawable/app_ic_profile" + android:title="@string/app_navigation_profile" /> - \ No newline at end of file + diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml deleted file mode 100644 index 8e4178d90..000000000 --- a/app/src/main/res/values-uk/strings.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - Налаштування - Далі - Назад - - Всі курси - Мої курси - Програми - Профіль - \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f24815f30..baa1c2a89 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4,7 +4,7 @@ Previous Discover - Dashboard + Learn Programs Profile - \ No newline at end of file + diff --git a/app/src/test/java/org/openedx/AppViewModelTest.kt b/app/src/test/java/org/openedx/AppViewModelTest.kt index 40b3e813d..f0e748b62 100644 --- a/app/src/test/java/org/openedx/AppViewModelTest.kt +++ b/app/src/test/java/org/openedx/AppViewModelTest.kt @@ -1,5 +1,6 @@ package org.openedx +import android.content.Context import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner @@ -23,11 +24,15 @@ import org.junit.rules.TestRule import org.openedx.app.AppAnalytics import org.openedx.app.AppViewModel import org.openedx.app.data.storage.PreferencesManager +import org.openedx.app.deeplink.DeepLinkRouter import org.openedx.app.room.AppDatabase -import org.openedx.app.system.notifier.AppNotifier -import org.openedx.app.system.notifier.LogoutEvent import org.openedx.core.config.Config +import org.openedx.core.config.FirebaseConfig import org.openedx.core.data.model.User +import org.openedx.core.system.notifier.DownloadNotifier +import org.openedx.core.system.notifier.app.AppNotifier +import org.openedx.core.system.notifier.app.LogoutEvent +import org.openedx.foundation.utils.FileUtil @ExperimentalCoroutinesApi class AppViewModelTest { @@ -42,12 +47,17 @@ class AppViewModelTest { private val room = mockk() private val preferencesManager = mockk() private val analytics = mockk() + private val fileUtil = mockk() + private val deepLinkRouter = mockk() + private val context = mockk() + private val downloadNotifier = mockk() private val user = User(0, "", "", "") @Before fun before() { Dispatchers.setMain(dispatcher) + every { downloadNotifier.notifier } returns flow { } } @After @@ -60,8 +70,21 @@ class AppViewModelTest { every { analytics.setUserIdForSession(any()) } returns Unit every { preferencesManager.user } returns user every { notifier.notifier } returns flow { } - val viewModel = - AppViewModel(config, notifier, room, preferencesManager, dispatcher, analytics) + every { preferencesManager.canResetAppDirectory } returns false + every { preferencesManager.pushToken } returns "" + + val viewModel = AppViewModel( + config, + notifier, + room, + preferencesManager, + dispatcher, + analytics, + deepLinkRouter, + fileUtil, + downloadNotifier, + context, + ) val mockLifeCycleOwner: LifecycleOwner = mockk() val lifecycleRegistry = LifecycleRegistry(mockLifeCycleOwner) @@ -75,15 +98,29 @@ class AppViewModelTest { @Test fun forceLogout() = runTest { every { notifier.notifier } returns flow { - emit(LogoutEvent()) + emit(LogoutEvent(true)) } - every { preferencesManager.clear() } returns Unit + every { preferencesManager.clearCorePreferences() } returns Unit every { analytics.setUserIdForSession(any()) } returns Unit every { preferencesManager.user } returns user every { room.clearAllTables() } returns Unit every { analytics.logoutEvent(true) } returns Unit - val viewModel = - AppViewModel(config, notifier, room, preferencesManager, dispatcher, analytics) + every { preferencesManager.canResetAppDirectory } returns false + every { preferencesManager.pushToken } returns "" + every { config.getFirebaseConfig() } returns FirebaseConfig() + + val viewModel = AppViewModel( + config, + notifier, + room, + preferencesManager, + dispatcher, + analytics, + deepLinkRouter, + fileUtil, + downloadNotifier, + context, + ) val mockLifeCycleOwner: LifecycleOwner = mockk() val lifecycleRegistry = LifecycleRegistry(mockLifeCycleOwner) @@ -98,16 +135,30 @@ class AppViewModelTest { @Test fun forceLogoutTwice() = runTest { every { notifier.notifier } returns flow { - emit(LogoutEvent()) - emit(LogoutEvent()) + emit(LogoutEvent(true)) + emit(LogoutEvent(true)) } - every { preferencesManager.clear() } returns Unit + every { preferencesManager.clearCorePreferences() } returns Unit every { analytics.setUserIdForSession(any()) } returns Unit every { preferencesManager.user } returns user every { room.clearAllTables() } returns Unit every { analytics.logoutEvent(true) } returns Unit - val viewModel = - AppViewModel(config, notifier, room, preferencesManager, dispatcher, analytics) + every { preferencesManager.canResetAppDirectory } returns false + every { preferencesManager.pushToken } returns "" + every { config.getFirebaseConfig() } returns FirebaseConfig() + + val viewModel = AppViewModel( + config, + notifier, + room, + preferencesManager, + dispatcher, + analytics, + deepLinkRouter, + fileUtil, + downloadNotifier, + context, + ) val mockLifeCycleOwner: LifecycleOwner = mockk() val lifecycleRegistry = LifecycleRegistry(mockLifeCycleOwner) @@ -116,7 +167,7 @@ class AppViewModelTest { advanceUntilIdle() verify(exactly = 1) { analytics.logoutEvent(true) } - verify(exactly = 1) { preferencesManager.clear() } + verify(exactly = 1) { preferencesManager.clearCorePreferences() } verify(exactly = 1) { analytics.setUserIdForSession(any()) } verify(exactly = 1) { preferencesManager.user } verify(exactly = 1) { room.clearAllTables() } diff --git a/auth/build.gradle b/auth/build.gradle index 7cf4d0a86..470174991 100644 --- a/auth/build.gradle +++ b/auth/build.gradle @@ -2,6 +2,7 @@ plugins { id 'com.android.library' id 'org.jetbrains.kotlin.android' id 'kotlin-parcelize' + id "org.jetbrains.kotlin.plugin.compose" } android { @@ -42,37 +43,32 @@ android { } kotlinOptions { jvmTarget = JavaVersion.VERSION_17 + freeCompilerArgs = List.of("-Xstring-concat=inline") } buildFeatures { viewBinding true compose true } - composeOptions { - kotlinCompilerExtensionVersion = "$compose_compiler_version" - } } dependencies { implementation project(path: ':core') - implementation "androidx.credentials:credentials:1.2.0" - implementation "androidx.credentials:credentials-play-services-auth:1.2.0" + implementation "androidx.credentials:credentials:1.3.0" + implementation "androidx.credentials:credentials-play-services-auth:1.3.0" implementation "com.facebook.android:facebook-login:16.2.0" - implementation "com.google.android.gms:play-services-auth:21.0.0" - implementation "com.google.android.libraries.identity.googleid:googleid:1.1.0" + implementation "com.google.android.gms:play-services-auth:21.2.0" + implementation "com.google.android.libraries.identity.googleid:googleid:1.1.1" implementation("com.microsoft.identity.client:msal:4.9.0") { //Workaround for the error Failed to resolve: 'io.opentelemetry:opentelemetry-bom' for AS Iguana exclude(group: "io.opentelemetry") } implementation("io.opentelemetry:opentelemetry-api:1.18.0") implementation("io.opentelemetry:opentelemetry-context:1.18.0") - androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' - androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version" - debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version" + androidTestImplementation 'androidx.test.ext:junit:1.2.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' testImplementation "junit:junit:$junit_version" testImplementation "io.mockk:mockk:$mockk_version" testImplementation "io.mockk:mockk-android:$mockk_version" - testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version" testImplementation "androidx.arch.core:core-testing:$android_arch_version" } diff --git a/auth/proguard-rules.pro b/auth/proguard-rules.pro index 82ef50a20..a054eb116 100644 --- a/auth/proguard-rules.pro +++ b/auth/proguard-rules.pro @@ -1,26 +1,12 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile - -if class androidx.credentials.CredentialManager -keep class androidx.credentials.playservices.** { *; } + +# Prevent shrinking, optimization, and obfuscation of the library when consumed by other modules. +# This ensures that all classes and methods remain available for use by the consumer of the library. +# Disabling these steps at the library level is important because the main app module will handle +# shrinking, optimization, and obfuscation for the entire application, including this library. +-dontshrink +-dontoptimize +-dontobfuscate diff --git a/auth/src/main/java/org/openedx/auth/presentation/AgreementProvider.kt b/auth/src/main/java/org/openedx/auth/presentation/AgreementProvider.kt index 0141df227..2b8fc9708 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/AgreementProvider.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/AgreementProvider.kt @@ -3,7 +3,7 @@ package org.openedx.auth.presentation import androidx.compose.ui.text.intl.Locale import org.openedx.auth.R import org.openedx.core.config.Config -import org.openedx.core.system.ResourceManager +import org.openedx.foundation.system.ResourceManager class AgreementProvider( private val config: Config, diff --git a/auth/src/main/java/org/openedx/auth/presentation/AuthAnalytics.kt b/auth/src/main/java/org/openedx/auth/presentation/AuthAnalytics.kt index e87ad9674..40125a18e 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/AuthAnalytics.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/AuthAnalytics.kt @@ -3,9 +3,14 @@ package org.openedx.auth.presentation interface AuthAnalytics { fun setUserIdForSession(userId: Long) fun logEvent(event: String, params: Map) + fun logScreenEvent(screenName: String, params: Map) } enum class AuthAnalyticsEvent(val eventName: String, val biValue: String) { + Logistration( + "Logistration", + "edx.bi.app.logistration" + ), DISCOVERY_COURSES_SEARCH( "Logistration:Courses Search", "edx.bi.app.logistration.courses_search" @@ -14,6 +19,14 @@ enum class AuthAnalyticsEvent(val eventName: String, val biValue: String) { "Logistration:Explore All Courses", "edx.bi.app.logistration.explore.all.courses" ), + SIGN_IN( + "Logistration:Sign In", + "edx.bi.app.logistration.signin" + ), + REGISTER( + "Logistration:Register", + "edx.bi.app.logistration.register" + ), REGISTER_CLICKED( "Logistration:Register Clicked", "edx.bi.app.logistration.register.clicked" diff --git a/auth/src/main/java/org/openedx/auth/presentation/AuthRouter.kt b/auth/src/main/java/org/openedx/auth/presentation/AuthRouter.kt index 9b1266119..945acf02e 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/AuthRouter.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/AuthRouter.kt @@ -4,7 +4,12 @@ import androidx.fragment.app.FragmentManager interface AuthRouter { - fun navigateToMain(fm: FragmentManager, courseId: String?, infoType: String?) + fun navigateToMain( + fm: FragmentManager, + courseId: String?, + infoType: String?, + openTab: String = "" + ) fun navigateToSignIn(fm: FragmentManager, courseId: String?, infoType: String?) diff --git a/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationFragment.kt b/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationFragment.kt index 738364c34..6faca63ce 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationFragment.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationFragment.kt @@ -33,6 +33,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -73,7 +74,8 @@ class LogistrationFragment : Fragment() { }, onSearchClick = { querySearch -> viewModel.navigateToDiscovery(parentFragmentManager, querySearch) - } + }, + isRegistrationEnabled = viewModel.isRegistrationEnabled ) } } @@ -97,6 +99,7 @@ private fun LogistrationScreen( onSearchClick: (String) -> Unit, onRegisterClick: () -> Unit, onSignInClick: () -> Unit, + isRegistrationEnabled: Boolean, ) { var textFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { @@ -131,7 +134,6 @@ private fun LogistrationScreen( LogistrationLogoView() Text( text = stringResource(id = R.string.pre_auth_title), - color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.headlineSmall, modifier = Modifier .testTag("txt_screen_title") @@ -177,12 +179,17 @@ private fun LogistrationScreen( }, text = stringResource(id = R.string.pre_auth_explore_all_courses), color = MaterialTheme.appColors.primary, - style = MaterialTheme.appTypography.labelLarge + style = MaterialTheme.appTypography.labelLarge, + textDecoration = TextDecoration.Underline ) Spacer(modifier = Modifier.weight(1f)) - AuthButtonsPanel(onRegisterClick = onRegisterClick, onSignInClick = onSignInClick) + AuthButtonsPanel( + onRegisterClick = onRegisterClick, + onSignInClick = onSignInClick, + showRegisterButton = isRegistrationEnabled + ) } } } @@ -198,7 +205,24 @@ private fun LogistrationPreview() { LogistrationScreen( onSearchClick = {}, onSignInClick = {}, - onRegisterClick = {} + onRegisterClick = {}, + isRegistrationEnabled = true, + ) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "NEXUS_9_Light", device = Devices.NEXUS_9, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "NEXUS_9_Night", device = Devices.NEXUS_9, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun LogistrationRegistrationDisabledPreview() { + OpenEdXTheme { + LogistrationScreen( + onSearchClick = {}, + onSignInClick = {}, + onRegisterClick = {}, + isRegistrationEnabled = false, ) } } diff --git a/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationViewModel.kt b/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationViewModel.kt index e48a5e8be..2b9ca07e2 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationViewModel.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationViewModel.kt @@ -5,9 +5,9 @@ import org.openedx.auth.presentation.AuthAnalytics import org.openedx.auth.presentation.AuthAnalyticsEvent import org.openedx.auth.presentation.AuthAnalyticsKey import org.openedx.auth.presentation.AuthRouter -import org.openedx.core.BaseViewModel import org.openedx.core.config.Config -import org.openedx.core.extension.takeIfNotEmpty +import org.openedx.foundation.extension.takeIfNotEmpty +import org.openedx.foundation.presentation.BaseViewModel class LogistrationViewModel( private val courseId: String, @@ -17,6 +17,11 @@ class LogistrationViewModel( ) : BaseViewModel() { private val discoveryTypeWebView get() = config.getDiscoveryConfig().isViewTypeWebView() + val isRegistrationEnabled get() = config.isRegistrationEnabled() + + init { + logLogistrationScreenEvent() + } fun navigateToSignIn(parentFragmentManager: FragmentManager) { router.navigateToSignIn(parentFragmentManager, courseId, null) @@ -62,4 +67,14 @@ class LogistrationViewModel( } ) } + + private fun logLogistrationScreenEvent() { + val event = AuthAnalyticsEvent.Logistration + analytics.logScreenEvent( + screenName = event.eventName, + params = buildMap { + put(AuthAnalyticsKey.NAME.key, event.biValue) + } + ) + } } diff --git a/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordFragment.kt b/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordFragment.kt index 18cf169bc..6f02f231c 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordFragment.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordFragment.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource @@ -57,21 +58,21 @@ import org.koin.androidx.viewmodel.ext.android.viewModel import org.openedx.auth.presentation.ui.LoginTextField import org.openedx.core.AppUpdateState import org.openedx.core.R -import org.openedx.core.UIMessage import org.openedx.core.presentation.global.app_upgrade.AppUpgradeRequiredScreen import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OpenEdXButton -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType import org.openedx.core.ui.displayCutoutForLandscape -import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography -import org.openedx.core.ui.windowSizeValue +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue import org.openedx.auth.R as authR class RestorePasswordFragment : Fragment() { @@ -127,9 +128,9 @@ private fun RestorePasswordScreen( ) { val scaffoldState = rememberScaffoldState() val scrollState = rememberScrollState() - var email by rememberSaveable { - mutableStateOf("") - } + var email by rememberSaveable { mutableStateOf("") } + var isEmailError by rememberSaveable { mutableStateOf(false) } + val keyboardController = LocalSoftwareKeyboardController.current Scaffold( scaffoldState = scaffoldState, @@ -269,12 +270,20 @@ private fun RestorePasswordScreen( description = stringResource(id = authR.string.auth_example_email), onValueChanged = { email = it + isEmailError = false }, imeAction = ImeAction.Done, keyboardActions = { - it.clearFocus() - onRestoreButtonClick(email) - } + keyboardController?.hide() + if (email.isNotEmpty()) { + it.clearFocus() + onRestoreButtonClick(email) + } else { + isEmailError = email.isEmpty() + } + }, + isError = isEmailError, + errorMessages = stringResource(id = authR.string.auth_error_empty_email) ) Spacer(Modifier.height(50.dp)) if (uiState == RestorePasswordUIState.Loading) { @@ -292,7 +301,12 @@ private fun RestorePasswordScreen( modifier = buttonWidth.testTag("btn_reset_password"), text = stringResource(id = authR.string.auth_reset_password), onClick = { - onRestoreButtonClick(email) + keyboardController?.hide() + if (email.isNotEmpty()) { + onRestoreButtonClick(email) + } else { + isEmailError = email.isEmpty() + } } ) } diff --git a/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordViewModel.kt b/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordViewModel.kt index b21c694da..504f55a7e 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordViewModel.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordViewModel.kt @@ -8,22 +8,22 @@ import org.openedx.auth.domain.interactor.AuthInteractor import org.openedx.auth.presentation.AuthAnalytics import org.openedx.auth.presentation.AuthAnalyticsEvent import org.openedx.auth.presentation.AuthAnalyticsKey -import org.openedx.core.BaseViewModel import org.openedx.core.R -import org.openedx.core.SingleEventLiveData -import org.openedx.core.UIMessage -import org.openedx.core.extension.isEmailValid -import org.openedx.core.extension.isInternetError import org.openedx.core.system.EdxError -import org.openedx.core.system.ResourceManager -import org.openedx.core.system.notifier.AppUpgradeEvent -import org.openedx.core.system.notifier.AppUpgradeNotifier +import org.openedx.core.system.notifier.app.AppNotifier +import org.openedx.core.system.notifier.app.AppUpgradeEvent +import org.openedx.foundation.extension.isEmailValid +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.SingleEventLiveData +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager class RestorePasswordViewModel( private val interactor: AuthInteractor, private val resourceManager: ResourceManager, private val analytics: AuthAnalytics, - private val appUpgradeNotifier: AppUpgradeNotifier, + private val appNotifier: AppNotifier ) : BaseViewModel() { private val _uiState = MutableLiveData() @@ -81,8 +81,10 @@ class RestorePasswordViewModel( private fun collectAppUpgradeEvent() { viewModelScope.launch { - appUpgradeNotifier.notifier.collect { event -> - _appUpgradeEvent.value = event + appNotifier.notifier.collect { event -> + if (event is AppUpgradeEvent) { + _appUpgradeEvent.value = event + } } } } diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInFragment.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInFragment.kt index fabd8a40b..d5f11ea0a 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInFragment.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInFragment.kt @@ -17,8 +17,8 @@ import org.openedx.auth.data.model.AuthType import org.openedx.auth.presentation.signin.compose.LoginScreen import org.openedx.core.AppUpdateState import org.openedx.core.presentation.global.app_upgrade.AppUpgradeRequiredScreen -import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.foundation.presentation.rememberWindowSize class SignInFragment : Fragment() { diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInUIState.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInUIState.kt index 9ce5cfc98..7d472882f 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInUIState.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInUIState.kt @@ -18,6 +18,7 @@ internal data class SignInUIState( val isMicrosoftAuthEnabled: Boolean = false, val isSocialAuthEnabled: Boolean = false, val isLogistrationEnabled: Boolean = false, + val isRegistrationEnabled: Boolean = true, val showProgress: Boolean = false, val loginSuccess: Boolean = false, val agreement: RegistrationField? = null, diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt index 7ebc5a569..5cc08b47e 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt @@ -21,20 +21,23 @@ import org.openedx.auth.presentation.AuthAnalyticsEvent import org.openedx.auth.presentation.AuthAnalyticsKey import org.openedx.auth.presentation.AuthRouter import org.openedx.auth.presentation.sso.OAuthHelper -import org.openedx.core.BaseViewModel -import org.openedx.core.SingleEventLiveData -import org.openedx.core.UIMessage import org.openedx.core.Validator import org.openedx.core.config.Config +import org.openedx.core.data.storage.CalendarPreferences import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.interactor.CalendarInteractor import org.openedx.core.domain.model.createHonorCodeField -import org.openedx.core.extension.isInternetError import org.openedx.core.presentation.global.WhatsNewGlobalManager import org.openedx.core.system.EdxError -import org.openedx.core.system.ResourceManager -import org.openedx.core.system.notifier.AppUpgradeEvent -import org.openedx.core.system.notifier.AppUpgradeNotifier +import org.openedx.core.system.notifier.app.AppNotifier +import org.openedx.core.system.notifier.app.AppUpgradeEvent +import org.openedx.core.system.notifier.app.SignInEvent import org.openedx.core.utils.Logger +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.SingleEventLiveData +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import org.openedx.core.R as CoreRes class SignInViewModel( @@ -42,11 +45,13 @@ class SignInViewModel( private val resourceManager: ResourceManager, private val preferencesManager: CorePreferences, private val validator: Validator, - private val appUpgradeNotifier: AppUpgradeNotifier, + private val appNotifier: AppNotifier, private val analytics: AuthAnalytics, private val oAuthHelper: OAuthHelper, private val router: AuthRouter, private val whatsNewGlobalManager: WhatsNewGlobalManager, + private val calendarPreferences: CalendarPreferences, + private val calendarInteractor: CalendarInteractor, agreementProvider: AgreementProvider, config: Config, val courseId: String?, @@ -62,6 +67,7 @@ class SignInViewModel( isMicrosoftAuthEnabled = config.getMicrosoftConfig().isEnabled(), isSocialAuthEnabled = config.isSocialAuthEnabled(), isLogistrationEnabled = config.isPreLoginExperienceEnabled(), + isRegistrationEnabled = config.isRegistrationEnabled(), agreement = agreementProvider.getAgreement(isSignIn = true)?.createHonorCodeField(), ) ) @@ -77,6 +83,7 @@ class SignInViewModel( init { collectAppUpgradeEvent() + logSignInScreenEvent() } fun login(username: String, password: String) { @@ -98,6 +105,10 @@ class SignInViewModel( interactor.login(username, password) _uiState.update { it.copy(loginSuccess = true) } setUserId() + if (calendarPreferences.calendarUser != username) { + calendarPreferences.clearCalendarPreferences() + calendarInteractor.clearCalendarCachedData() + } logEvent( AuthAnalyticsEvent.SIGN_IN_SUCCESS, buildMap { @@ -107,6 +118,7 @@ class SignInViewModel( ) } ) + appNotifier.send(SignInEvent()) } catch (e: Exception) { if (e is EdxError.InvalidGrantException) { _uiMessage.value = @@ -125,8 +137,10 @@ class SignInViewModel( private fun collectAppUpgradeEvent() { viewModelScope.launch { - appUpgradeNotifier.notifier.collect { event -> - _appUpgradeEvent.value = event + appNotifier.notifier.collect { event -> + if (event is AppUpgradeEvent) { + _appUpgradeEvent.value = event + } } } } @@ -170,6 +184,7 @@ class SignInViewModel( _uiState.update { it.copy(loginSuccess = true) } setUserId() _uiState.update { it.copy(showProgress = false) } + appNotifier.send(SignInEvent()) } } @@ -240,4 +255,14 @@ class SignInViewModel( } ) } + + private fun logSignInScreenEvent() { + val event = AuthAnalyticsEvent.SIGN_IN + analytics.logScreenEvent( + screenName = event.eventName, + params = buildMap { + put(AuthAnalyticsKey.NAME.key, event.biValue) + } + ) + } } diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt index 77e290994..be4e9bf53 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -49,6 +50,8 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp @@ -57,15 +60,13 @@ import org.openedx.auth.R import org.openedx.auth.presentation.signin.AuthEvent import org.openedx.auth.presentation.signin.SignInUIState import org.openedx.auth.presentation.ui.LoginTextField +import org.openedx.auth.presentation.ui.PasswordVisibilityIcon import org.openedx.auth.presentation.ui.SocialAuthView -import org.openedx.core.UIMessage import org.openedx.core.extension.TextConverter import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.HyperlinkText import org.openedx.core.ui.OpenEdXButton -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.noRippleClickable import org.openedx.core.ui.theme.OpenEdXTheme @@ -73,7 +74,10 @@ import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography import org.openedx.core.ui.theme.compose.SignInLogoView -import org.openedx.core.ui.windowSizeValue +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.windowSizeValue import org.openedx.core.R as coreR @OptIn(ExperimentalComposeUiApi::class) @@ -195,7 +199,8 @@ internal fun LoginScreen( modifier = Modifier.testTag("txt_${state.agreement.name}"), fullText = linkedText.text, hyperLinks = linkedText.links, - linkTextColor = MaterialTheme.appColors.primary, + linkTextColor = MaterialTheme.appColors.textHyperLink, + linkTextDecoration = TextDecoration.Underline, action = { link -> onEvent(AuthEvent.OpenLink(linkedText.links, link)) }, @@ -216,6 +221,9 @@ private fun AuthForm( ) { var login by rememberSaveable { mutableStateOf("") } var password by rememberSaveable { mutableStateOf("") } + val keyboardController = LocalSoftwareKeyboardController.current + var isEmailError by rememberSaveable { mutableStateOf(false) } + var isPasswordError by rememberSaveable { mutableStateOf(false) } Column(horizontalAlignment = Alignment.CenterHorizontally) { LoginTextField( @@ -225,7 +233,11 @@ private fun AuthForm( description = stringResource(id = R.string.auth_enter_email_username), onValueChanged = { login = it - }) + isEmailError = false + }, + isError = isEmailError, + errorMessages = stringResource(id = R.string.auth_error_empty_username_email) + ) Spacer(modifier = Modifier.height(18.dp)) PasswordTextField( @@ -233,10 +245,18 @@ private fun AuthForm( .fillMaxWidth(), onValueChanged = { password = it + isPasswordError = false }, onPressDone = { - onEvent(AuthEvent.SignIn(login = login, password = password)) - } + keyboardController?.hide() + if (password.isNotEmpty()) { + onEvent(AuthEvent.SignIn(login = login, password = password)) + } else { + isEmailError = login.isEmpty() + isPasswordError = password.isEmpty() + } + }, + isError = isPasswordError, ) Row( @@ -244,7 +264,7 @@ private fun AuthForm( .fillMaxWidth() .padding(top = 20.dp, bottom = 36.dp) ) { - if (state.isLogistrationEnabled.not()) { + if (state.isLogistrationEnabled.not() && state.isRegistrationEnabled) { Text( modifier = Modifier .testTag("txt_register") @@ -264,7 +284,7 @@ private fun AuthForm( onEvent(AuthEvent.ForgotPasswordClick) }, text = stringResource(id = R.string.auth_forgot_password), - color = MaterialTheme.appColors.primary, + color = MaterialTheme.appColors.info_variant, style = MaterialTheme.appTypography.labelLarge ) } @@ -275,8 +295,16 @@ private fun AuthForm( OpenEdXButton( modifier = buttonWidth.testTag("btn_sign_in"), text = stringResource(id = coreR.string.core_sign_in), + textColor = MaterialTheme.appColors.primaryButtonText, + backgroundColor = MaterialTheme.appColors.secondaryButtonBackground, onClick = { - onEvent(AuthEvent.SignIn(login = login, password = password)) + keyboardController?.hide() + if (login.isNotEmpty() && password.isNotEmpty()) { + onEvent(AuthEvent.SignIn(login = login, password = password)) + } else { + isEmailError = login.isEmpty() + isPasswordError = password.isEmpty() + } } ) } @@ -288,6 +316,7 @@ private fun AuthForm( isMicrosoftAuthEnabled = state.isMicrosoftAuthEnabled, isSignIn = true, ) { + keyboardController?.hide() onEvent(AuthEvent.SocialSignIn(it)) } } @@ -297,15 +326,16 @@ private fun AuthForm( @Composable private fun PasswordTextField( modifier: Modifier = Modifier, + isError: Boolean, onValueChanged: (String) -> Unit, onPressDone: () -> Unit, ) { var passwordTextFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { - mutableStateOf( - TextFieldValue("") - ) + mutableStateOf(TextFieldValue("")) } + var isPasswordVisible by remember { mutableStateOf(false) } val focusManager = LocalFocusManager.current + Text( modifier = Modifier .testTag("txt_password_label") @@ -314,7 +344,9 @@ private fun PasswordTextField( color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.labelLarge ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( modifier = modifier.testTag("tf_password"), value = passwordTextFieldValue, @@ -323,8 +355,10 @@ private fun PasswordTextField( onValueChanged(it.text.trim()) }, colors = TextFieldDefaults.outlinedTextFieldColors( + textColor = MaterialTheme.appColors.textFieldText, + backgroundColor = MaterialTheme.appColors.textFieldBackground, unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, - backgroundColor = MaterialTheme.appColors.textFieldBackground + cursorColor = MaterialTheme.appColors.textFieldText, ), shape = MaterialTheme.appShapes.textFieldShape, placeholder = { @@ -335,18 +369,37 @@ private fun PasswordTextField( style = MaterialTheme.appTypography.bodyMedium ) }, + trailingIcon = { + PasswordVisibilityIcon( + isPasswordVisible = isPasswordVisible, + onClick = { isPasswordVisible = !isPasswordVisible } + ) + }, keyboardOptions = KeyboardOptions.Default.copy( keyboardType = KeyboardType.Password, imeAction = ImeAction.Done ), - visualTransformation = PasswordVisualTransformation(), + visualTransformation = if (isPasswordVisible) VisualTransformation.None + else PasswordVisualTransformation(), keyboardActions = KeyboardActions { focusManager.clearFocus() onPressDone() }, + isError = isError, textStyle = MaterialTheme.appTypography.bodyMedium, - singleLine = true + singleLine = true, ) + if (isError) { + Text( + modifier = Modifier + .testTag("txt_password_error") + .fillMaxWidth() + .padding(top = 4.dp), + text = stringResource(id = R.string.auth_error_empty_password), + style = MaterialTheme.appTypography.bodySmall, + color = MaterialTheme.appColors.error, + ) + } } @Preview(uiMode = UI_MODE_NIGHT_NO) diff --git a/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpFragment.kt b/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpFragment.kt index fa27d7d60..dabcc0e31 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpFragment.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpFragment.kt @@ -18,8 +18,8 @@ import org.openedx.auth.presentation.AuthRouter import org.openedx.auth.presentation.signup.compose.SignUpView import org.openedx.core.AppUpdateState import org.openedx.core.presentation.global.app_upgrade.AppUpgradeRequiredScreen -import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.foundation.presentation.rememberWindowSize class SignUpFragment : Fragment() { diff --git a/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpUIState.kt b/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpUIState.kt index 0f7873b78..7e60beb1d 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpUIState.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpUIState.kt @@ -2,7 +2,7 @@ package org.openedx.auth.presentation.signup import org.openedx.auth.domain.model.SocialAuthResponse import org.openedx.core.domain.model.RegistrationField -import org.openedx.core.system.notifier.AppUpgradeEvent +import org.openedx.core.system.notifier.app.AppUpgradeEvent data class SignUpUIState( val allFields: List = emptyList(), diff --git a/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpViewModel.kt b/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpViewModel.kt index 8fafe40ff..35da6c030 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpViewModel.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpViewModel.kt @@ -22,17 +22,19 @@ import org.openedx.auth.presentation.AuthAnalyticsKey import org.openedx.auth.presentation.AuthRouter import org.openedx.auth.presentation.sso.OAuthHelper import org.openedx.core.ApiConstants -import org.openedx.core.BaseViewModel -import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.RegistrationField import org.openedx.core.domain.model.RegistrationFieldType import org.openedx.core.domain.model.createHonorCodeField -import org.openedx.core.extension.isInternetError -import org.openedx.core.system.ResourceManager -import org.openedx.core.system.notifier.AppUpgradeNotifier +import org.openedx.core.system.notifier.app.AppNotifier +import org.openedx.core.system.notifier.app.AppUpgradeEvent +import org.openedx.core.system.notifier.app.SignInEvent import org.openedx.core.utils.Logger +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import org.openedx.core.R as coreR class SignUpViewModel( @@ -40,7 +42,7 @@ class SignUpViewModel( private val resourceManager: ResourceManager, private val analytics: AuthAnalytics, private val preferencesManager: CorePreferences, - private val appUpgradeNotifier: AppUpgradeNotifier, + private val appNotifier: AppNotifier, private val agreementProvider: AgreementProvider, private val oAuthHelper: OAuthHelper, private val config: Config, @@ -71,6 +73,7 @@ class SignUpViewModel( init { collectAppUpgradeEvent() + logRegisterScreenEvent() } fun getRegistrationFields() { @@ -175,6 +178,7 @@ class SignUpViewModel( ) setUserId() _uiState.update { it.copy(successLogin = true, isButtonLoading = false) } + appNotifier.send(SignInEvent()) } else { exchangeToken(socialAuth) } @@ -226,9 +230,14 @@ class SignUpViewModel( interactor.loginSocial(socialAuth.accessToken, socialAuth.authType) }.onFailure { val fields = uiState.value.allFields.toMutableList() - .filter { field -> field.type != RegistrationFieldType.PASSWORD } - updateField(ApiConstants.NAME, socialAuth.name) - updateField(ApiConstants.EMAIL, socialAuth.email) + .filter { it.type != RegistrationFieldType.PASSWORD } + .map { field -> + when (field.name) { + ApiConstants.NAME -> field.copy(placeholder = socialAuth.name) + ApiConstants.EMAIL -> field.copy(placeholder = socialAuth.email) + else -> field + } + } setErrorInstructions(emptyMap()) _uiState.update { it.copy( @@ -250,6 +259,7 @@ class SignUpViewModel( ) _uiState.update { it.copy(successLogin = true) } logger.d { "Social login (${socialAuth.authType.methodName}) success" } + appNotifier.send(SignInEvent()) } } @@ -269,8 +279,10 @@ class SignUpViewModel( private fun collectAppUpgradeEvent() { viewModelScope.launch { - appUpgradeNotifier.notifier.collect { event -> - _uiState.update { it.copy(appUpgradeEvent = event) } + appNotifier.notifier.collect { event -> + if (event is AppUpgradeEvent) { + _uiState.update { it.copy(appUpgradeEvent = event) } + } } } } @@ -313,4 +325,14 @@ class SignUpViewModel( } ) } + + private fun logRegisterScreenEvent() { + val event = AuthAnalyticsEvent.REGISTER + analytics.logScreenEvent( + screenName = event.eventName, + params = buildMap { + put(AuthAnalyticsKey.NAME.key, event.biValue) + } + ) + } } diff --git a/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt b/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt index 2e2180d83..05c5c1d4e 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt @@ -21,7 +21,6 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.CircularProgressIndicator -import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.MaterialTheme import androidx.compose.material.ModalBottomSheetLayout import androidx.compose.material.ModalBottomSheetValue @@ -53,6 +52,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Devices @@ -67,15 +67,12 @@ import org.openedx.auth.presentation.ui.ExpandableText import org.openedx.auth.presentation.ui.OptionalFields import org.openedx.auth.presentation.ui.RequiredFields import org.openedx.auth.presentation.ui.SocialAuthView -import org.openedx.core.UIMessage import org.openedx.core.domain.model.RegistrationField import org.openedx.core.domain.model.RegistrationFieldType import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.SheetContent -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.isImeVisibleState import org.openedx.core.ui.noRippleClickable @@ -85,10 +82,13 @@ import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography -import org.openedx.core.ui.windowSizeValue +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.windowSizeValue import org.openedx.core.R as coreR -@OptIn(ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class) +@OptIn(ExperimentalComposeUiApi::class) @Composable internal fun SignUpView( windowSize: WindowSize, @@ -317,10 +317,11 @@ internal fun SignUpView( Text( modifier = Modifier .fillMaxWidth() - .padding(top = 4.dp), + .padding(top = 8.dp), text = stringResource( id = R.string.auth_compete_registration ), + fontWeight = FontWeight.Bold, color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.titleSmall ) @@ -329,7 +330,7 @@ internal fun SignUpView( modifier = Modifier .testTag("txt_sign_up_title") .fillMaxWidth(), - text = stringResource(id = R.string.auth_sign_up), + text = stringResource(id = coreR.string.core_register), color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.displaySmall ) @@ -437,7 +438,10 @@ internal fun SignUpView( OpenEdXButton( modifier = buttonWidth.testTag("btn_create_account"), text = stringResource(id = R.string.auth_create_account), + textColor = MaterialTheme.appColors.primaryButtonText, + backgroundColor = MaterialTheme.appColors.secondaryButtonBackground, onClick = { + keyboardController?.hide() showErrorMap.clear() onRegisterClick(AuthType.PASSWORD) } @@ -451,6 +455,7 @@ internal fun SignUpView( isMicrosoftAuthEnabled = uiState.isMicrosoftAuthEnabled, isSignIn = false, ) { + keyboardController?.hide() onRegisterClick(it) } } @@ -474,7 +479,10 @@ private fun RegistrationScreenPreview() { SignUpView( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), uiState = SignUpUIState( - allFields = listOf(field, field, field.copy(required = false)), + allFields = listOf(field), + requiredFields = listOf(field, field), + optionalFields = listOf(field, field), + agreementFields = listOf(field), ), uiMessage = null, onBackClick = {}, diff --git a/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SocialSignedView.kt b/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SocialSignedView.kt index 25a9434d1..b2dee1919 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SocialSignedView.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SocialSignedView.kt @@ -3,11 +3,15 @@ package org.openedx.auth.presentation.signup.compose import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Devices @@ -26,21 +30,36 @@ internal fun SocialSignedView(authType: AuthType) { Column( modifier = Modifier .background( - color = MaterialTheme.appColors.secondary, + color = MaterialTheme.appColors.authSSOSuccessBackground, shape = MaterialTheme.appShapes.buttonShape ) .padding(20.dp) ) { - Text( - fontSize = 18.sp, - fontWeight = FontWeight.Bold, - text = stringResource( - id = R.string.auth_social_signed_title, - authType.methodName + Row { + Icon( + modifier = Modifier + .padding(end = 8.dp) + .size(20.dp), + painter = painterResource(id = coreR.drawable.ic_core_check), + tint = MaterialTheme.appColors.successBackground, + contentDescription = "" ) - ) + + Text( + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.primary, + text = stringResource( + id = R.string.auth_social_signed_title, + authType.methodName + ) + ) + } + Text( - modifier = Modifier.padding(top = 8.dp), + modifier = Modifier.padding(top = 8.dp, start = 28.dp), + fontSize = 14.sp, + fontWeight = FontWeight.Normal, text = stringResource( id = R.string.auth_social_signed_desc, stringResource(id = coreR.string.app_name) diff --git a/auth/src/main/java/org/openedx/auth/presentation/sso/FacebookAuthHelper.kt b/auth/src/main/java/org/openedx/auth/presentation/sso/FacebookAuthHelper.kt index 70f2209ab..0d00e734e 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/sso/FacebookAuthHelper.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/sso/FacebookAuthHelper.kt @@ -12,8 +12,8 @@ import kotlinx.coroutines.suspendCancellableCoroutine import org.openedx.auth.data.model.AuthType import org.openedx.auth.domain.model.SocialAuthResponse import org.openedx.core.ApiConstants -import org.openedx.core.extension.safeResume import org.openedx.core.utils.Logger +import org.openedx.foundation.extension.safeResume import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException @@ -46,8 +46,8 @@ class FacebookAuthHelper { continuation.safeResume( SocialAuthResponse( accessToken = result.accessToken.token, - name = obj?.getString(ApiConstants.NAME) ?: "", - email = obj?.getString(ApiConstants.EMAIL) ?: "", + name = obj?.optString(ApiConstants.NAME).orEmpty(), + email = obj?.optString(ApiConstants.EMAIL).orEmpty(), authType = AuthType.FACEBOOK, ) ) { diff --git a/auth/src/main/java/org/openedx/auth/presentation/sso/MicrosoftAuthHelper.kt b/auth/src/main/java/org/openedx/auth/presentation/sso/MicrosoftAuthHelper.kt index 7cfcef591..5b75f3896 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/sso/MicrosoftAuthHelper.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/sso/MicrosoftAuthHelper.kt @@ -12,8 +12,8 @@ import org.openedx.auth.data.model.AuthType import org.openedx.auth.domain.model.SocialAuthResponse import org.openedx.core.ApiConstants import org.openedx.core.R -import org.openedx.core.extension.safeResume import org.openedx.core.utils.Logger +import org.openedx.foundation.extension.safeResume import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException diff --git a/auth/src/main/java/org/openedx/auth/presentation/ui/AuthUI.kt b/auth/src/main/java/org/openedx/auth/presentation/ui/AuthUI.kt index 4f98ea50c..8e1a31d05 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/ui/AuthUI.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/ui/AuthUI.kt @@ -3,8 +3,8 @@ package org.openedx.auth.presentation.ui import android.content.res.Configuration import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.rememberTransition import androidx.compose.animation.core.tween -import androidx.compose.animation.core.updateTransition import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -12,10 +12,12 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.Icon +import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme import androidx.compose.material.OutlinedTextField import androidx.compose.material.Text @@ -23,6 +25,8 @@ import androidx.compose.material.TextFieldDefaults import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -44,13 +48,13 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import org.openedx.auth.R import org.openedx.core.domain.model.RegistrationField import org.openedx.core.domain.model.RegistrationFieldType import org.openedx.core.extension.TextConverter -import org.openedx.core.extension.tagId import org.openedx.core.ui.HyperlinkText import org.openedx.core.ui.SheetContent import org.openedx.core.ui.noRippleClickable @@ -58,6 +62,7 @@ import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography +import org.openedx.foundation.extension.tagId @Composable fun RequiredFields( @@ -65,11 +70,15 @@ fun RequiredFields( showErrorMap: MutableMap, selectableNamesMap: MutableMap, onFieldUpdated: (String, String) -> Unit, - onSelectClick: (String, RegistrationField, List) -> Unit + onSelectClick: (String, RegistrationField, List) -> Unit, ) { fields.forEach { field -> when (field.type) { - RegistrationFieldType.TEXT, RegistrationFieldType.EMAIL, RegistrationFieldType.CONFIRM_EMAIL, RegistrationFieldType.PASSWORD -> { + RegistrationFieldType.TEXT, + RegistrationFieldType.EMAIL, + RegistrationFieldType.CONFIRM_EMAIL, + RegistrationFieldType.PASSWORD, + -> { InputRegistrationField( modifier = Modifier.fillMaxWidth(), isErrorShown = showErrorMap[field.name] ?: true, @@ -170,7 +179,8 @@ fun OptionalFields( HyperlinkText( fullText = linkedText.text, hyperLinks = linkedText.links, - linkTextColor = MaterialTheme.appColors.primary, + linkTextColor = MaterialTheme.appColors.textHyperLink, + linkTextDecoration = TextDecoration.Underline, action = { hyperLinkAction?.invoke(linkedText.links, it) }, @@ -224,9 +234,11 @@ fun LoginTextField( modifier: Modifier = Modifier, title: String, description: String, + isError: Boolean = false, + errorMessages: String = "", onValueChanged: (String) -> Unit, imeAction: ImeAction = ImeAction.Next, - keyboardActions: (FocusManager) -> Unit = { it.moveFocus(FocusDirection.Down) } + keyboardActions: (FocusManager) -> Unit = { it.moveFocus(FocusDirection.Down) }, ) { var loginTextFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf( @@ -250,8 +262,10 @@ fun LoginTextField( onValueChanged(it.text.trim()) }, colors = TextFieldDefaults.outlinedTextFieldColors( + textColor = MaterialTheme.appColors.textFieldText, + backgroundColor = MaterialTheme.appColors.textFieldBackground, unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, - backgroundColor = MaterialTheme.appColors.textFieldBackground + cursorColor = MaterialTheme.appColors.textFieldText, ), shape = MaterialTheme.appShapes.textFieldShape, placeholder = { @@ -271,8 +285,20 @@ fun LoginTextField( }, textStyle = MaterialTheme.appTypography.bodyMedium, singleLine = true, - modifier = modifier.testTag("tf_email") + modifier = modifier.testTag("tf_email"), + isError = isError ) + if (isError) { + Text( + modifier = Modifier + .testTag("txt_email_error") + .fillMaxWidth() + .padding(top = 4.dp), + text = errorMessages, + style = MaterialTheme.appTypography.bodySmall, + color = MaterialTheme.appColors.error, + ) + } } @Composable @@ -280,16 +306,20 @@ fun InputRegistrationField( modifier: Modifier, isErrorShown: Boolean, registrationField: RegistrationField, - onValueChanged: (String, String, Boolean) -> Unit + onValueChanged: (String, String, Boolean) -> Unit, ) { var inputRegistrationFieldValue by rememberSaveable { mutableStateOf(registrationField.placeholder) } + var isPasswordVisible by remember { mutableStateOf(false) } + val focusManager = LocalFocusManager.current - val visualTransformation = if (registrationField.type == RegistrationFieldType.PASSWORD) { - PasswordVisualTransformation() - } else { - VisualTransformation.None + val visualTransformation = remember(isPasswordVisible) { + if (registrationField.type == RegistrationFieldType.PASSWORD && !isPasswordVisible) { + PasswordVisualTransformation() + } else { + VisualTransformation.None + } } val keyboardType = when (registrationField.type) { RegistrationFieldType.CONFIRM_EMAIL, RegistrationFieldType.EMAIL -> KeyboardType.Email @@ -311,6 +341,18 @@ fun InputRegistrationField( } else { registrationField.instructions } + val trailingIcon: @Composable (() -> Unit)? = + if (registrationField.type == RegistrationFieldType.PASSWORD) { + { + PasswordVisibilityIcon( + isPasswordVisible = isPasswordVisible, + onClick = { isPasswordVisible = !isPasswordVisible } + ) + } + } else { + null + } + Column { Text( modifier = Modifier @@ -332,8 +374,11 @@ fun InputRegistrationField( } }, colors = TextFieldDefaults.outlinedTextFieldColors( + textColor = MaterialTheme.appColors.textFieldText, + backgroundColor = MaterialTheme.appColors.textFieldBackground, + focusedBorderColor = MaterialTheme.appColors.textFieldBorder, unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, - backgroundColor = MaterialTheme.appColors.textFieldBackground + cursorColor = MaterialTheme.appColors.textFieldText, ), shape = MaterialTheme.appShapes.textFieldShape, placeholder = { @@ -352,6 +397,7 @@ fun InputRegistrationField( keyboardActions = KeyboardActions { focusManager.moveFocus(FocusDirection.Down) }, + trailingIcon = trailingIcon, textStyle = MaterialTheme.appTypography.bodyMedium, singleLine = isSingleLine, modifier = modifier.testTag("tf_${registrationField.name.tagId()}") @@ -371,7 +417,7 @@ fun SelectableRegisterField( registrationField: RegistrationField, isErrorShown: Boolean, initialValue: String, - onClick: (String, List) -> Unit + onClick: (String, List) -> Unit, ) { val helperTextColor = if (registrationField.errorInstructions.isEmpty()) { MaterialTheme.appColors.textSecondary @@ -411,6 +457,7 @@ fun SelectableRegisterField( OutlinedTextField( readOnly = true, enabled = false, + singleLine = true, value = initialValue, colors = TextFieldDefaults.outlinedTextFieldColors( unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, @@ -458,14 +505,14 @@ fun SelectableRegisterField( fun ExpandableText( modifier: Modifier = Modifier, isExpanded: Boolean, - onClick: (Boolean) -> Unit + onClick: (Boolean) -> Unit, ) { val transitionState = remember { MutableTransitionState(isExpanded).apply { targetState = !isExpanded } } - val transition = updateTransition(transitionState, label = "") + val transition = rememberTransition(transitionState, label = "") val arrowRotationDegree by transition.animateFloat({ tween(durationMillis = 300) }, label = "") { @@ -487,7 +534,6 @@ fun ExpandableText( }, horizontalArrangement = Arrangement.SpaceBetween ) { - //TODO: textStyle Text( modifier = Modifier, text = text, @@ -503,6 +549,26 @@ fun ExpandableText( } } +@Composable +internal fun PasswordVisibilityIcon( + isPasswordVisible: Boolean, + onClick: () -> Unit, +) { + val (image, description) = if (isPasswordVisible) { + Icons.Filled.VisibilityOff to stringResource(R.string.auth_accessibility_hide_password) + } else { + Icons.Filled.Visibility to stringResource(R.string.auth_accessibility_show_password) + } + + IconButton(onClick = onClick) { + Icon( + imageVector = image, + contentDescription = description, + tint = MaterialTheme.appColors.onSurface + ) + } +} + @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable diff --git a/auth/src/main/java/org/openedx/auth/presentation/ui/SocialAuthView.kt b/auth/src/main/java/org/openedx/auth/presentation/ui/SocialAuthView.kt index 336c09f8f..028439290 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/ui/SocialAuthView.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/ui/SocialAuthView.kt @@ -45,7 +45,7 @@ internal fun SocialAuthView( .testTag("btn_google_auth") .padding(top = 24.dp) .fillMaxWidth(), - backgroundColor = MaterialTheme.appColors.background, + backgroundColor = MaterialTheme.appColors.authGoogleButtonBackground, borderColor = MaterialTheme.appColors.primary, textColor = Color.Unspecified, onClick = { @@ -62,7 +62,8 @@ internal fun SocialAuthView( modifier = Modifier .testTag("txt_google_auth") .padding(start = 10.dp), - text = stringResource(id = stringRes) + text = stringResource(id = stringRes), + color = MaterialTheme.appColors.primaryButtonBorderedText, ) } } @@ -87,13 +88,13 @@ internal fun SocialAuthView( Icon( painter = painterResource(id = R.drawable.ic_auth_facebook), contentDescription = null, - tint = MaterialTheme.appColors.buttonText, + tint = MaterialTheme.appColors.primaryButtonText, ) Text( modifier = Modifier .testTag("txt_facebook_auth") .padding(start = 10.dp), - color = MaterialTheme.appColors.buttonText, + color = MaterialTheme.appColors.primaryButtonText, text = stringResource(id = stringRes) ) } @@ -125,7 +126,7 @@ internal fun SocialAuthView( modifier = Modifier .testTag("txt_microsoft_auth") .padding(start = 10.dp), - color = MaterialTheme.appColors.buttonText, + color = MaterialTheme.appColors.primaryButtonText, text = stringResource(id = stringRes) ) } diff --git a/auth/src/main/res/values-uk/strings.xml b/auth/src/main/res/values-uk/strings.xml deleted file mode 100644 index c2c34abef..000000000 --- a/auth/src/main/res/values-uk/strings.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - Зареєструватися - Забули пароль? - Електронна пошта - Неправильна E-mail адреса - Пароль занадто короткий - Ласкаво просимо! Будь ласка, авторизуйтесь, щоб продовжити. - Показати додаткові поля - Приховати додаткові поля - Створити акаунт - Відновити пароль - Забули пароль - Будь ласка, введіть свій логін або адресу електронної пошти для відновлення нижче, і ми надішлемо вам електронний лист з інструкціями. - Перевірте свою електронну пошту - Ми надіслали інструкції щодо відновлення пароля на вашу електронну пошту %s - Введіть пароль - Створити новий аккаунт. - - diff --git a/auth/src/main/res/values/strings.xml b/auth/src/main/res/values/strings.xml index 4f8ce12d8..49a8fb68e 100644 --- a/auth/src/main/res/values/strings.xml +++ b/auth/src/main/res/values/strings.xml @@ -11,7 +11,7 @@ Email or Username Invalid email or username Password is too short - Welcome back! Please authorize to continue. + Welcome back! Sign in to access your courses. Show optional fields Hide optional fields Create account @@ -22,8 +22,11 @@ We have sent a password recover instructions to your email %s username@domain.com Enter email or username + Please enter your username or e-mail address and try again. + Please enter your e-mail address and try again. Enter password - Create new account. + Please enter your password and try again. + Create an account to start learning today! Complete your registration Sign in with Google Sign in with Facebook @@ -39,4 +42,6 @@ By creating an account, you agree to the %1$s and %2$s and you acknowledge that %3$s and each Member process your personal data in accordance with the %4$s. By signing in to this app, you agree to the %1$s and %2$s and you acknowledge that %3$s and each Member process your personal data in accordance with the %4$s. %2$s]]> + Show password + Hide password diff --git a/auth/src/test/java/org/openedx/auth/presentation/restore/RestorePasswordViewModelTest.kt b/auth/src/test/java/org/openedx/auth/presentation/restore/RestorePasswordViewModelTest.kt index 4c92b317f..580688a48 100644 --- a/auth/src/test/java/org/openedx/auth/presentation/restore/RestorePasswordViewModelTest.kt +++ b/auth/src/test/java/org/openedx/auth/presentation/restore/RestorePasswordViewModelTest.kt @@ -23,10 +23,10 @@ import org.junit.rules.TestRule import org.openedx.auth.domain.interactor.AuthInteractor import org.openedx.auth.presentation.AuthAnalytics import org.openedx.core.R -import org.openedx.core.UIMessage import org.openedx.core.system.EdxError -import org.openedx.core.system.ResourceManager -import org.openedx.core.system.notifier.AppUpgradeNotifier +import org.openedx.core.system.notifier.app.AppNotifier +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException @OptIn(ExperimentalCoroutinesApi::class) @@ -39,7 +39,7 @@ class RestorePasswordViewModelTest { private val resourceManager = mockk() private val interactor = mockk() private val analytics = mockk() - private val appUpgradeNotifier = mockk() + private val appNotifier = mockk() //region parameters @@ -60,7 +60,7 @@ class RestorePasswordViewModelTest { every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong every { resourceManager.getString(org.openedx.auth.R.string.auth_invalid_email) } returns invalidEmail every { resourceManager.getString(org.openedx.auth.R.string.auth_invalid_password) } returns invalidPassword - every { appUpgradeNotifier.notifier } returns emptyFlow() + every { appNotifier.notifier } returns emptyFlow() } @After @@ -71,14 +71,14 @@ class RestorePasswordViewModelTest { @Test fun `passwordReset empty email validation error`() = runTest { val viewModel = - RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) + RestorePasswordViewModel(interactor, resourceManager, analytics, appNotifier) coEvery { interactor.passwordReset(emptyEmail) } returns true every { analytics.logEvent(any(), any()) } returns Unit viewModel.passwordReset(emptyEmail) advanceUntilIdle() coVerify(exactly = 0) { interactor.passwordReset(any()) } verify(exactly = 2) { analytics.logEvent(any(), any()) } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage @@ -89,14 +89,14 @@ class RestorePasswordViewModelTest { @Test fun `passwordReset invalid email validation error`() = runTest { val viewModel = - RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) + RestorePasswordViewModel(interactor, resourceManager, analytics, appNotifier) coEvery { interactor.passwordReset(invalidEmail) } returns true every { analytics.logEvent(any(), any()) } returns Unit viewModel.passwordReset(invalidEmail) advanceUntilIdle() coVerify(exactly = 0) { interactor.passwordReset(any()) } verify(exactly = 2) { analytics.logEvent(any(), any()) } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage @@ -107,14 +107,14 @@ class RestorePasswordViewModelTest { @Test fun `passwordReset validation error`() = runTest { val viewModel = - RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) + RestorePasswordViewModel(interactor, resourceManager, analytics, appNotifier) coEvery { interactor.passwordReset(correctEmail) } throws EdxError.ValidationException("error") every { analytics.logEvent(any(), any()) } returns Unit viewModel.passwordReset(correctEmail) advanceUntilIdle() coVerify(exactly = 1) { interactor.passwordReset(any()) } verify(exactly = 2) { analytics.logEvent(any(), any()) } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage @@ -125,14 +125,14 @@ class RestorePasswordViewModelTest { @Test fun `passwordReset no internet error`() = runTest { val viewModel = - RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) + RestorePasswordViewModel(interactor, resourceManager, analytics, appNotifier) coEvery { interactor.passwordReset(correctEmail) } throws UnknownHostException() every { analytics.logEvent(any(), any()) } returns Unit viewModel.passwordReset(correctEmail) advanceUntilIdle() coVerify(exactly = 1) { interactor.passwordReset(any()) } verify(exactly = 2) { analytics.logEvent(any(), any()) } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage @@ -143,14 +143,14 @@ class RestorePasswordViewModelTest { @Test fun `passwordReset unknown error`() = runTest { val viewModel = - RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) + RestorePasswordViewModel(interactor, resourceManager, analytics, appNotifier) coEvery { interactor.passwordReset(correctEmail) } throws Exception() every { analytics.logEvent(any(), any()) } returns Unit viewModel.passwordReset(correctEmail) advanceUntilIdle() coVerify(exactly = 1) { interactor.passwordReset(any()) } verify(exactly = 2) { analytics.logEvent(any(), any()) } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage @@ -161,14 +161,14 @@ class RestorePasswordViewModelTest { @Test fun `unSuccess restore password`() = runTest { val viewModel = - RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) + RestorePasswordViewModel(interactor, resourceManager, analytics, appNotifier) coEvery { interactor.passwordReset(correctEmail) } returns false every { analytics.logEvent(any(), any()) } returns Unit viewModel.passwordReset(correctEmail) advanceUntilIdle() coVerify(exactly = 1) { interactor.passwordReset(any()) } verify(exactly = 2) { analytics.logEvent(any(), any()) } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage @@ -180,14 +180,14 @@ class RestorePasswordViewModelTest { @Test fun `success restore password`() = runTest { val viewModel = - RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) + RestorePasswordViewModel(interactor, resourceManager, analytics, appNotifier) coEvery { interactor.passwordReset(correctEmail) } returns true every { analytics.logEvent(any(), any()) } returns Unit viewModel.passwordReset(correctEmail) advanceUntilIdle() coVerify(exactly = 1) { interactor.passwordReset(any()) } verify(exactly = 2) { analytics.logEvent(any(), any()) } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } val state = viewModel.uiState.value as? RestorePasswordUIState.Success val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage diff --git a/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt b/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt index b36aabb10..48480e310 100644 --- a/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt +++ b/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt @@ -27,18 +27,21 @@ import org.openedx.auth.presentation.AgreementProvider import org.openedx.auth.presentation.AuthAnalytics import org.openedx.auth.presentation.AuthRouter import org.openedx.auth.presentation.sso.OAuthHelper -import org.openedx.core.UIMessage import org.openedx.core.Validator import org.openedx.core.config.Config import org.openedx.core.config.FacebookConfig import org.openedx.core.config.GoogleConfig import org.openedx.core.config.MicrosoftConfig import org.openedx.core.data.model.User +import org.openedx.core.data.storage.CalendarPreferences import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.interactor.CalendarInteractor import org.openedx.core.presentation.global.WhatsNewGlobalManager import org.openedx.core.system.EdxError -import org.openedx.core.system.ResourceManager -import org.openedx.core.system.notifier.AppUpgradeNotifier +import org.openedx.core.system.notifier.app.AppNotifier +import org.openedx.core.system.notifier.app.SignInEvent +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException import org.openedx.core.R as CoreRes @@ -56,11 +59,13 @@ class SignInViewModelTest { private val preferencesManager = mockk() private val interactor = mockk() private val analytics = mockk() - private val appUpgradeNotifier = mockk() + private val appNotifier = mockk() private val agreementProvider = mockk() private val oAuthHelper = mockk() private val router = mockk() private val whatsNewGlobalManager = mockk() + private val calendarInteractor = mockk() + private val calendarPreferences = mockk() private val invalidCredential = "Invalid credentials" private val noInternet = "Slow or no internet connection" @@ -78,13 +83,18 @@ class SignInViewModelTest { every { resourceManager.getString(CoreRes.string.core_error_unknown_error) } returns somethingWrong every { resourceManager.getString(R.string.auth_invalid_email_username) } returns invalidEmailOrUsername every { resourceManager.getString(R.string.auth_invalid_password) } returns invalidPassword - every { appUpgradeNotifier.notifier } returns emptyFlow() + every { appNotifier.notifier } returns emptyFlow() every { agreementProvider.getAgreement(true) } returns null every { config.isPreLoginExperienceEnabled() } returns false every { config.isSocialAuthEnabled() } returns false every { config.getFacebookConfig() } returns FacebookConfig() every { config.getGoogleConfig() } returns GoogleConfig() every { config.getMicrosoftConfig() } returns MicrosoftConfig() + every { calendarPreferences.calendarUser } returns "" + every { calendarPreferences.clearCalendarPreferences() } returns Unit + coEvery { calendarInteractor.clearCalendarCachedData() } returns Unit + every { analytics.logScreenEvent(any(), any()) } returns Unit + every { config.isRegistrationEnabled() } returns true } @After @@ -104,7 +114,7 @@ class SignInViewModelTest { preferencesManager = preferencesManager, validator = validator, analytics = analytics, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, @@ -112,11 +122,14 @@ class SignInViewModelTest { whatsNewGlobalManager = whatsNewGlobalManager, courseId = "", infoType = "", + calendarInteractor = calendarInteractor, + calendarPreferences = calendarPreferences ) viewModel.login("", "") coVerify(exactly = 0) { interactor.login(any(), any()) } verify(exactly = 0) { analytics.setUserIdForSession(any()) } verify(exactly = 1) { analytics.logEvent(any(), any()) } + verify(exactly = 1) { analytics.logScreenEvent(any(), any()) } val message = viewModel.uiMessage.value as UIMessage.SnackBarMessage val uiState = viewModel.uiState.value @@ -137,7 +150,7 @@ class SignInViewModelTest { preferencesManager = preferencesManager, validator = validator, analytics = analytics, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, @@ -145,6 +158,8 @@ class SignInViewModelTest { whatsNewGlobalManager = whatsNewGlobalManager, courseId = "", infoType = "", + calendarInteractor = calendarInteractor, + calendarPreferences = calendarPreferences ) viewModel.login("acc@test.o", "") coVerify(exactly = 0) { interactor.login(any(), any()) } @@ -171,7 +186,7 @@ class SignInViewModelTest { preferencesManager = preferencesManager, validator = validator, analytics = analytics, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, @@ -179,6 +194,8 @@ class SignInViewModelTest { whatsNewGlobalManager = whatsNewGlobalManager, courseId = "", infoType = "", + calendarInteractor = calendarInteractor, + calendarPreferences = calendarPreferences ) viewModel.login("acc@test.org", "") @@ -204,7 +221,7 @@ class SignInViewModelTest { preferencesManager = preferencesManager, validator = validator, analytics = analytics, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, @@ -212,12 +229,15 @@ class SignInViewModelTest { whatsNewGlobalManager = whatsNewGlobalManager, courseId = "", infoType = "", + calendarInteractor = calendarInteractor, + calendarPreferences = calendarPreferences ) viewModel.login("acc@test.org", "ed") coVerify(exactly = 0) { interactor.login(any(), any()) } verify(exactly = 0) { analytics.setUserIdForSession(any()) } verify(exactly = 1) { analytics.logEvent(any(), any()) } + verify(exactly = 1) { analytics.logScreenEvent(any(), any()) } val message = viewModel.uiMessage.value as UIMessage.SnackBarMessage val uiState = viewModel.uiState.value @@ -233,13 +253,14 @@ class SignInViewModelTest { every { preferencesManager.user } returns user every { analytics.setUserIdForSession(any()) } returns Unit every { analytics.logEvent(any(), any()) } returns Unit + coEvery { appNotifier.send(any()) } returns Unit val viewModel = SignInViewModel( interactor = interactor, resourceManager = resourceManager, preferencesManager = preferencesManager, validator = validator, analytics = analytics, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, @@ -247,6 +268,8 @@ class SignInViewModelTest { whatsNewGlobalManager = whatsNewGlobalManager, courseId = "", infoType = "", + calendarInteractor = calendarInteractor, + calendarPreferences = calendarPreferences ) coEvery { interactor.login("acc@test.org", "edx") } returns Unit viewModel.login("acc@test.org", "edx") @@ -255,7 +278,8 @@ class SignInViewModelTest { coVerify(exactly = 1) { interactor.login(any(), any()) } verify(exactly = 1) { analytics.setUserIdForSession(any()) } verify(exactly = 2) { analytics.logEvent(any(), any()) } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { analytics.logScreenEvent(any(), any()) } + verify(exactly = 1) { appNotifier.notifier } val uiState = viewModel.uiState.value assertFalse(uiState.showProgress) assert(uiState.loginSuccess) @@ -275,7 +299,7 @@ class SignInViewModelTest { preferencesManager = preferencesManager, validator = validator, analytics = analytics, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, @@ -283,6 +307,8 @@ class SignInViewModelTest { whatsNewGlobalManager = whatsNewGlobalManager, courseId = "", infoType = "", + calendarInteractor = calendarInteractor, + calendarPreferences = calendarPreferences ) coEvery { interactor.login("acc@test.org", "edx") } throws UnknownHostException() viewModel.login("acc@test.org", "edx") @@ -291,7 +317,8 @@ class SignInViewModelTest { coVerify(exactly = 1) { interactor.login(any(), any()) } verify(exactly = 0) { analytics.setUserIdForSession(any()) } verify(exactly = 1) { analytics.logEvent(any(), any()) } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { analytics.logScreenEvent(any(), any()) } + verify(exactly = 1) { appNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage val uiState = viewModel.uiState.value @@ -313,7 +340,7 @@ class SignInViewModelTest { preferencesManager = preferencesManager, validator = validator, analytics = analytics, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, @@ -321,6 +348,8 @@ class SignInViewModelTest { whatsNewGlobalManager = whatsNewGlobalManager, courseId = "", infoType = "", + calendarInteractor = calendarInteractor, + calendarPreferences = calendarPreferences ) coEvery { interactor.login("acc@test.org", "edx") } throws EdxError.InvalidGrantException() viewModel.login("acc@test.org", "edx") @@ -328,8 +357,9 @@ class SignInViewModelTest { coVerify(exactly = 1) { interactor.login(any(), any()) } verify(exactly = 0) { analytics.setUserIdForSession(any()) } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } verify(exactly = 1) { analytics.logEvent(any(), any()) } + verify(exactly = 1) { analytics.logScreenEvent(any(), any()) } val message = viewModel.uiMessage.value as UIMessage.SnackBarMessage val uiState = viewModel.uiState.value @@ -351,7 +381,7 @@ class SignInViewModelTest { preferencesManager = preferencesManager, validator = validator, analytics = analytics, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, @@ -359,6 +389,8 @@ class SignInViewModelTest { whatsNewGlobalManager = whatsNewGlobalManager, courseId = "", infoType = "", + calendarInteractor = calendarInteractor, + calendarPreferences = calendarPreferences ) coEvery { interactor.login("acc@test.org", "edx") } throws IllegalStateException() viewModel.login("acc@test.org", "edx") @@ -366,8 +398,9 @@ class SignInViewModelTest { coVerify(exactly = 1) { interactor.login(any(), any()) } verify(exactly = 0) { analytics.setUserIdForSession(any()) } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } verify(exactly = 1) { analytics.logEvent(any(), any()) } + verify(exactly = 1) { analytics.logScreenEvent(any(), any()) } val message = viewModel.uiMessage.value as UIMessage.SnackBarMessage val uiState = viewModel.uiState.value diff --git a/auth/src/test/java/org/openedx/auth/presentation/signup/SignUpViewModelTest.kt b/auth/src/test/java/org/openedx/auth/presentation/signup/SignUpViewModelTest.kt index f304f7363..90ef8728f 100644 --- a/auth/src/test/java/org/openedx/auth/presentation/signup/SignUpViewModelTest.kt +++ b/auth/src/test/java/org/openedx/auth/presentation/signup/SignUpViewModelTest.kt @@ -33,7 +33,6 @@ import org.openedx.auth.presentation.AuthRouter import org.openedx.auth.presentation.sso.OAuthHelper import org.openedx.core.ApiConstants import org.openedx.core.R -import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.config.FacebookConfig import org.openedx.core.config.GoogleConfig @@ -43,8 +42,9 @@ import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.AgreementUrls import org.openedx.core.domain.model.RegistrationField import org.openedx.core.domain.model.RegistrationFieldType -import org.openedx.core.system.ResourceManager -import org.openedx.core.system.notifier.AppUpgradeNotifier +import org.openedx.core.system.notifier.app.AppNotifier +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException @ExperimentalCoroutinesApi @@ -59,7 +59,7 @@ class SignUpViewModelTest { private val preferencesManager = mockk() private val interactor = mockk() private val analytics = mockk() - private val appUpgradeNotifier = mockk() + private val appNotifier = mockk() private val agreementProvider = mockk() private val oAuthHelper = mockk() private val router = mockk() @@ -111,7 +111,7 @@ class SignUpViewModelTest { every { resourceManager.getString(R.string.core_error_invalid_grant) } returns "Invalid credentials" every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong - every { appUpgradeNotifier.notifier } returns emptyFlow() + every { appNotifier.notifier } returns emptyFlow() every { agreementProvider.getAgreement(false) } returns null every { config.isSocialAuthEnabled() } returns false every { config.getAgreement(Locale.current.language) } returns AgreementUrls() @@ -119,6 +119,7 @@ class SignUpViewModelTest { every { config.getGoogleConfig() } returns GoogleConfig() every { config.getMicrosoftConfig() } returns MicrosoftConfig() every { config.getMicrosoftConfig() } returns MicrosoftConfig() + every { analytics.logScreenEvent(any(), any()) } returns Unit } @After @@ -133,7 +134,7 @@ class SignUpViewModelTest { resourceManager = resourceManager, analytics = analytics, preferencesManager = preferencesManager, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, @@ -159,10 +160,11 @@ class SignUpViewModelTest { advanceUntilIdle() coVerify(exactly = 1) { interactor.validateRegistrationFields(any()) } verify(exactly = 1) { analytics.logEvent(any(), any()) } + verify(exactly = 1) { analytics.logScreenEvent(any(), any()) } coVerify(exactly = 0) { interactor.register(any()) } coVerify(exactly = 0) { interactor.login(any(), any()) } verify(exactly = 0) { analytics.setUserIdForSession(any()) } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } assertEquals(true, viewModel.uiState.value.validationError) assertFalse(viewModel.uiState.value.successLogin) @@ -176,7 +178,7 @@ class SignUpViewModelTest { resourceManager = resourceManager, analytics = analytics, preferencesManager = preferencesManager, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, @@ -206,11 +208,12 @@ class SignUpViewModelTest { viewModel.register() advanceUntilIdle() verify(exactly = 1) { analytics.logEvent(any(), any()) } + verify(exactly = 1) { analytics.logScreenEvent(any(), any()) } verify(exactly = 0) { analytics.setUserIdForSession(any()) } coVerify(exactly = 1) { interactor.validateRegistrationFields(any()) } coVerify(exactly = 0) { interactor.register(any()) } coVerify(exactly = 0) { interactor.login(any(), any()) } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } assertFalse(viewModel.uiState.value.validationError) assertFalse(viewModel.uiState.value.successLogin) @@ -225,7 +228,7 @@ class SignUpViewModelTest { resourceManager = resourceManager, analytics = analytics, preferencesManager = preferencesManager, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, @@ -245,10 +248,11 @@ class SignUpViewModelTest { advanceUntilIdle() verify(exactly = 0) { analytics.setUserIdForSession(any()) } verify(exactly = 1) { analytics.logEvent(any(), any()) } + verify(exactly = 1) { analytics.logScreenEvent(any(), any()) } coVerify(exactly = 1) { interactor.validateRegistrationFields(any()) } coVerify(exactly = 0) { interactor.register(any()) } coVerify(exactly = 0) { interactor.login(any(), any()) } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } assertFalse(viewModel.uiState.value.validationError) assertFalse(viewModel.uiState.value.successLogin) @@ -263,7 +267,7 @@ class SignUpViewModelTest { resourceManager = resourceManager, analytics = analytics, preferencesManager = preferencesManager, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, @@ -298,7 +302,8 @@ class SignUpViewModelTest { coVerify(exactly = 1) { interactor.register(any()) } coVerify(exactly = 1) { interactor.login(any(), any()) } verify(exactly = 2) { analytics.logEvent(any(), any()) } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { analytics.logScreenEvent(any(), any()) } + verify(exactly = 1) { appNotifier.notifier } assertFalse(viewModel.uiState.value.validationError) assertFalse(viewModel.uiState.value.isButtonLoading) @@ -312,7 +317,7 @@ class SignUpViewModelTest { resourceManager = resourceManager, analytics = analytics, preferencesManager = preferencesManager, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, @@ -326,7 +331,7 @@ class SignUpViewModelTest { viewModel.getRegistrationFields() advanceUntilIdle() coVerify(exactly = 1) { interactor.getRegistrationFields() } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } assertFalse(viewModel.uiState.value.isLoading) assertEquals(noInternet, (deferred.await() as? UIMessage.SnackBarMessage)?.message) @@ -339,7 +344,7 @@ class SignUpViewModelTest { resourceManager = resourceManager, analytics = analytics, preferencesManager = preferencesManager, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, @@ -353,7 +358,7 @@ class SignUpViewModelTest { viewModel.getRegistrationFields() advanceUntilIdle() coVerify(exactly = 1) { interactor.getRegistrationFields() } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } assertFalse(viewModel.uiState.value.isLoading) assertEquals(somethingWrong, (deferred.await() as? UIMessage.SnackBarMessage)?.message) @@ -366,7 +371,7 @@ class SignUpViewModelTest { resourceManager = resourceManager, analytics = analytics, preferencesManager = preferencesManager, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, @@ -378,7 +383,7 @@ class SignUpViewModelTest { viewModel.getRegistrationFields() advanceUntilIdle() coVerify(exactly = 1) { interactor.getRegistrationFields() } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } //val fields = viewModel.uiState.value as? SignUpUIState.Fields diff --git a/build.gradle b/build.gradle index ef9ca662c..e7f7d673b 100644 --- a/build.gradle +++ b/build.gradle @@ -5,19 +5,20 @@ import java.util.regex.Pattern buildscript { ext { - kotlin_version = '1.9.22' - coroutines_version = '1.7.1' - compose_version = '1.6.2' - compose_compiler_version = '1.5.10' + //Depends on versions in OEXFoundation + kotlin_version = '2.0.0' + room_version = '2.6.1' } } plugins { - id 'com.android.application' version '8.4.0' apply false - id 'com.android.library' version '8.4.0' apply false + id 'com.android.application' version '8.5.2' apply false + id 'com.android.library' version '8.5.2' apply false id 'org.jetbrains.kotlin.android' version "$kotlin_version" apply false - id 'com.google.gms.google-services' version '4.3.15' apply false - id "com.google.firebase.crashlytics" version "2.9.6" apply false + id 'com.google.gms.google-services' version '4.4.2' apply false + id "com.google.firebase.crashlytics" version "3.0.2" apply false + id "com.google.devtools.ksp" version "2.0.0-1.0.24" apply false + id "org.jetbrains.kotlin.plugin.compose" version "$kotlin_version" apply false } tasks.register('clean', Delete) { @@ -25,41 +26,22 @@ tasks.register('clean', Delete) { } ext { - core_version = "1.10.1" - appcompat_version = "1.6.1" - material_version = "1.11.0" - lifecycle_version = "2.7.0" - fragment_version = "1.6.2" - constraintlayout_version = "2.1.4" - viewpager2_version = "1.0.0" - media3_version = "1.1.1" + media3_version = "1.4.1" youtubeplayer_version = "11.1.0" - firebase_version = "32.1.0" - - retrofit_version = '2.9.0' - logginginterceptor_version = '4.9.1' - - koin_version = '3.2.0' - - coil_version = '2.3.0' + firebase_version = "33.0.0" jsoup_version = '1.13.1' - room_version = '2.6.1' - - work_version = '2.9.0' - - window_version = '1.2.0' - in_app_review = '2.0.1' extented_spans_version = "1.3.0" configHelper = new ConfigHelper(projectDir, getCurrentFlavor()) + zip_version = '2.6.3' //testing - mockk_version = '1.13.3' + mockk_version = '1.13.12' android_arch_version = '2.2.0' junit_version = '4.13.2' } @@ -81,6 +63,6 @@ def getCurrentFlavor() { } } -task generateMockedRawFile() { +tasks.register('generateMockedRawFile') { doLast { configHelper.generateMicrosoftConfig() } } diff --git a/core/build.gradle b/core/build.gradle index f1f091823..f1ae6be5e 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -4,7 +4,7 @@ buildscript { } dependencies { - classpath 'org.yaml:snakeyaml:1.33' + classpath 'org.yaml:snakeyaml:2.0' } } @@ -12,12 +12,13 @@ plugins { id 'com.android.library' id 'org.jetbrains.kotlin.android' id 'kotlin-parcelize' - id 'kotlin-kapt' + id 'com.google.devtools.ksp' + id "org.jetbrains.kotlin.plugin.compose" } def currentFlavour = getCurrentFlavor() def config = configHelper.fetchConfig() -def platformName = config.getOrDefault("PLATFORM_NAME", "OpenEdx").toLowerCase() +def themeDirectory = config.getOrDefault("THEME_DIRECTORY", "openedx") android { compileSdk 34 @@ -50,16 +51,16 @@ android { sourceSets { prod { - java.srcDirs = ["src/$platformName"] - res.srcDirs = ["src/$platformName/res"] + java.srcDirs = ["src/$themeDirectory"] + res.srcDirs = ["src/$themeDirectory/res"] } develop { - java.srcDirs = ["src/$platformName"] - res.srcDirs = ["src/$platformName/res"] + java.srcDirs = ["src/$themeDirectory"] + res.srcDirs = ["src/$themeDirectory/res"] } stage { - java.srcDirs = ["src/$platformName"] - res.srcDirs = ["src/$platformName/res"] + java.srcDirs = ["src/$themeDirectory"] + res.srcDirs = ["src/$themeDirectory/res"] } main { assets { @@ -80,6 +81,7 @@ android { } kotlinOptions { jvmTarget = JavaVersion.VERSION_17 + freeCompilerArgs = List.of("-Xstring-concat=inline") } buildFeatures { @@ -87,76 +89,39 @@ android { compose true buildConfig true } - composeOptions { - kotlinCompilerExtensionVersion = "$compose_compiler_version" - } } dependencies { api fileTree(dir: 'libs', include: ['*.jar']) - api "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" - api "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version" - api "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version" - - //AndroidX - api "androidx.core:core-ktx:$core_version" - api "androidx.appcompat:appcompat:$appcompat_version" - api "com.google.android.material:material:$material_version" - api "androidx.fragment:fragment-ktx:$fragment_version" - api "androidx.constraintlayout:constraintlayout:$constraintlayout_version" - api "androidx.viewpager2:viewpager2:$viewpager2_version" - api "androidx.window:window:$window_version" - api "androidx.work:work-runtime-ktx:$work_version" - - //Android Jetpack - api "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version" - api "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version" - api "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version" - // Room - api "androidx.room:room-runtime:$room_version" - api "androidx.room:room-ktx:$room_version" - kapt "androidx.room:room-compiler:$room_version" - - //Compose - api "androidx.compose.runtime:runtime:$compose_version" - api "androidx.compose.runtime:runtime-livedata:$compose_version" - api "androidx.compose.ui:ui:$compose_version" - api "androidx.compose.material:material:$compose_version" - api "androidx.compose.foundation:foundation:$compose_version" - debugApi "androidx.compose.ui:ui-tooling:$compose_version" - api "androidx.compose.ui:ui-tooling-preview:$compose_version" - api "androidx.compose.material:material-icons-extended:$compose_version" - debugApi "androidx.customview:customview:1.2.0-alpha02" - debugApi "androidx.customview:customview-poolingcontainer:1.0.0" - - //Networking - api "com.squareup.retrofit2:retrofit:$retrofit_version" - api "com.squareup.retrofit2:converter-gson:$retrofit_version" - api "com.squareup.okhttp3:logging-interceptor:$logginginterceptor_version" - - // Koin DI - api "io.insert-koin:koin-core:$koin_version" - api "io.insert-koin:koin-android:$koin_version" - api "io.insert-koin:koin-androidx-compose:$koin_version" - - api "io.coil-kt:coil-compose:$coil_version" - api "io.coil-kt:coil-gif:$coil_version" + ksp "androidx.room:room-compiler:$room_version" + // jsoup api "org.jsoup:jsoup:$jsoup_version" + // Firebase api platform("com.google.firebase:firebase-bom:$firebase_version") api 'com.google.firebase:firebase-common-ktx' api "com.google.firebase:firebase-crashlytics-ktx" - api "com.google.firebase:firebase-analytics-ktx" //Play In-App Review api "com.google.android.play:review-ktx:$in_app_review" + // Branch SDK Integration + api "io.branch.sdk.android:library:5.9.0" + api "com.google.android.gms:play-services-ads-identifier:18.1.0" + api "com.android.installreferrer:installreferrer:2.2" + + // Zip + api "net.lingala.zip4j:zip4j:$zip_version" + + // OpenEdx libs + api("com.github.openedx:openedx-app-foundation-android:1.0.0") + testImplementation 'junit:junit:4.13.2' - androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + androidTestImplementation 'androidx.test.ext:junit:1.2.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' } def insertBuildConfigFields(currentFlavour, buildType) { diff --git a/core/consumer-rules.pro b/core/consumer-rules.pro index 894a21021..e69de29bb 100644 --- a/core/consumer-rules.pro +++ b/core/consumer-rules.pro @@ -1,2 +0,0 @@ --dontwarn java.lang.invoke.StringConcatFactory --dontwarn org.openedx.core.R$string \ No newline at end of file diff --git a/core/proguard-rules.pro b/core/proguard-rules.pro index a6be9313d..cdb308aa0 100644 --- a/core/proguard-rules.pro +++ b/core/proguard-rules.pro @@ -1,23 +1,7 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile - --dontwarn java.lang.invoke.StringConcatFactory \ No newline at end of file +# Prevent shrinking, optimization, and obfuscation of the library when consumed by other modules. +# This ensures that all classes and methods remain available for use by the consumer of the library. +# Disabling these steps at the library level is important because the main app module will handle +# shrinking, optimization, and obfuscation for the entire application, including this library. +-dontshrink +-dontoptimize +-dontobfuscate diff --git a/core/src/main/java/org/openedx/core/AppUpdateState.kt b/core/src/main/java/org/openedx/core/AppUpdateState.kt index bf347cd29..6d6a8e357 100644 --- a/core/src/main/java/org/openedx/core/AppUpdateState.kt +++ b/core/src/main/java/org/openedx/core/AppUpdateState.kt @@ -5,7 +5,7 @@ import android.content.Context import android.content.Intent import android.net.Uri import androidx.compose.runtime.mutableStateOf -import org.openedx.core.system.notifier.AppUpgradeEvent +import org.openedx.core.system.notifier.app.AppUpgradeEvent object AppUpdateState { var wasUpdateDialogDisplayed = false diff --git a/core/src/main/java/org/openedx/core/BaseViewModel.kt b/core/src/main/java/org/openedx/core/BaseViewModel.kt deleted file mode 100644 index ac0578624..000000000 --- a/core/src/main/java/org/openedx/core/BaseViewModel.kt +++ /dev/null @@ -1,6 +0,0 @@ -package org.openedx.core - -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.ViewModel - -open class BaseViewModel : ViewModel(), DefaultLifecycleObserver \ No newline at end of file diff --git a/core/src/main/java/org/openedx/core/CalendarRouter.kt b/core/src/main/java/org/openedx/core/CalendarRouter.kt new file mode 100644 index 000000000..1969ca860 --- /dev/null +++ b/core/src/main/java/org/openedx/core/CalendarRouter.kt @@ -0,0 +1,8 @@ +package org.openedx.core + +import androidx.fragment.app.FragmentManager + +interface CalendarRouter { + + fun navigateToCalendarSettings(fm: FragmentManager) +} diff --git a/core/src/main/java/org/openedx/core/DatabaseManager.kt b/core/src/main/java/org/openedx/core/DatabaseManager.kt new file mode 100644 index 000000000..d7bc7d025 --- /dev/null +++ b/core/src/main/java/org/openedx/core/DatabaseManager.kt @@ -0,0 +1,5 @@ +package org.openedx.core + +interface DatabaseManager { + fun clearTables() +} diff --git a/core/src/main/java/org/openedx/core/NoContentScreenType.kt b/core/src/main/java/org/openedx/core/NoContentScreenType.kt new file mode 100644 index 000000000..88e8ad94b --- /dev/null +++ b/core/src/main/java/org/openedx/core/NoContentScreenType.kt @@ -0,0 +1,31 @@ +package org.openedx.core + +enum class NoContentScreenType( + val iconResId: Int, + val messageResId: Int, +) { + COURSE_OUTLINE( + iconResId = R.drawable.core_ic_no_content, + messageResId = R.string.core_no_course_content + ), + COURSE_VIDEOS( + iconResId = R.drawable.core_ic_no_videos, + messageResId = R.string.core_no_videos + ), + COURSE_DATES( + iconResId = R.drawable.core_ic_no_content, + messageResId = R.string.core_no_dates + ), + COURSE_DISCUSSIONS( + iconResId = R.drawable.core_ic_no_content, + messageResId = R.string.core_no_discussion + ), + COURSE_HANDOUTS( + iconResId = R.drawable.core_ic_no_handouts, + messageResId = R.string.core_no_handouts + ), + COURSE_ANNOUNCEMENTS( + iconResId = R.drawable.core_ic_no_announcements, + messageResId = R.string.core_no_announcements + ) +} diff --git a/core/src/main/java/org/openedx/core/SingleEventLiveData.kt b/core/src/main/java/org/openedx/core/SingleEventLiveData.kt deleted file mode 100644 index dfa53c6dd..000000000 --- a/core/src/main/java/org/openedx/core/SingleEventLiveData.kt +++ /dev/null @@ -1,39 +0,0 @@ -package org.openedx.core - -import androidx.annotation.MainThread -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.Observer - -class SingleEventLiveData : MutableLiveData() { - - @MainThread - override fun observe(owner: LifecycleOwner, observer: Observer) { - - // Being strict about the observer numbers is up to you - // I thought it made sense to only allow one to handle the event - if (hasActiveObservers()) { - throw IllegalAccessException("Only one observer at a time may observe to a SingleEventLiveData") - } - - super.observe(owner, Observer { data -> - // We ignore any null values and early return - if (data == null) return@Observer - observer.onChanged(data) - // We set the value to null straight after emitting the change to the observer - value = null - // This means that the state of the data will always be null / non existent - // It will only be available to the observer in its callback and since we do not emit null values - // the observer never receives a null value and any observers resuming do not receive the last event. - // Therefore it only emits to the observer the single action - // so you are free to show messages over and over again - // Or launch an activity/dialog or anything that should only happen once per action / click :). - }) - } - - // Just a nicely named method that wraps setting the value - @MainThread - fun sendAction(data: T) { - value = data - } -} \ No newline at end of file diff --git a/core/src/main/java/org/openedx/core/UIMessage.kt b/core/src/main/java/org/openedx/core/UIMessage.kt deleted file mode 100644 index 8a9267f36..000000000 --- a/core/src/main/java/org/openedx/core/UIMessage.kt +++ /dev/null @@ -1,12 +0,0 @@ -package org.openedx.core - -import androidx.compose.material.SnackbarDuration - -open class UIMessage { - class SnackBarMessage( - val message: String, - val duration: SnackbarDuration = SnackbarDuration.Long, - ) : UIMessage() - - class ToastMessage(val message: String) : UIMessage() -} diff --git a/app/src/main/java/org/openedx/app/adapter/MainNavigationFragmentAdapter.kt b/core/src/main/java/org/openedx/core/adapter/NavigationFragmentAdapter.kt similarity index 74% rename from app/src/main/java/org/openedx/app/adapter/MainNavigationFragmentAdapter.kt rename to core/src/main/java/org/openedx/core/adapter/NavigationFragmentAdapter.kt index ccbe6f715..708b43829 100644 --- a/app/src/main/java/org/openedx/app/adapter/MainNavigationFragmentAdapter.kt +++ b/core/src/main/java/org/openedx/core/adapter/NavigationFragmentAdapter.kt @@ -1,9 +1,9 @@ -package org.openedx.app.adapter +package org.openedx.core.adapter import androidx.fragment.app.Fragment import androidx.viewpager2.adapter.FragmentStateAdapter -class MainNavigationFragmentAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) { +class NavigationFragmentAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) { private val fragments = ArrayList() @@ -14,4 +14,4 @@ class MainNavigationFragmentAdapter(fragment: Fragment) : FragmentStateAdapter(f fun addFragment(fragment: Fragment) { fragments.add(fragment) } -} \ No newline at end of file +} diff --git a/core/src/main/java/org/openedx/core/config/AnalyticsSource.kt b/core/src/main/java/org/openedx/core/config/AnalyticsSource.kt deleted file mode 100644 index b3ee82211..000000000 --- a/core/src/main/java/org/openedx/core/config/AnalyticsSource.kt +++ /dev/null @@ -1,11 +0,0 @@ -package org.openedx.core.config - -import com.google.gson.annotations.SerializedName - -enum class AnalyticsSource { - @SerializedName("segment") - SEGMENT, - - @SerializedName("none") - NONE, -} diff --git a/core/src/main/java/org/openedx/core/config/Config.kt b/core/src/main/java/org/openedx/core/config/Config.kt index 4b40fbc29..e38a923b5 100644 --- a/core/src/main/java/org/openedx/core/config/Config.kt +++ b/core/src/main/java/org/openedx/core/config/Config.kt @@ -10,45 +10,44 @@ import java.io.InputStreamReader class Config(context: Context) { - private var configProperties: JsonObject - - init { - configProperties = try { - val inputStream = context.assets.open("config/config.json") - val parser = JsonParser() - val config = parser.parse(InputStreamReader(inputStream)) - config.asJsonObject - } catch (e: Exception) { - JsonObject() - } + private var configProperties: JsonObject = try { + val inputStream = context.assets.open("config/config.json") + val config = JsonParser.parseReader(InputStreamReader(inputStream)) + config.asJsonObject + } catch (e: Exception) { + JsonObject() + } + + fun getAppId(): String { + return getString(APPLICATION_ID, "") } fun getApiHostURL(): String { - return getString(API_HOST_URL, "") + return getString(API_HOST_URL) } fun getUriScheme(): String { - return getString(URI_SCHEME, "") + return getString(URI_SCHEME) } fun getOAuthClientId(): String { - return getString(OAUTH_CLIENT_ID, "") + return getString(OAUTH_CLIENT_ID) } fun getAccessTokenType(): String { - return getString(TOKEN_TYPE, "") + return getString(TOKEN_TYPE) } fun getFaqUrl(): String { - return getString(FAQ_URL, "") + return getString(FAQ_URL) } fun getFeedbackEmailAddress(): String { - return getString(FEEDBACK_EMAIL_ADDRESS, "") + return getString(FEEDBACK_EMAIL_ADDRESS) } fun getPlatformName(): String { - return getString(PLATFORM_NAME, "") + return getString(PLATFORM_NAME) } fun getAgreement(locale: String): AgreementUrls { @@ -61,10 +60,6 @@ class Config(context: Context) { return getObjectOrNewInstance(FIREBASE, FirebaseConfig::class.java) } - fun getSegmentConfig(): SegmentConfig { - return getObjectOrNewInstance(SEGMENT_IO, SegmentConfig::class.java) - } - fun getBrazeConfig(): BrazeConfig { return getObjectOrNewInstance(BRAZE, BrazeConfig::class.java) } @@ -91,6 +86,10 @@ class Config(context: Context) { return getObjectOrNewInstance(PROGRAM, ProgramConfig::class.java) } + fun getDashboardConfig(): DashboardConfig { + return getObjectOrNewInstance(DASHBOARD, DashboardConfig::class.java) + } + fun getBranchConfig(): BranchConfig { return getObjectOrNewInstance(BRANCH, BranchConfig::class.java) } @@ -103,15 +102,15 @@ class Config(context: Context) { return getBoolean(PRE_LOGIN_EXPERIENCE_ENABLED, true) } - fun isCourseNestedListEnabled(): Boolean { - return getBoolean(COURSE_NESTED_LIST_ENABLED, false) + fun getCourseUIConfig(): UIConfig { + return getObjectOrNewInstance(UI_COMPONENTS, UIConfig::class.java) } - fun isCourseUnitProgressEnabled(): Boolean { - return getBoolean(COURSE_UNIT_PROGRESS_ENABLED, false) + fun isRegistrationEnabled(): Boolean { + return getBoolean(REGISTRATION_ENABLED, true) } - private fun getString(key: String, defaultValue: String): String { + private fun getString(key: String, defaultValue: String = ""): String { val element = getObject(key) return if (element != null) { element.asString @@ -146,6 +145,7 @@ class Config(context: Context) { } companion object { + private const val APPLICATION_ID = "APPLICATION_ID" private const val API_HOST_URL = "API_HOST_URL" private const val URI_SCHEME = "URI_SCHEME" private const val OAUTH_CLIENT_ID = "OAUTH_CLIENT_ID" @@ -156,17 +156,17 @@ class Config(context: Context) { private const val WHATS_NEW_ENABLED = "WHATS_NEW_ENABLED" private const val SOCIAL_AUTH_ENABLED = "SOCIAL_AUTH_ENABLED" private const val FIREBASE = "FIREBASE" - private const val SEGMENT_IO = "SEGMENT_IO" private const val BRAZE = "BRAZE" private const val FACEBOOK = "FACEBOOK" private const val GOOGLE = "GOOGLE" private const val MICROSOFT = "MICROSOFT" private const val PRE_LOGIN_EXPERIENCE_ENABLED = "PRE_LOGIN_EXPERIENCE_ENABLED" + private const val REGISTRATION_ENABLED = "REGISTRATION_ENABLED" private const val DISCOVERY = "DISCOVERY" private const val PROGRAM = "PROGRAM" + private const val DASHBOARD = "DASHBOARD" private const val BRANCH = "BRANCH" - private const val COURSE_NESTED_LIST_ENABLED = "COURSE_NESTED_LIST_ENABLED" - private const val COURSE_UNIT_PROGRESS_ENABLED = "COURSE_UNIT_PROGRESS_ENABLED" + private const val UI_COMPONENTS = "UI_COMPONENTS" private const val PLATFORM_NAME = "PLATFORM_NAME" } diff --git a/core/src/main/java/org/openedx/core/config/DashboardConfig.kt b/core/src/main/java/org/openedx/core/config/DashboardConfig.kt new file mode 100644 index 000000000..9aa081aff --- /dev/null +++ b/core/src/main/java/org/openedx/core/config/DashboardConfig.kt @@ -0,0 +1,16 @@ +package org.openedx.core.config + +import com.google.gson.annotations.SerializedName + +data class DashboardConfig( + @SerializedName("TYPE") + private val viewType: String = DashboardType.GALLERY.name, +) { + fun getType(): DashboardType { + return DashboardType.valueOf(viewType.uppercase()) + } + + enum class DashboardType { + LIST, GALLERY + } +} diff --git a/core/src/main/java/org/openedx/core/config/FirebaseConfig.kt b/core/src/main/java/org/openedx/core/config/FirebaseConfig.kt index f5b2e9136..878b1e734 100644 --- a/core/src/main/java/org/openedx/core/config/FirebaseConfig.kt +++ b/core/src/main/java/org/openedx/core/config/FirebaseConfig.kt @@ -6,9 +6,6 @@ data class FirebaseConfig( @SerializedName("ENABLED") val enabled: Boolean = false, - @SerializedName("ANALYTICS_SOURCE") - val analyticsSource: AnalyticsSource = AnalyticsSource.NONE, - @SerializedName("CLOUD_MESSAGING_ENABLED") val isCloudMessagingEnabled: Boolean = false, @@ -23,8 +20,4 @@ data class FirebaseConfig( @SerializedName("API_KEY") val apiKey: String = "", -) { - fun isSegmentAnalyticsSource(): Boolean { - return enabled && analyticsSource == AnalyticsSource.SEGMENT - } -} +) diff --git a/core/src/main/java/org/openedx/core/config/ProgramConfig.kt b/core/src/main/java/org/openedx/core/config/ProgramConfig.kt index 55714dadc..ce34365ec 100644 --- a/core/src/main/java/org/openedx/core/config/ProgramConfig.kt +++ b/core/src/main/java/org/openedx/core/config/ProgramConfig.kt @@ -14,8 +14,8 @@ data class ProgramConfig( } data class ProgramWebViewConfig( - @SerializedName("PROGRAM_URL") + @SerializedName("BASE_URL") val programUrl: String = "", - @SerializedName("PROGRAM_DETAIL_URL_TEMPLATE") + @SerializedName("PROGRAM_DETAIL_TEMPLATE") val programDetailUrlTemplate: String = "", ) diff --git a/core/src/main/java/org/openedx/core/config/SegmentConfig.kt b/core/src/main/java/org/openedx/core/config/SegmentConfig.kt deleted file mode 100644 index ffa43e8bc..000000000 --- a/core/src/main/java/org/openedx/core/config/SegmentConfig.kt +++ /dev/null @@ -1,11 +0,0 @@ -package org.openedx.core.config - -import com.google.gson.annotations.SerializedName - -data class SegmentConfig( - @SerializedName("ENABLED") - val enabled: Boolean = false, - - @SerializedName("SEGMENT_IO_WRITE_KEY") - val segmentWriteKey: String = "", -) diff --git a/core/src/main/java/org/openedx/core/config/UIConfig.kt b/core/src/main/java/org/openedx/core/config/UIConfig.kt new file mode 100644 index 000000000..0da0388bd --- /dev/null +++ b/core/src/main/java/org/openedx/core/config/UIConfig.kt @@ -0,0 +1,12 @@ +package org.openedx.core.config + +import com.google.gson.annotations.SerializedName + +data class UIConfig( + @SerializedName("COURSE_DROPDOWN_NAVIGATION_ENABLED") + val isCourseDropdownNavigationEnabled: Boolean = false, + @SerializedName("COURSE_UNIT_PROGRESS_ENABLED") + val isCourseUnitProgressEnabled: Boolean = false, + @SerializedName("COURSE_DOWNLOAD_QUEUE_SCREEN") + val isCourseDownloadQueueEnabled: Boolean = false, +) diff --git a/core/src/main/java/org/openedx/core/data/api/CourseApi.kt b/core/src/main/java/org/openedx/core/data/api/CourseApi.kt index 4a19c383d..32d401f7b 100644 --- a/core/src/main/java/org/openedx/core/data/api/CourseApi.kt +++ b/core/src/main/java/org/openedx/core/data/api/CourseApi.kt @@ -1,18 +1,23 @@ package org.openedx.core.data.api +import okhttp3.MultipartBody import org.openedx.core.data.model.AnnouncementModel import org.openedx.core.data.model.BlocksCompletionBody import org.openedx.core.data.model.CourseComponentStatus import org.openedx.core.data.model.CourseDates import org.openedx.core.data.model.CourseDatesBannerInfo +import org.openedx.core.data.model.CourseEnrollmentDetails import org.openedx.core.data.model.CourseEnrollments import org.openedx.core.data.model.CourseStructureModel +import org.openedx.core.data.model.EnrollmentStatus import org.openedx.core.data.model.HandoutsModel import org.openedx.core.data.model.ResetCourseDates import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.Header +import retrofit2.http.Multipart import retrofit2.http.POST +import retrofit2.http.Part import retrofit2.http.Path import retrofit2.http.Query @@ -67,4 +72,31 @@ interface CourseApi { @GET("/api/mobile/v1/course_info/{course_id}/updates") suspend fun getAnnouncements(@Path("course_id") courseId: String): List + + @GET("/api/mobile/v4/users/{username}/course_enrollments/") + suspend fun getUserCourses( + @Path("username") username: String, + @Query("page") page: Int = 1, + @Query("page_size") pageSize: Int = 20, + @Query("status") status: String? = null, + @Query("requested_fields") fields: List = emptyList() + ): CourseEnrollments + + @Multipart + @POST("/courses/{course_id}/xblock/{block_id}/handler/xmodule_handler/problem_check") + suspend fun submitOfflineXBlockProgress( + @Path("course_id") courseId: String, + @Path("block_id") blockId: String, + @Part progress: List + ) + + @GET("/api/mobile/v1/users/{username}/enrollments_status/") + suspend fun getEnrollmentsStatus( + @Path("username") username: String + ): List + + @GET("/api/mobile/v1/course_info/{course_id}/enrollment_details") + suspend fun getEnrollmentDetails( + @Path("course_id") courseId: String, + ): CourseEnrollmentDetails } diff --git a/core/src/main/java/org/openedx/core/data/model/AssignmentProgress.kt b/core/src/main/java/org/openedx/core/data/model/AssignmentProgress.kt new file mode 100644 index 000000000..2ac10cb18 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/AssignmentProgress.kt @@ -0,0 +1,26 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.data.model.room.AssignmentProgressDb +import org.openedx.core.domain.model.AssignmentProgress + +data class AssignmentProgress( + @SerializedName("assignment_type") + val assignmentType: String?, + @SerializedName("num_points_earned") + val numPointsEarned: Float?, + @SerializedName("num_points_possible") + val numPointsPossible: Float?, +) { + fun mapToDomain() = AssignmentProgress( + assignmentType = assignmentType ?: "", + numPointsEarned = numPointsEarned ?: 0f, + numPointsPossible = numPointsPossible ?: 0f + ) + + fun mapToRoomEntity() = AssignmentProgressDb( + assignmentType = assignmentType, + numPointsEarned = numPointsEarned, + numPointsPossible = numPointsPossible + ) +} diff --git a/core/src/main/java/org/openedx/core/data/model/Block.kt b/core/src/main/java/org/openedx/core/data/model/Block.kt index 9c07367ac..c4b50df63 100644 --- a/core/src/main/java/org/openedx/core/data/model/Block.kt +++ b/core/src/main/java/org/openedx/core/data/model/Block.kt @@ -2,7 +2,12 @@ package org.openedx.core.data.model import com.google.gson.annotations.SerializedName import org.openedx.core.BlockType -import org.openedx.core.domain.model.Block +import org.openedx.core.utils.TimeUtils +import org.openedx.core.domain.model.Block as DomainBlock +import org.openedx.core.domain.model.BlockCounts as DomainBlockCounts +import org.openedx.core.domain.model.EncodedVideos as DomainEncodedVideos +import org.openedx.core.domain.model.StudentViewData as DomainStudentViewData +import org.openedx.core.domain.model.VideoInfo as DomainVideoInfo data class Block( @SerializedName("id") @@ -33,8 +38,14 @@ data class Block( val completion: Double?, @SerializedName("contains_gated_content") val containsGatedContent: Boolean?, + @SerializedName("assignment_progress") + val assignmentProgress: AssignmentProgress?, + @SerializedName("due") + val due: String?, + @SerializedName("offline_download") + val offlineDownload: OfflineDownload?, ) { - fun mapToDomain(blockData: Map): Block { + fun mapToDomain(blockData: Map): DomainBlock { val blockType = BlockType.getBlockType(type ?: "") val descendantsType = if (blockType == BlockType.VERTICAL) { val types = descendants?.map { descendant -> @@ -46,7 +57,7 @@ data class Block( blockType } - return org.openedx.core.domain.model.Block( + return DomainBlock( id = id ?: "", blockId = blockId ?: "", lmsWebUrl = lmsWebUrl ?: "", @@ -61,7 +72,10 @@ data class Block( studentViewMultiDevice = studentViewMultiDevice ?: false, blockCounts = blockCounts?.mapToDomain()!!, completion = completion ?: 0.0, - containsGatedContent = containsGatedContent ?: false + containsGatedContent = containsGatedContent ?: false, + assignmentProgress = assignmentProgress?.mapToDomain(), + due = TimeUtils.iso8601ToDate(due ?: ""), + offlineDownload = offlineDownload?.mapToDomain() ) } } @@ -80,8 +94,8 @@ data class StudentViewData( @SerializedName("topic_id") val topicId: String? ) { - fun mapToDomain(): org.openedx.core.domain.model.StudentViewData { - return org.openedx.core.domain.model.StudentViewData( + fun mapToDomain(): DomainStudentViewData { + return DomainStudentViewData( onlyOnWeb = onlyOnWeb ?: false, duration = duration ?: "", transcripts = transcripts, @@ -106,8 +120,8 @@ data class EncodedVideos( var mobileLow: VideoInfo? ) { - fun mapToDomain(): org.openedx.core.domain.model.EncodedVideos { - return org.openedx.core.domain.model.EncodedVideos( + fun mapToDomain(): DomainEncodedVideos { + return DomainEncodedVideos( youtube = videoInfo?.mapToDomain(), hls = hls?.mapToDomain(), fallback = fallback?.mapToDomain(), @@ -122,10 +136,10 @@ data class VideoInfo( @SerializedName("url") var url: String?, @SerializedName("file_size") - var fileSize: Int? + var fileSize: Long? ) { - fun mapToDomain(): org.openedx.core.domain.model.VideoInfo { - return org.openedx.core.domain.model.VideoInfo( + fun mapToDomain(): DomainVideoInfo { + return DomainVideoInfo( url = url ?: "", fileSize = fileSize ?: 0 ) @@ -136,9 +150,9 @@ data class BlockCounts( @SerializedName("video") var video: Int? ) { - fun mapToDomain(): org.openedx.core.domain.model.BlockCounts { - return org.openedx.core.domain.model.BlockCounts( + fun mapToDomain(): DomainBlockCounts { + return DomainBlockCounts( video = video ?: 0 ) } -} \ No newline at end of file +} diff --git a/core/src/main/java/org/openedx/core/data/model/CourseAccessDetails.kt b/core/src/main/java/org/openedx/core/data/model/CourseAccessDetails.kt new file mode 100644 index 000000000..c69b092ed --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseAccessDetails.kt @@ -0,0 +1,36 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.data.model.room.discovery.CourseAccessDetailsDb +import org.openedx.core.utils.TimeUtils +import org.openedx.core.domain.model.CourseAccessDetails as DomainCourseAccessDetails + +data class CourseAccessDetails( + @SerializedName("has_unmet_prerequisites") + val hasUnmetPrerequisites: Boolean, + @SerializedName("is_too_early") + val isTooEarly: Boolean, + @SerializedName("is_staff") + val isStaff: Boolean, + @SerializedName("audit_access_expires") + val auditAccessExpires: String?, + @SerializedName("courseware_access") + var coursewareAccess: CoursewareAccess?, +) { + fun mapToDomain() = DomainCourseAccessDetails( + hasUnmetPrerequisites = hasUnmetPrerequisites, + isTooEarly = isTooEarly, + isStaff = isStaff, + auditAccessExpires = TimeUtils.iso8601ToDate(auditAccessExpires ?: ""), + coursewareAccess = coursewareAccess?.mapToDomain(), + ) + + fun mapToRoomEntity(): CourseAccessDetailsDb = + CourseAccessDetailsDb( + hasUnmetPrerequisites = hasUnmetPrerequisites, + isTooEarly = isTooEarly, + isStaff = isStaff, + auditAccessExpires = auditAccessExpires, + coursewareAccess = coursewareAccess?.mapToRoomEntity() + ) +} diff --git a/core/src/main/java/org/openedx/core/data/model/CourseAssignments.kt b/core/src/main/java/org/openedx/core/data/model/CourseAssignments.kt new file mode 100644 index 000000000..ed8de3a4e --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseAssignments.kt @@ -0,0 +1,30 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.data.model.room.discovery.CourseAssignmentsDb +import org.openedx.core.domain.model.CourseAssignments + +data class CourseAssignments( + @SerializedName("future_assignments") + val futureAssignments: List?, + @SerializedName("past_assignments") + val pastAssignments: List?, +) { + fun mapToDomain() = CourseAssignments( + futureAssignments = futureAssignments?.mapNotNull { + it.mapToDomain() + }, + pastAssignments = pastAssignments?.mapNotNull { + it.mapToDomain() + } + ) + + fun mapToRoomEntity() = CourseAssignmentsDb( + futureAssignments = futureAssignments?.mapNotNull { + it.mapToRoomEntity() + }, + pastAssignments = pastAssignments?.mapNotNull { + it.mapToRoomEntity() + } + ) +} diff --git a/core/src/main/java/org/openedx/core/data/model/CourseDateBlock.kt b/core/src/main/java/org/openedx/core/data/model/CourseDateBlock.kt index 887112845..d29e7a7ea 100644 --- a/core/src/main/java/org/openedx/core/data/model/CourseDateBlock.kt +++ b/core/src/main/java/org/openedx/core/data/model/CourseDateBlock.kt @@ -1,8 +1,13 @@ package org.openedx.core.data.model +import android.os.Parcelable import com.google.gson.annotations.SerializedName -import java.util.* +import kotlinx.parcelize.Parcelize +import org.openedx.core.data.model.room.discovery.CourseDateBlockDb +import org.openedx.core.domain.model.CourseDateBlock +import org.openedx.core.utils.TimeUtils +@Parcelize data class CourseDateBlock( @SerializedName("complete") val complete: Boolean = false, @@ -25,4 +30,36 @@ data class CourseDateBlock( // component blockId in-case of navigating inside the app for component available in mobile @SerializedName("first_component_block_id") val blockId: String = "", -) +) : Parcelable { + fun mapToDomain(): CourseDateBlock? { + TimeUtils.iso8601ToDate(date)?.let { + return CourseDateBlock( + complete = complete, + date = it, + assignmentType = assignmentType, + dateType = dateType, + description = description, + learnerHasAccess = learnerHasAccess, + link = link, + title = title, + blockId = blockId + ) + } ?: return null + } + + fun mapToRoomEntity(): CourseDateBlockDb? { + TimeUtils.iso8601ToDate(date)?.let { + return CourseDateBlockDb( + complete = complete, + date = it, + assignmentType = assignmentType, + dateType = dateType, + description = description, + learnerHasAccess = learnerHasAccess, + link = link, + title = title, + blockId = blockId + ) + } ?: return null + } +} diff --git a/core/src/main/java/org/openedx/core/data/model/CourseEnrollmentDetails.kt b/core/src/main/java/org/openedx/core/data/model/CourseEnrollmentDetails.kt new file mode 100644 index 000000000..b27057eac --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseEnrollmentDetails.kt @@ -0,0 +1,36 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.domain.model.CourseEnrollmentDetails as DomainCourseEnrollmentDetails + +data class CourseEnrollmentDetails( + @SerializedName("id") + val id: String, + @SerializedName("course_updates") + val courseUpdates: String?, + @SerializedName("course_handouts") + val courseHandouts: String?, + @SerializedName("discussion_url") + val discussionUrl: String?, + @SerializedName("course_access_details") + val courseAccessDetails: CourseAccessDetails, + @SerializedName("certificate") + val certificate: Certificate?, + @SerializedName("enrollment_details") + val enrollmentDetails: EnrollmentDetails, + @SerializedName("course_info_overview") + val courseInfoOverview: CourseInfoOverview, +) { + fun mapToDomain(): DomainCourseEnrollmentDetails { + return DomainCourseEnrollmentDetails( + id = id, + courseUpdates = courseUpdates ?: "", + courseHandouts = courseHandouts ?: "", + discussionUrl = discussionUrl ?: "", + courseAccessDetails = courseAccessDetails.mapToDomain(), + certificate = certificate?.mapToDomain(), + enrollmentDetails = enrollmentDetails.mapToDomain(), + courseInfoOverview = courseInfoOverview.mapToDomain(), + ) + } +} diff --git a/core/src/main/java/org/openedx/core/data/model/CourseEnrollments.kt b/core/src/main/java/org/openedx/core/data/model/CourseEnrollments.kt index 89ecdcab4..ca28740fe 100644 --- a/core/src/main/java/org/openedx/core/data/model/CourseEnrollments.kt +++ b/core/src/main/java/org/openedx/core/data/model/CourseEnrollments.kt @@ -7,6 +7,7 @@ import com.google.gson.JsonElement import com.google.gson.JsonObject import com.google.gson.annotations.SerializedName import java.lang.reflect.Type +import org.openedx.core.domain.model.CourseEnrollments as DomainCourseEnrollments data class CourseEnrollments( @SerializedName("enrollments") @@ -14,17 +15,38 @@ data class CourseEnrollments( @SerializedName("config") val configs: AppConfig, + + @SerializedName("primary") + val primary: EnrolledCourse?, ) { + fun mapToDomain() = DomainCourseEnrollments( + enrollments = enrollments.mapToDomain(), + configs = configs.mapToDomain(), + primary = primary?.mapToDomain() + ) + class Deserializer : JsonDeserializer { override fun deserialize( json: JsonElement?, typeOfT: Type?, - context: JsonDeserializationContext? + context: JsonDeserializationContext?, ): CourseEnrollments { val enrollments = deserializeEnrollments(json) val appConfig = deserializeAppConfig(json) + val primaryCourse = deserializePrimaryCourse(json) - return CourseEnrollments(enrollments, appConfig) + return CourseEnrollments(enrollments, appConfig, primaryCourse) + } + + private fun deserializePrimaryCourse(json: JsonElement?): EnrolledCourse? { + return try { + Gson().fromJson( + (json as JsonObject).get("primary"), + EnrolledCourse::class.java + ) + } catch (ex: Exception) { + null + } } private fun deserializeEnrollments(json: JsonElement?): DashboardCourseList { diff --git a/core/src/main/java/org/openedx/core/data/model/CourseInfoOverview.kt b/core/src/main/java/org/openedx/core/data/model/CourseInfoOverview.kt new file mode 100644 index 000000000..57faedd2a --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseInfoOverview.kt @@ -0,0 +1,44 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.utils.TimeUtils +import org.openedx.core.domain.model.CourseInfoOverview as DomainCourseInfoOverview + +data class CourseInfoOverview( + @SerializedName("name") + val name: String, + @SerializedName("number") + val number: String, + @SerializedName("org") + val org: String, + @SerializedName("start") + val start: String?, + @SerializedName("start_display") + val startDisplay: String, + @SerializedName("start_type") + val startType: String, + @SerializedName("end") + val end: String?, + @SerializedName("is_self_paced") + val isSelfPaced: Boolean, + @SerializedName("media") + var media: Media?, + @SerializedName("course_sharing_utm_parameters") + val courseSharingUtmParameters: CourseSharingUtmParameters, + @SerializedName("course_about") + val courseAbout: String, +) { + fun mapToDomain() = DomainCourseInfoOverview( + name = name, + number = number, + org = org, + start = TimeUtils.iso8601ToDate(start ?: ""), + startDisplay = startDisplay, + startType = startType, + end = TimeUtils.iso8601ToDate(end ?: ""), + isSelfPaced = isSelfPaced, + media = media?.mapToDomain(), + courseSharingUtmParameters = courseSharingUtmParameters.mapToDomain(), + courseAbout = courseAbout, + ) +} diff --git a/core/src/main/java/org/openedx/core/data/model/CourseStatus.kt b/core/src/main/java/org/openedx/core/data/model/CourseStatus.kt new file mode 100644 index 000000000..53cb028b4 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseStatus.kt @@ -0,0 +1,30 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.data.model.room.discovery.CourseStatusDb +import org.openedx.core.domain.model.CourseStatus + +data class CourseStatus( + @SerializedName("last_visited_module_id") + val lastVisitedModuleId: String?, + @SerializedName("last_visited_module_path") + val lastVisitedModulePath: List?, + @SerializedName("last_visited_block_id") + val lastVisitedBlockId: String?, + @SerializedName("last_visited_unit_display_name") + val lastVisitedUnitDisplayName: String?, +) { + fun mapToDomain() = CourseStatus( + lastVisitedModuleId = lastVisitedModuleId ?: "", + lastVisitedModulePath = lastVisitedModulePath ?: emptyList(), + lastVisitedBlockId = lastVisitedBlockId ?: "", + lastVisitedUnitDisplayName = lastVisitedUnitDisplayName ?: "" + ) + + fun mapToRoomEntity() = CourseStatusDb( + lastVisitedModuleId = lastVisitedModuleId ?: "", + lastVisitedModulePath = lastVisitedModulePath ?: emptyList(), + lastVisitedBlockId = lastVisitedBlockId ?: "", + lastVisitedUnitDisplayName = lastVisitedUnitDisplayName ?: "" + ) +} diff --git a/core/src/main/java/org/openedx/core/data/model/CourseStructureModel.kt b/core/src/main/java/org/openedx/core/data/model/CourseStructureModel.kt index 9f22a14a0..a21492dc7 100644 --- a/core/src/main/java/org/openedx/core/data/model/CourseStructureModel.kt +++ b/core/src/main/java/org/openedx/core/data/model/CourseStructureModel.kt @@ -4,6 +4,7 @@ import com.google.gson.annotations.SerializedName import org.openedx.core.data.model.room.BlockDb import org.openedx.core.data.model.room.CourseStructureEntity import org.openedx.core.data.model.room.MediaDb +import org.openedx.core.data.model.room.discovery.ProgressDb import org.openedx.core.domain.model.CourseStructure import org.openedx.core.utils.TimeUtils @@ -32,10 +33,16 @@ data class CourseStructureModel( var coursewareAccess: CoursewareAccess?, @SerializedName("media") var media: Media?, + @SerializedName("course_access_details") + val courseAccessDetails: CourseAccessDetails, @SerializedName("certificate") val certificate: Certificate?, + @SerializedName("enrollment_details") + val enrollmentDetails: EnrollmentDetails, @SerializedName("is_self_paced") - var isSelfPaced: Boolean? + var isSelfPaced: Boolean?, + @SerializedName("course_progress") + val progress: Progress?, ) { fun mapToDomain(): CourseStructure { return CourseStructure( @@ -54,7 +61,8 @@ data class CourseStructureModel( coursewareAccess = coursewareAccess?.mapToDomain(), media = media?.mapToDomain(), certificate = certificate?.mapToDomain(), - isSelfPaced = isSelfPaced ?: false + isSelfPaced = isSelfPaced ?: false, + progress = progress?.mapToDomain(), ) } @@ -73,7 +81,8 @@ data class CourseStructureModel( coursewareAccess = coursewareAccess?.mapToRoomEntity(), media = MediaDb.createFrom(media), certificate = certificate?.mapToRoomEntity(), - isSelfPaced = isSelfPaced ?: false + isSelfPaced = isSelfPaced ?: false, + progress = progress?.mapToRoomEntity() ?: ProgressDb.DEFAULT_PROGRESS, ) } } diff --git a/core/src/main/java/org/openedx/core/data/model/EnrolledCourse.kt b/core/src/main/java/org/openedx/core/data/model/EnrolledCourse.kt index 984794698..edf8bbce3 100644 --- a/core/src/main/java/org/openedx/core/data/model/EnrolledCourse.kt +++ b/core/src/main/java/org/openedx/core/data/model/EnrolledCourse.kt @@ -2,8 +2,10 @@ package org.openedx.core.data.model import com.google.gson.annotations.SerializedName import org.openedx.core.data.model.room.discovery.EnrolledCourseEntity +import org.openedx.core.data.model.room.discovery.ProgressDb import org.openedx.core.domain.model.EnrolledCourse import org.openedx.core.utils.TimeUtils +import org.openedx.core.domain.model.Progress as ProgressDomain data class EnrolledCourse( @SerializedName("audit_access_expires") @@ -17,7 +19,13 @@ data class EnrolledCourse( @SerializedName("course") val course: EnrolledCourseData?, @SerializedName("certificate") - val certificate: Certificate? + val certificate: Certificate?, + @SerializedName("course_progress") + val progress: Progress?, + @SerializedName("course_status") + val courseStatus: CourseStatus?, + @SerializedName("course_assignments") + val courseAssignments: CourseAssignments? ) { fun mapToDomain(): EnrolledCourse { return EnrolledCourse( @@ -26,7 +34,10 @@ data class EnrolledCourse( mode = mode ?: "", isActive = isActive ?: false, course = course?.mapToDomain()!!, - certificate = certificate?.mapToDomain() + certificate = certificate?.mapToDomain(), + progress = progress?.mapToDomain() ?: ProgressDomain.DEFAULT_PROGRESS, + courseStatus = courseStatus?.mapToDomain(), + courseAssignments = courseAssignments?.mapToDomain() ) } @@ -38,7 +49,10 @@ data class EnrolledCourse( mode = mode ?: "", isActive = isActive ?: false, course = course?.mapToRoomEntity()!!, - certificate = certificate?.mapToRoomEntity() + certificate = certificate?.mapToRoomEntity(), + progress = progress?.mapToRoomEntity() ?: ProgressDb.DEFAULT_PROGRESS, + courseStatus = courseStatus?.mapToRoomEntity(), + courseAssignments = courseAssignments?.mapToRoomEntity() ) } } diff --git a/core/src/main/java/org/openedx/core/data/model/EnrollmentDetails.kt b/core/src/main/java/org/openedx/core/data/model/EnrollmentDetails.kt new file mode 100644 index 000000000..668e97f07 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/EnrollmentDetails.kt @@ -0,0 +1,36 @@ +package org.openedx.core.data.model + +import android.os.Parcelable +import com.google.gson.annotations.SerializedName +import kotlinx.parcelize.Parcelize +import org.openedx.core.data.model.room.discovery.EnrollmentDetailsDB +import org.openedx.core.utils.TimeUtils + +import org.openedx.core.domain.model.EnrollmentDetails as DomainEnrollmentDetails + +data class EnrollmentDetails( + @SerializedName("created") + var created: String?, + @SerializedName("date") + val date: String?, + @SerializedName("mode") + val mode: String?, + @SerializedName("is_active") + val isActive: Boolean = false, + @SerializedName("upgrade_deadline") + val upgradeDeadline: String?, +) { + fun mapToDomain() = DomainEnrollmentDetails( + created = TimeUtils.iso8601ToDate(date ?: ""), + mode = mode, + isActive = isActive, + upgradeDeadline = TimeUtils.iso8601ToDate(upgradeDeadline ?: ""), + ) + + fun mapToRoomEntity() = EnrollmentDetailsDB( + created = created, + mode = mode, + isActive = isActive, + upgradeDeadline = upgradeDeadline, + ) +} diff --git a/core/src/main/java/org/openedx/core/data/model/EnrollmentStatus.kt b/core/src/main/java/org/openedx/core/data/model/EnrollmentStatus.kt new file mode 100644 index 000000000..dc73134ec --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/EnrollmentStatus.kt @@ -0,0 +1,19 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.domain.model.EnrollmentStatus + +data class EnrollmentStatus( + @SerializedName("course_id") + val courseId: String?, + @SerializedName("course_name") + val courseName: String?, + @SerializedName("recently_active") + val recentlyActive: Boolean? +) { + fun mapToDomain() = EnrollmentStatus( + courseId = courseId ?: "", + courseName = courseName ?: "", + recentlyActive = recentlyActive ?: false + ) +} diff --git a/core/src/main/java/org/openedx/core/data/model/OfflineDownload.kt b/core/src/main/java/org/openedx/core/data/model/OfflineDownload.kt new file mode 100644 index 000000000..40868fc7a --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/OfflineDownload.kt @@ -0,0 +1,26 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.data.model.room.OfflineDownloadDb +import org.openedx.core.domain.model.OfflineDownload + +data class OfflineDownload( + @SerializedName("file_url") + var fileUrl: String?, + @SerializedName("last_modified") + var lastModified: String?, + @SerializedName("file_size") + var fileSize: Long?, +) { + fun mapToDomain() = OfflineDownload( + fileUrl = fileUrl ?: "", + lastModified = lastModified, + fileSize = fileSize ?: 0 + ) + + fun mapToRoomEntity() = OfflineDownloadDb( + fileUrl = fileUrl ?: "", + lastModified = lastModified, + fileSize = fileSize ?: 0 + ) +} diff --git a/core/src/main/java/org/openedx/core/data/model/Progress.kt b/core/src/main/java/org/openedx/core/data/model/Progress.kt new file mode 100644 index 000000000..d4813c14c --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/Progress.kt @@ -0,0 +1,22 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.data.model.room.discovery.ProgressDb +import org.openedx.core.domain.model.Progress + +data class Progress( + @SerializedName("assignments_completed") + val assignmentsCompleted: Int?, + @SerializedName("total_assignments_count") + val totalAssignmentsCount: Int?, +) { + fun mapToDomain() = Progress( + assignmentsCompleted = assignmentsCompleted ?: 0, + totalAssignmentsCount = totalAssignmentsCount ?: 0 + ) + + fun mapToRoomEntity() = ProgressDb( + assignmentsCompleted = assignmentsCompleted ?: 0, + totalAssignmentsCount = totalAssignmentsCount ?: 0 + ) +} diff --git a/core/src/main/java/org/openedx/core/data/model/XBlockProgressBody.kt b/core/src/main/java/org/openedx/core/data/model/XBlockProgressBody.kt new file mode 100644 index 000000000..25251abfc --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/XBlockProgressBody.kt @@ -0,0 +1,8 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName + +data class XBlockProgressBody( + @SerializedName("body") + val body: String +) diff --git a/core/src/main/java/org/openedx/core/data/model/room/BlockDb.kt b/core/src/main/java/org/openedx/core/data/model/room/BlockDb.kt index b1e9a53cf..70ddfdf79 100644 --- a/core/src/main/java/org/openedx/core/data/model/room/BlockDb.kt +++ b/core/src/main/java/org/openedx/core/data/model/room/BlockDb.kt @@ -3,7 +3,18 @@ package org.openedx.core.data.model.room import androidx.room.ColumnInfo import androidx.room.Embedded import org.openedx.core.BlockType -import org.openedx.core.domain.model.* +import org.openedx.core.data.model.Block +import org.openedx.core.data.model.BlockCounts +import org.openedx.core.data.model.EncodedVideos +import org.openedx.core.data.model.StudentViewData +import org.openedx.core.data.model.VideoInfo +import org.openedx.core.utils.TimeUtils +import org.openedx.core.domain.model.AssignmentProgress as DomainAssignmentProgress +import org.openedx.core.domain.model.Block as DomainBlock +import org.openedx.core.domain.model.BlockCounts as DomainBlockCounts +import org.openedx.core.domain.model.EncodedVideos as DomainEncodedVideos +import org.openedx.core.domain.model.StudentViewData as DomainStudentViewData +import org.openedx.core.domain.model.VideoInfo as DomainVideoInfo data class BlockDb( @ColumnInfo("id") @@ -33,9 +44,15 @@ data class BlockDb( @ColumnInfo("completion") val completion: Double, @ColumnInfo("contains_gated_content") - val containsGatedContent: Boolean + val containsGatedContent: Boolean, + @Embedded + val assignmentProgress: AssignmentProgressDb?, + @ColumnInfo("due") + val due: String?, + @Embedded + val offlineDownload: OfflineDownloadDb?, ) { - fun mapToDomain(blocks: List): Block { + fun mapToDomain(blocks: List): DomainBlock { val blockType = BlockType.getBlockType(type) val descendantsType = if (blockType == BlockType.VERTICAL) { val types = descendants.map { descendant -> @@ -47,7 +64,7 @@ data class BlockDb( blockType } - return Block( + return DomainBlock( id = id, blockId = blockId, lmsWebUrl = lmsWebUrl, @@ -62,14 +79,17 @@ data class BlockDb( descendants = descendants, descendantsType = descendantsType, completion = completion, - containsGatedContent = containsGatedContent + containsGatedContent = containsGatedContent, + assignmentProgress = assignmentProgress?.mapToDomain(), + due = TimeUtils.iso8601ToDate(due ?: ""), + offlineDownload = offlineDownload?.mapToDomain() ) } companion object { fun createFrom( - block: org.openedx.core.data.model.Block + block: Block ): BlockDb { with(block) { return BlockDb( @@ -86,7 +106,10 @@ data class BlockDb( studentViewMultiDevice = studentViewMultiDevice ?: false, blockCounts = BlockCountsDb.createFrom(blockCounts), completion = completion ?: 0.0, - containsGatedContent = containsGatedContent ?: false + containsGatedContent = containsGatedContent ?: false, + assignmentProgress = assignmentProgress?.mapToRoomEntity(), + due = due, + offlineDownload = offlineDownload?.mapToRoomEntity() ) } } @@ -105,8 +128,8 @@ data class StudentViewDataDb( @Embedded val encodedVideos: EncodedVideosDb? ) { - fun mapToDomain(): StudentViewData { - return StudentViewData( + fun mapToDomain(): DomainStudentViewData { + return DomainStudentViewData( onlyOnWeb, duration, transcripts, @@ -117,7 +140,7 @@ data class StudentViewDataDb( companion object { - fun createFrom(studentViewData: org.openedx.core.data.model.StudentViewData?): StudentViewDataDb { + fun createFrom(studentViewData: StudentViewData?): StudentViewDataDb { return StudentViewDataDb( onlyOnWeb = studentViewData?.onlyOnWeb ?: false, duration = studentViewData?.duration.toString(), @@ -144,9 +167,9 @@ data class EncodedVideosDb( @ColumnInfo("mobileLow") var mobileLow: VideoInfoDb? ) { - fun mapToDomain(): EncodedVideos { - return EncodedVideos( - youtube?.mapToDomain(), + fun mapToDomain(): DomainEncodedVideos { + return DomainEncodedVideos( + youtube = youtube?.mapToDomain(), hls = hls?.mapToDomain(), fallback = fallback?.mapToDomain(), desktopMp4 = desktopMp4?.mapToDomain(), @@ -156,7 +179,7 @@ data class EncodedVideosDb( } companion object { - fun createFrom(encodedVideos: org.openedx.core.data.model.EncodedVideos?): EncodedVideosDb { + fun createFrom(encodedVideos: EncodedVideos?): EncodedVideosDb { return EncodedVideosDb( youtube = VideoInfoDb.createFrom(encodedVideos?.videoInfo), hls = VideoInfoDb.createFrom(encodedVideos?.hls), @@ -174,12 +197,12 @@ data class VideoInfoDb( @ColumnInfo("url") val url: String, @ColumnInfo("fileSize") - val fileSize: Int + val fileSize: Long ) { - fun mapToDomain() = VideoInfo(url, fileSize) + fun mapToDomain() = DomainVideoInfo(url, fileSize) companion object { - fun createFrom(videoInfo: org.openedx.core.data.model.VideoInfo?): VideoInfoDb? { + fun createFrom(videoInfo: VideoInfo?): VideoInfoDb? { if (videoInfo == null) return null return VideoInfoDb( videoInfo.url ?: "", @@ -193,11 +216,43 @@ data class BlockCountsDb( @ColumnInfo("video") val video: Int ) { - fun mapToDomain() = BlockCounts(video) + fun mapToDomain() = DomainBlockCounts(video) companion object { - fun createFrom(blocksCounts: org.openedx.core.data.model.BlockCounts?): BlockCountsDb { + fun createFrom(blocksCounts: BlockCounts?): BlockCountsDb { return BlockCountsDb(blocksCounts?.video ?: 0) } } } + +data class AssignmentProgressDb( + @ColumnInfo("assignment_type") + val assignmentType: String?, + @ColumnInfo("num_points_earned") + val numPointsEarned: Float?, + @ColumnInfo("num_points_possible") + val numPointsPossible: Float?, +) { + fun mapToDomain() = DomainAssignmentProgress( + assignmentType = assignmentType ?: "", + numPointsEarned = numPointsEarned ?: 0f, + numPointsPossible = numPointsPossible ?: 0f + ) +} + +data class OfflineDownloadDb( + @ColumnInfo("file_url") + var fileUrl: String?, + @ColumnInfo("last_modified") + var lastModified: String?, + @ColumnInfo("file_size") + var fileSize: Long?, +) { + fun mapToDomain(): org.openedx.core.domain.model.OfflineDownload { + return org.openedx.core.domain.model.OfflineDownload( + fileUrl = fileUrl ?: "", + lastModified = lastModified, + fileSize = fileSize ?: 0 + ) + } +} diff --git a/core/src/main/java/org/openedx/core/data/model/room/CourseCalendarEventEntity.kt b/core/src/main/java/org/openedx/core/data/model/room/CourseCalendarEventEntity.kt new file mode 100644 index 000000000..62f3c30b4 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/room/CourseCalendarEventEntity.kt @@ -0,0 +1,21 @@ +package org.openedx.core.data.model.room + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import org.openedx.core.domain.model.CourseCalendarEvent + +@Entity(tableName = "course_calendar_event_table") +data class CourseCalendarEventEntity( + @PrimaryKey + @ColumnInfo("event_id") + val eventId: Long, + @ColumnInfo("course_id") + val courseId: String +) { + + fun mapToDomain() = CourseCalendarEvent( + courseId = courseId, + eventId = eventId + ) +} diff --git a/core/src/main/java/org/openedx/core/data/model/room/CourseCalendarStateEntity.kt b/core/src/main/java/org/openedx/core/data/model/room/CourseCalendarStateEntity.kt new file mode 100644 index 000000000..e2c39991c --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/room/CourseCalendarStateEntity.kt @@ -0,0 +1,24 @@ +package org.openedx.core.data.model.room + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import org.openedx.core.domain.model.CourseCalendarState + +@Entity(tableName = "course_calendar_state_table") +data class CourseCalendarStateEntity( + @PrimaryKey + @ColumnInfo("course_id") + val courseId: String, + @ColumnInfo("checksum") + val checksum: Int = 0, + @ColumnInfo("is_course_sync_enabled") + val isCourseSyncEnabled: Boolean, +) { + + fun mapToDomain() = CourseCalendarState( + checksum = checksum, + courseId = courseId, + isCourseSyncEnabled = isCourseSyncEnabled + ) +} diff --git a/core/src/main/java/org/openedx/core/data/model/room/CourseStructureEntity.kt b/core/src/main/java/org/openedx/core/data/model/room/CourseStructureEntity.kt index 90352d821..49862d683 100644 --- a/core/src/main/java/org/openedx/core/data/model/room/CourseStructureEntity.kt +++ b/core/src/main/java/org/openedx/core/data/model/room/CourseStructureEntity.kt @@ -6,6 +6,7 @@ import androidx.room.Entity import androidx.room.PrimaryKey import org.openedx.core.data.model.room.discovery.CertificateDb import org.openedx.core.data.model.room.discovery.CoursewareAccessDb +import org.openedx.core.data.model.room.discovery.ProgressDb import org.openedx.core.domain.model.CourseStructure import org.openedx.core.utils.TimeUtils @@ -39,7 +40,9 @@ data class CourseStructureEntity( @Embedded val certificate: CertificateDb?, @ColumnInfo("isSelfPaced") - val isSelfPaced: Boolean + val isSelfPaced: Boolean, + @Embedded + val progress: ProgressDb, ) { fun mapToDomain(): CourseStructure { @@ -57,7 +60,8 @@ data class CourseStructureEntity( coursewareAccess?.mapToDomain(), media?.mapToDomain(), certificate?.mapToDomain(), - isSelfPaced + isSelfPaced, + progress.mapToDomain() ) } diff --git a/core/src/main/java/org/openedx/core/data/model/room/OfflineXBlockProgress.kt b/core/src/main/java/org/openedx/core/data/model/room/OfflineXBlockProgress.kt new file mode 100644 index 000000000..f78ef6524 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/room/OfflineXBlockProgress.kt @@ -0,0 +1,49 @@ +package org.openedx.core.data.model.room + +import androidx.room.ColumnInfo +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.PrimaryKey +import org.json.JSONObject + +@Entity(tableName = "offline_x_block_progress_table") +data class OfflineXBlockProgress( + @PrimaryKey + @ColumnInfo("id") + val blockId: String, + @ColumnInfo("courseId") + val courseId: String, + @Embedded + val jsonProgress: XBlockProgressData, +) + +data class XBlockProgressData( + @PrimaryKey + @ColumnInfo("url") + val url: String, + @ColumnInfo("type") + val type: String, + @ColumnInfo("data") + val data: String +) { + + fun toJson(): String { + val jsonObject = JSONObject() + jsonObject.put("url", url) + jsonObject.put("type", type) + jsonObject.put("data", data) + + return jsonObject.toString() + } + + companion object { + fun parseJson(jsonString: String): XBlockProgressData { + val jsonObject = JSONObject(jsonString) + val url = jsonObject.getString("url") + val type = jsonObject.getString("type") + val data = jsonObject.getString("data") + + return XBlockProgressData(url, type, data) + } + } +} diff --git a/core/src/main/java/org/openedx/core/data/model/room/discovery/EnrolledCourseEntity.kt b/core/src/main/java/org/openedx/core/data/model/room/discovery/EnrolledCourseEntity.kt index 05aab3bdd..59de42e53 100644 --- a/core/src/main/java/org/openedx/core/data/model/room/discovery/EnrolledCourseEntity.kt +++ b/core/src/main/java/org/openedx/core/data/model/room/discovery/EnrolledCourseEntity.kt @@ -4,9 +4,21 @@ import androidx.room.ColumnInfo import androidx.room.Embedded import androidx.room.Entity import androidx.room.PrimaryKey +import org.openedx.core.data.model.DateType import org.openedx.core.data.model.room.MediaDb -import org.openedx.core.domain.model.* +import org.openedx.core.domain.model.Certificate +import org.openedx.core.domain.model.CourseAccessDetails +import org.openedx.core.domain.model.CourseAssignments +import org.openedx.core.domain.model.CourseDateBlock +import org.openedx.core.domain.model.CourseSharingUtmParameters +import org.openedx.core.domain.model.CourseStatus +import org.openedx.core.domain.model.CoursewareAccess +import org.openedx.core.domain.model.EnrolledCourse +import org.openedx.core.domain.model.EnrolledCourseData +import org.openedx.core.domain.model.EnrollmentDetails +import org.openedx.core.domain.model.Progress import org.openedx.core.utils.TimeUtils +import java.util.Date @Entity(tableName = "course_enrolled_table") data class EnrolledCourseEntity( @@ -25,6 +37,12 @@ data class EnrolledCourseEntity( val course: EnrolledCourseDataDb, @Embedded val certificate: CertificateDb?, + @Embedded + val progress: ProgressDb, + @Embedded + val courseStatus: CourseStatusDb?, + @Embedded + val courseAssignments: CourseAssignmentsDb?, ) { fun mapToDomain(): EnrolledCourse { @@ -34,7 +52,10 @@ data class EnrolledCourseEntity( mode, isActive, course.mapToDomain(), - certificate?.mapToDomain() + certificate?.mapToDomain(), + progress.mapToDomain(), + courseStatus?.mapToDomain(), + courseAssignments?.mapToDomain() ) } } @@ -79,7 +100,7 @@ data class EnrolledCourseDataDb( @ColumnInfo("videoOutline") val videoOutline: String, @ColumnInfo("isSelfPaced") - val isSelfPaced: Boolean + val isSelfPaced: Boolean, ) { fun mapToDomain(): EnrolledCourseData { return EnrolledCourseData( @@ -119,7 +140,7 @@ data class CoursewareAccessDb( @ColumnInfo("additionalContextUserMessage") val additionalContextUserMessage: String, @ColumnInfo("userFragment") - val userFragment: String + val userFragment: String, ) { fun mapToDomain(): CoursewareAccess { @@ -137,7 +158,7 @@ data class CoursewareAccessDb( data class CertificateDb( @ColumnInfo("certificateURL") - val certificateURL: String? + val certificateURL: String?, ) { fun mapToDomain() = Certificate(certificateURL) } @@ -146,9 +167,123 @@ data class CourseSharingUtmParametersDb( @ColumnInfo("facebook") val facebook: String, @ColumnInfo("twitter") - val twitter: String + val twitter: String, ) { fun mapToDomain() = CourseSharingUtmParameters( facebook, twitter ) -} \ No newline at end of file +} + +data class ProgressDb( + @ColumnInfo("assignments_completed") + val assignmentsCompleted: Int, + @ColumnInfo("total_assignments_count") + val totalAssignmentsCount: Int, +) { + companion object { + val DEFAULT_PROGRESS = ProgressDb(0, 0) + } + + fun mapToDomain() = Progress(assignmentsCompleted, totalAssignmentsCount) +} + +data class CourseStatusDb( + @ColumnInfo("lastVisitedModuleId") + val lastVisitedModuleId: String, + @ColumnInfo("lastVisitedModulePath") + val lastVisitedModulePath: List, + @ColumnInfo("lastVisitedBlockId") + val lastVisitedBlockId: String, + @ColumnInfo("lastVisitedUnitDisplayName") + val lastVisitedUnitDisplayName: String, +) { + fun mapToDomain() = CourseStatus( + lastVisitedModuleId, lastVisitedModulePath, lastVisitedBlockId, lastVisitedUnitDisplayName + ) +} + +data class CourseAssignmentsDb( + @ColumnInfo("futureAssignments") + val futureAssignments: List?, + @ColumnInfo("pastAssignments") + val pastAssignments: List?, +) { + fun mapToDomain() = CourseAssignments( + futureAssignments = futureAssignments?.map { it.mapToDomain() }, + pastAssignments = pastAssignments?.map { it.mapToDomain() } + ) +} + +data class CourseDateBlockDb( + @ColumnInfo("title") + val title: String = "", + @ColumnInfo("description") + val description: String = "", + @ColumnInfo("link") + val link: String = "", + @ColumnInfo("blockId") + val blockId: String = "", + @ColumnInfo("learnerHasAccess") + val learnerHasAccess: Boolean = false, + @ColumnInfo("complete") + val complete: Boolean = false, + @Embedded + val date: Date, + @ColumnInfo("dateType") + val dateType: DateType = DateType.NONE, + @ColumnInfo("assignmentType") + val assignmentType: String? = "", +) { + fun mapToDomain() = CourseDateBlock( + title = title, + description = description, + link = link, + blockId = blockId, + learnerHasAccess = learnerHasAccess, + complete = complete, + date = date, + dateType = dateType, + assignmentType = assignmentType + ) +} + +data class EnrollmentDetailsDB( + @ColumnInfo("created") + var created: String?, + @ColumnInfo("mode") + var mode: String?, + @ColumnInfo("isActive") + var isActive: Boolean, + @ColumnInfo("upgradeDeadline") + var upgradeDeadline: String?, +) { + fun mapToDomain() = EnrollmentDetails( + TimeUtils.iso8601ToDate(created ?: ""), + mode, + isActive, + TimeUtils.iso8601ToDate(upgradeDeadline ?: "") + ) +} + +data class CourseAccessDetailsDb( + @ColumnInfo("hasUnmetPrerequisites") + val hasUnmetPrerequisites: Boolean, + @ColumnInfo("isTooEarly") + val isTooEarly: Boolean, + @ColumnInfo("isStaff") + val isStaff: Boolean, + @ColumnInfo("auditAccessExpires") + var auditAccessExpires: String?, + @Embedded + val coursewareAccess: CoursewareAccessDb?, +) { + fun mapToDomain(): CourseAccessDetails { + return CourseAccessDetails( + hasUnmetPrerequisites = hasUnmetPrerequisites, + isTooEarly = isTooEarly, + isStaff = isStaff, + auditAccessExpires = TimeUtils.iso8601ToDate(auditAccessExpires ?: ""), + coursewareAccess = coursewareAccess?.mapToDomain() + ) + } +} diff --git a/core/src/main/java/org/openedx/core/data/storage/CalendarPreferences.kt b/core/src/main/java/org/openedx/core/data/storage/CalendarPreferences.kt new file mode 100644 index 000000000..91e38b35c --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/storage/CalendarPreferences.kt @@ -0,0 +1,10 @@ +package org.openedx.core.data.storage + +interface CalendarPreferences { + var calendarId: Long + var calendarUser: String + var isCalendarSyncEnabled: Boolean + var isHideInactiveCourses: Boolean + + fun clearCalendarPreferences() +} diff --git a/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt b/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt index 48999ab4e..5435494ba 100644 --- a/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt +++ b/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt @@ -7,10 +7,13 @@ import org.openedx.core.domain.model.VideoSettings interface CorePreferences { var accessToken: String var refreshToken: String + var pushToken: String var accessTokenExpiresAt: Long var user: User? var videoSettings: VideoSettings var appConfig: AppConfig + var canResetAppDirectory: Boolean + var isRelativeDatesEnabled: Boolean - fun clear() + fun clearCorePreferences() } diff --git a/core/src/main/java/org/openedx/core/domain/interactor/CalendarInteractor.kt b/core/src/main/java/org/openedx/core/domain/interactor/CalendarInteractor.kt new file mode 100644 index 000000000..da84dba1a --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/interactor/CalendarInteractor.kt @@ -0,0 +1,60 @@ +package org.openedx.core.domain.interactor + +import org.openedx.core.data.model.room.CourseCalendarEventEntity +import org.openedx.core.data.model.room.CourseCalendarStateEntity +import org.openedx.core.domain.model.CourseCalendarEvent +import org.openedx.core.domain.model.CourseCalendarState +import org.openedx.core.repository.CalendarRepository + +class CalendarInteractor( + private val repository: CalendarRepository +) { + + suspend fun getEnrollmentsStatus() = repository.getEnrollmentsStatus() + + suspend fun getCourseDates(courseId: String) = repository.getCourseDates(courseId) + + suspend fun insertCourseCalendarEntityToCache(vararg courseCalendarEntity: CourseCalendarEventEntity) { + repository.insertCourseCalendarEntityToCache(*courseCalendarEntity) + } + + suspend fun getCourseCalendarEventsByIdFromCache(courseId: String): List { + return repository.getCourseCalendarEventsByIdFromCache(courseId) + } + + suspend fun deleteCourseCalendarEntitiesByIdFromCache(courseId: String) { + repository.deleteCourseCalendarEntitiesByIdFromCache(courseId) + } + + suspend fun insertCourseCalendarStateEntityToCache(vararg courseCalendarStateEntity: CourseCalendarStateEntity) { + repository.insertCourseCalendarStateEntityToCache(*courseCalendarStateEntity) + } + + suspend fun getCourseCalendarStateByIdFromCache(courseId: String): CourseCalendarState? { + return repository.getCourseCalendarStateByIdFromCache(courseId) + } + + suspend fun getAllCourseCalendarStateFromCache(): List { + return repository.getAllCourseCalendarStateFromCache() + } + + suspend fun clearCalendarCachedData() { + repository.clearCalendarCachedData() + } + + suspend fun resetChecksums() { + repository.resetChecksums() + } + + suspend fun updateCourseCalendarStateByIdInCache( + courseId: String, + checksum: Int? = null, + isCourseSyncEnabled: Boolean? = null + ) { + repository.updateCourseCalendarStateByIdInCache(courseId, checksum, isCourseSyncEnabled) + } + + suspend fun deleteCourseCalendarStateByIdFromCache(courseId: String) { + repository.deleteCourseCalendarStateByIdFromCache(courseId) + } +} diff --git a/core/src/main/java/org/openedx/core/domain/model/AppConfig.kt b/core/src/main/java/org/openedx/core/domain/model/AppConfig.kt index 596fd0619..97750957f 100644 --- a/core/src/main/java/org/openedx/core/domain/model/AppConfig.kt +++ b/core/src/main/java/org/openedx/core/domain/model/AppConfig.kt @@ -3,12 +3,12 @@ package org.openedx.core.domain.model import java.io.Serializable data class AppConfig( - val courseDatesCalendarSync: CourseDatesCalendarSync, + val courseDatesCalendarSync: CourseDatesCalendarSync = CourseDatesCalendarSync(), ) : Serializable data class CourseDatesCalendarSync( - val isEnabled: Boolean, - val isSelfPacedEnabled: Boolean, - val isInstructorPacedEnabled: Boolean, - val isDeepLinkEnabled: Boolean, + val isEnabled: Boolean = false, + val isSelfPacedEnabled: Boolean = false, + val isInstructorPacedEnabled: Boolean = false, + val isDeepLinkEnabled: Boolean = false, ) : Serializable diff --git a/core/src/main/java/org/openedx/core/domain/model/AssignmentProgress.kt b/core/src/main/java/org/openedx/core/domain/model/AssignmentProgress.kt new file mode 100644 index 000000000..730bfbfba --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/AssignmentProgress.kt @@ -0,0 +1,11 @@ +package org.openedx.core.domain.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class AssignmentProgress( + val assignmentType: String, + val numPointsEarned: Float, + val numPointsPossible: Float +) : Parcelable diff --git a/core/src/main/java/org/openedx/core/domain/model/Block.kt b/core/src/main/java/org/openedx/core/domain/model/Block.kt index 2f1766ecb..3ebf8c8b6 100644 --- a/core/src/main/java/org/openedx/core/domain/model/Block.kt +++ b/core/src/main/java/org/openedx/core/domain/model/Block.kt @@ -1,14 +1,18 @@ package org.openedx.core.domain.model +import android.os.Parcelable import android.webkit.URLUtil +import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.RawValue import org.openedx.core.AppDataConstants import org.openedx.core.BlockType import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.db.FileType import org.openedx.core.utils.VideoUtil +import java.util.Date - +@Parcelize data class Block( val id: String, val blockId: String, @@ -25,22 +29,26 @@ data class Block( val descendantsType: BlockType, val completion: Double, val containsGatedContent: Boolean = false, - val downloadModel: DownloadModel? = null -) { + val downloadModel: DownloadModel? = null, + val assignmentProgress: AssignmentProgress?, + val due: Date?, + val offlineDownload: OfflineDownload? +) : Parcelable { val isDownloadable: Boolean get() { - return studentViewData != null && studentViewData.encodedVideos?.hasDownloadableVideo == true + return (studentViewData != null && studentViewData.encodedVideos?.hasDownloadableVideo == true) || isxBlock } - val downloadableType: FileType - get() = when (type) { - BlockType.VIDEO -> { - FileType.VIDEO - } + val isxBlock: Boolean + get() = !offlineDownload?.fileUrl.isNullOrEmpty() - else -> { - FileType.UNKNOWN - } + val downloadableType: FileType? + get() = if (type == BlockType.VIDEO) { + FileType.VIDEO + } else if (isxBlock) { + FileType.X_BLOCK + } else { + null } fun isDownloading(): Boolean { @@ -86,14 +94,16 @@ data class Block( val isSurveyBlock get() = type == BlockType.SURVEY } +@Parcelize data class StudentViewData( val onlyOnWeb: Boolean, - val duration: Any, + val duration: @RawValue Any, val transcripts: HashMap?, val encodedVideos: EncodedVideos?, val topicId: String, -) +) : Parcelable +@Parcelize data class EncodedVideos( val youtube: VideoInfo?, var hls: VideoInfo?, @@ -101,7 +111,7 @@ data class EncodedVideos( var desktopMp4: VideoInfo?, var mobileHigh: VideoInfo?, var mobileLow: VideoInfo?, -) { +) : Parcelable { val hasDownloadableVideo: Boolean get() = isPreferredVideoInfo(hls) || isPreferredVideoInfo(fallback) || @@ -181,11 +191,20 @@ data class EncodedVideos( } +@Parcelize data class VideoInfo( val url: String, - val fileSize: Int, -) + val fileSize: Long, +) : Parcelable +@Parcelize data class BlockCounts( val video: Int, -) +) : Parcelable + +@Parcelize +data class OfflineDownload( + var fileUrl: String, + var lastModified: String?, + var fileSize: Long, +) : Parcelable diff --git a/core/src/main/java/org/openedx/core/domain/model/CalendarData.kt b/core/src/main/java/org/openedx/core/domain/model/CalendarData.kt new file mode 100644 index 000000000..849d2f303 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CalendarData.kt @@ -0,0 +1,10 @@ +package org.openedx.core.domain.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class CalendarData( + val title: String, + val color: Int +) : Parcelable diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseAccessDetails.kt b/core/src/main/java/org/openedx/core/domain/model/CourseAccessDetails.kt new file mode 100644 index 000000000..fac674e66 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseAccessDetails.kt @@ -0,0 +1,14 @@ +package org.openedx.core.domain.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import java.util.Date + +@Parcelize +data class CourseAccessDetails( + val hasUnmetPrerequisites: Boolean, + val isTooEarly: Boolean, + val isStaff: Boolean, + val auditAccessExpires: Date?, + val coursewareAccess: CoursewareAccess?, +) : Parcelable diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseAssignments.kt b/core/src/main/java/org/openedx/core/domain/model/CourseAssignments.kt new file mode 100644 index 000000000..feb039fc7 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseAssignments.kt @@ -0,0 +1,10 @@ +package org.openedx.core.domain.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class CourseAssignments( + val futureAssignments: List?, + val pastAssignments: List? +): Parcelable diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseCalendarEvent.kt b/core/src/main/java/org/openedx/core/domain/model/CourseCalendarEvent.kt new file mode 100644 index 000000000..bdf676c7f --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseCalendarEvent.kt @@ -0,0 +1,6 @@ +package org.openedx.core.domain.model + +data class CourseCalendarEvent( + val courseId: String, + val eventId: Long, +) diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseCalendarState.kt b/core/src/main/java/org/openedx/core/domain/model/CourseCalendarState.kt new file mode 100644 index 000000000..fefad4d82 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseCalendarState.kt @@ -0,0 +1,7 @@ +package org.openedx.core.domain.model + +data class CourseCalendarState( + val checksum: Int, + val courseId: String, + val isCourseSyncEnabled: Boolean +) diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseDateBlock.kt b/core/src/main/java/org/openedx/core/domain/model/CourseDateBlock.kt index 7e91c59fa..9249d6a23 100644 --- a/core/src/main/java/org/openedx/core/domain/model/CourseDateBlock.kt +++ b/core/src/main/java/org/openedx/core/domain/model/CourseDateBlock.kt @@ -1,10 +1,11 @@ package org.openedx.core.domain.model +import android.os.Parcelable +import kotlinx.parcelize.Parcelize import org.openedx.core.data.model.DateType -import org.openedx.core.utils.isTimeLessThan24Hours -import org.openedx.core.utils.isToday import java.util.Date +@Parcelize data class CourseDateBlock( val title: String = "", val description: String = "", @@ -15,7 +16,7 @@ data class CourseDateBlock( val date: Date, val dateType: DateType = DateType.NONE, val assignmentType: String? = "", -) { +) : Parcelable { fun isCompleted(): Boolean { return complete || (dateType in setOf( DateType.COURSE_START_DATE, @@ -26,7 +27,23 @@ data class CourseDateBlock( ) && date.before(Date())) } - fun isTimeDifferenceLessThan24Hours(): Boolean { - return (date.isToday() && date.before(Date())) || date.isTimeLessThan24Hours() + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as CourseDateBlock + + if (blockId != other.blockId) return false + if (date != other.date) return false + if (assignmentType != other.assignmentType) return false + + return true + } + + override fun hashCode(): Int { + var result = blockId.hashCode() + result = 31 * result + date.hashCode() + result = 31 * result + (assignmentType?.hashCode() ?: 0) + return result } } diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseEnrollmentDetails.kt b/core/src/main/java/org/openedx/core/domain/model/CourseEnrollmentDetails.kt new file mode 100644 index 000000000..5c61fee60 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseEnrollmentDetails.kt @@ -0,0 +1,30 @@ +package org.openedx.core.domain.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import org.openedx.core.extension.isNotNull +import java.util.Date + +@Parcelize +data class CourseEnrollmentDetails( + val id: String, + val courseUpdates: String, + val courseHandouts: String, + val discussionUrl: String, + val courseAccessDetails: CourseAccessDetails, + val certificate: Certificate?, + val enrollmentDetails: EnrollmentDetails, + val courseInfoOverview: CourseInfoOverview, +) : Parcelable { + + val hasAccess: Boolean + get() = courseAccessDetails.coursewareAccess?.hasAccess ?: false + + val isAuditAccessExpired: Boolean + get() = courseAccessDetails.auditAccessExpires.isNotNull() && + Date().after(courseAccessDetails.auditAccessExpires) +} + +enum class CourseAccessError { + NONE, AUDIT_EXPIRED_NOT_UPGRADABLE, NOT_YET_STARTED, UNKNOWN +} diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseEnrollments.kt b/core/src/main/java/org/openedx/core/domain/model/CourseEnrollments.kt new file mode 100644 index 000000000..6606902c2 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseEnrollments.kt @@ -0,0 +1,7 @@ +package org.openedx.core.domain.model + +data class CourseEnrollments( + val enrollments: DashboardCourseList, + val configs: AppConfig, + val primary: EnrolledCourse?, +) diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseInfoOverview.kt b/core/src/main/java/org/openedx/core/domain/model/CourseInfoOverview.kt new file mode 100644 index 000000000..4d02f10b9 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseInfoOverview.kt @@ -0,0 +1,23 @@ +package org.openedx.core.domain.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import java.util.Date + +@Parcelize +data class CourseInfoOverview( + val name: String, + val number: String, + val org: String, + val start: Date?, + val startDisplay: String, + val startType: String, + val end: Date?, + val isSelfPaced: Boolean, + var media: Media?, + val courseSharingUtmParameters: CourseSharingUtmParameters, + val courseAbout: String, +) : Parcelable { + val isStarted: Boolean + get() = start?.before(Date()) ?: false +} diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseStatus.kt b/core/src/main/java/org/openedx/core/domain/model/CourseStatus.kt new file mode 100644 index 000000000..aef245f67 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseStatus.kt @@ -0,0 +1,12 @@ +package org.openedx.core.domain.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class CourseStatus( + val lastVisitedModuleId: String, + val lastVisitedModulePath: List, + val lastVisitedBlockId: String, + val lastVisitedUnitDisplayName: String, +) : Parcelable diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseStructure.kt b/core/src/main/java/org/openedx/core/domain/model/CourseStructure.kt index bdb3820de..4ba3a8419 100644 --- a/core/src/main/java/org/openedx/core/domain/model/CourseStructure.kt +++ b/core/src/main/java/org/openedx/core/domain/model/CourseStructure.kt @@ -16,5 +16,6 @@ data class CourseStructure( val coursewareAccess: CoursewareAccess?, val media: Media?, val certificate: Certificate?, - val isSelfPaced: Boolean + val isSelfPaced: Boolean, + val progress: Progress?, ) diff --git a/core/src/main/java/org/openedx/core/domain/model/EnrolledCourse.kt b/core/src/main/java/org/openedx/core/domain/model/EnrolledCourse.kt index 8e339b3f6..184fc3aa4 100644 --- a/core/src/main/java/org/openedx/core/domain/model/EnrolledCourse.kt +++ b/core/src/main/java/org/openedx/core/domain/model/EnrolledCourse.kt @@ -12,4 +12,7 @@ data class EnrolledCourse( val isActive: Boolean, val course: EnrolledCourseData, val certificate: Certificate?, + val progress: Progress, + val courseStatus: CourseStatus?, + val courseAssignments: CourseAssignments? ) : Parcelable diff --git a/core/src/main/java/org/openedx/core/domain/model/EnrollmentDetails.kt b/core/src/main/java/org/openedx/core/domain/model/EnrollmentDetails.kt new file mode 100644 index 000000000..01882167b --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/EnrollmentDetails.kt @@ -0,0 +1,17 @@ +package org.openedx.core.domain.model + +import android.os.Parcelable +import androidx.room.ColumnInfo +import kotlinx.parcelize.Parcelize +import org.openedx.core.data.model.EnrollmentDetails +import org.openedx.core.extension.isNotNull +import java.util.Date + +@Parcelize +data class EnrollmentDetails( + val created: Date?, + val mode: String?, + val isActive: Boolean, + val upgradeDeadline: Date?, +) : Parcelable + diff --git a/core/src/main/java/org/openedx/core/domain/model/EnrollmentStatus.kt b/core/src/main/java/org/openedx/core/domain/model/EnrollmentStatus.kt new file mode 100644 index 000000000..4039975e3 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/EnrollmentStatus.kt @@ -0,0 +1,7 @@ +package org.openedx.core.domain.model + +data class EnrollmentStatus( + val courseId: String, + val courseName: String, + val recentlyActive: Boolean +) diff --git a/core/src/main/java/org/openedx/core/domain/model/Progress.kt b/core/src/main/java/org/openedx/core/domain/model/Progress.kt new file mode 100644 index 000000000..800a9c292 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/Progress.kt @@ -0,0 +1,23 @@ +package org.openedx.core.domain.model + +import android.os.Parcelable +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize + +@Parcelize +data class Progress( + val assignmentsCompleted: Int, + val totalAssignmentsCount: Int, +) : Parcelable { + + @IgnoredOnParcel + val value: Float = try { + assignmentsCompleted.toFloat() / totalAssignmentsCount.toFloat() + } catch (_: ArithmeticException) { + 0f + } + + companion object { + val DEFAULT_PROGRESS = Progress(0, 0) + } +} diff --git a/core/src/main/java/org/openedx/core/extension/AssetExt.kt b/core/src/main/java/org/openedx/core/extension/AssetExt.kt deleted file mode 100644 index 190f68721..000000000 --- a/core/src/main/java/org/openedx/core/extension/AssetExt.kt +++ /dev/null @@ -1,15 +0,0 @@ -package org.openedx.core.extension - -import android.content.res.AssetManager -import android.util.Log -import java.io.BufferedReader - -fun AssetManager.readAsText(fileName: String): String? { - return try { - open(fileName).bufferedReader().use(BufferedReader::readText) - } catch (e: Exception) { - Log.e("AssetExt", "Unable to load file $fileName from assets") - e.printStackTrace() - null - } -} diff --git a/core/src/main/java/org/openedx/core/extension/BooleanExt.kt b/core/src/main/java/org/openedx/core/extension/BooleanExt.kt new file mode 100644 index 000000000..4e9f69a0c --- /dev/null +++ b/core/src/main/java/org/openedx/core/extension/BooleanExt.kt @@ -0,0 +1,9 @@ +package org.openedx.core.extension + +fun Boolean?.isTrue(): Boolean { + return this == true +} + +fun Boolean?.isFalse(): Boolean { + return this == false +} diff --git a/core/src/main/java/org/openedx/core/extension/BundleExt.kt b/core/src/main/java/org/openedx/core/extension/BundleExt.kt deleted file mode 100644 index 59c0b9f93..000000000 --- a/core/src/main/java/org/openedx/core/extension/BundleExt.kt +++ /dev/null @@ -1,34 +0,0 @@ -@file:Suppress("NOTHING_TO_INLINE") - -package org.openedx.core.extension - -import android.os.Build.VERSION.SDK_INT -import android.os.Bundle -import android.os.Parcelable -import com.google.gson.Gson -import java.io.Serializable - -inline fun Bundle.parcelable(key: String): T? = when { - SDK_INT >= 33 -> getParcelable(key, T::class.java) - else -> @Suppress("DEPRECATION") getParcelable(key) as? T -} - -inline fun Bundle.serializable(key: String): T? = when { - SDK_INT >= 33 -> getSerializable(key, T::class.java) - else -> @Suppress("DEPRECATION") getSerializable(key) as? T -} - -inline fun Bundle.parcelableArrayList(key: String): ArrayList? = when { - SDK_INT >= 33 -> getParcelableArrayList(key, T::class.java) - else -> @Suppress("DEPRECATION") getParcelableArrayList(key) -} - -inline fun objectToString(value: T): String = Gson().toJson(value) - -inline fun stringToObject(value: String): T? { - return try { - Gson().fromJson(value, genericType()) - } catch (e: Exception) { - null - } -} diff --git a/core/src/main/java/org/openedx/core/extension/ContinuationExt.kt b/core/src/main/java/org/openedx/core/extension/ContinuationExt.kt deleted file mode 100644 index 8de4ec05b..000000000 --- a/core/src/main/java/org/openedx/core/extension/ContinuationExt.kt +++ /dev/null @@ -1,12 +0,0 @@ -package org.openedx.core.extension - -import kotlinx.coroutines.CancellableContinuation -import kotlin.coroutines.resume - -inline fun CancellableContinuation.safeResume(value: T, onExceptionCalled: () -> Unit) { - if (isActive) { - resume(value) - } else { - onExceptionCalled() - } -} diff --git a/core/src/main/java/org/openedx/core/extension/FlowExtension.kt b/core/src/main/java/org/openedx/core/extension/FlowExtension.kt deleted file mode 100644 index e88aff9ac..000000000 --- a/core/src/main/java/org/openedx/core/extension/FlowExtension.kt +++ /dev/null @@ -1,18 +0,0 @@ -package org.openedx.core.extension - -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.launch - -fun Flow.doWorkWhenStarted(lifecycleOwner: LifecycleOwner, doWork: (it: T) -> Unit) { - lifecycleOwner.lifecycleScope.launch { - lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - this@doWorkWhenStarted.collect { - doWork(it) - } - } - } -} \ No newline at end of file diff --git a/core/src/main/java/org/openedx/core/extension/FragmentExt.kt b/core/src/main/java/org/openedx/core/extension/FragmentExt.kt deleted file mode 100644 index 5d340b55d..000000000 --- a/core/src/main/java/org/openedx/core/extension/FragmentExt.kt +++ /dev/null @@ -1,28 +0,0 @@ -package org.openedx.core.extension - -import androidx.fragment.app.Fragment -import androidx.window.layout.WindowMetricsCalculator -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType - -fun Fragment.computeWindowSizeClasses(): WindowSize { - val metrics = WindowMetricsCalculator.getOrCreate() - .computeCurrentWindowMetrics(requireActivity()) - - val widthDp = metrics.bounds.width() / - resources.displayMetrics.density - val widthWindowSize = when { - widthDp < 600f -> WindowType.Compact - widthDp < 840f -> WindowType.Medium - else -> WindowType.Expanded - } - - val heightDp = metrics.bounds.height() / - resources.displayMetrics.density - val heightWindowSize = when { - heightDp < 480f -> WindowType.Compact - heightDp < 900f -> WindowType.Medium - else -> WindowType.Expanded - } - return WindowSize(widthWindowSize, heightWindowSize) -} \ No newline at end of file diff --git a/core/src/main/java/org/openedx/core/extension/GsonExt.kt b/core/src/main/java/org/openedx/core/extension/GsonExt.kt deleted file mode 100644 index 579a5ee6d..000000000 --- a/core/src/main/java/org/openedx/core/extension/GsonExt.kt +++ /dev/null @@ -1,9 +0,0 @@ -package org.openedx.core.extension - -import com.google.gson.Gson -import com.google.gson.reflect.TypeToken - -inline fun genericType() = object: TypeToken() {}.type - -inline fun Gson.fromJson(json: String) = fromJson(json, object: TypeToken() {}.type) - diff --git a/core/src/main/java/org/openedx/core/extension/ImageUploaderExtension.kt b/core/src/main/java/org/openedx/core/extension/ImageUploaderExtension.kt deleted file mode 100644 index a716544ca..000000000 --- a/core/src/main/java/org/openedx/core/extension/ImageUploaderExtension.kt +++ /dev/null @@ -1,17 +0,0 @@ -package org.openedx.core.extension - -import android.content.ContentResolver -import android.net.Uri -import android.provider.OpenableColumns - -fun ContentResolver.getFileName(fileUri: Uri): String { - var name = "" - val returnCursor = this.query(fileUri, null, null, null, null) - if (returnCursor != null) { - val nameIndex = returnCursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) - returnCursor.moveToFirst() - name = returnCursor.getString(nameIndex) - returnCursor.close() - } - return name -} \ No newline at end of file diff --git a/core/src/main/java/org/openedx/core/extension/IntExt.kt b/core/src/main/java/org/openedx/core/extension/IntExt.kt deleted file mode 100644 index 5739007f5..000000000 --- a/core/src/main/java/org/openedx/core/extension/IntExt.kt +++ /dev/null @@ -1,5 +0,0 @@ -package org.openedx.core.extension - -fun Int.nonZero(): Int? { - return if (this != 0) this else null -} diff --git a/core/src/main/java/org/openedx/core/extension/ListExt.kt b/core/src/main/java/org/openedx/core/extension/ListExt.kt index 1c2a242f7..6d97816ae 100644 --- a/core/src/main/java/org/openedx/core/extension/ListExt.kt +++ b/core/src/main/java/org/openedx/core/extension/ListExt.kt @@ -3,30 +3,6 @@ package org.openedx.core.extension import org.openedx.core.BlockType import org.openedx.core.domain.model.Block -inline fun List.indexOfFirstFromIndex(startIndex: Int, predicate: (T) -> Boolean): Int { - var index = 0 - for ((i, item) in this.withIndex()) { - if (i > startIndex) { - if (predicate(item)) - return index - } - index++ - } - return -1 -} - -fun ArrayList.clearAndAddAll(collection: Collection): ArrayList { - this.clear() - this.addAll(collection) - return this -} - -fun MutableList.clearAndAddAll(collection: Collection): MutableList { - this.clear() - this.addAll(collection) - return this -} - fun List.getVerticalBlocks(): List { return this.filter { it.type == BlockType.VERTICAL } } @@ -34,9 +10,3 @@ fun List.getVerticalBlocks(): List { fun List.getSequentialBlocks(): List { return this.filter { it.type == BlockType.SEQUENTIAL } } - -fun List?.isNotEmptyThenLet(block: (List) -> Unit) { - if (!isNullOrEmpty()) { - block(this) - } -} diff --git a/core/src/main/java/org/openedx/core/extension/LongExt.kt b/core/src/main/java/org/openedx/core/extension/LongExt.kt deleted file mode 100644 index 06f052616..000000000 --- a/core/src/main/java/org/openedx/core/extension/LongExt.kt +++ /dev/null @@ -1,18 +0,0 @@ -package org.openedx.core.extension - -import kotlin.math.log10 -import kotlin.math.pow - -fun Long.toFileSize(round: Int = 2): String { - try { - if (this <= 0) return "0" - val units = arrayOf("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB") - val digitGroups = (log10(this.toDouble()) / log10(1024.0)).toInt() - return String.format( - "%." + round + "f", this / 1024.0.pow(digitGroups.toDouble()) - ) + " " + units[digitGroups] - } catch (e: Exception) { - println(e.toString()) - } - return "" -} diff --git a/core/src/main/java/org/openedx/core/extension/MapExt.kt b/core/src/main/java/org/openedx/core/extension/MapExt.kt deleted file mode 100644 index f985d119d..000000000 --- a/core/src/main/java/org/openedx/core/extension/MapExt.kt +++ /dev/null @@ -1,13 +0,0 @@ -package org.openedx.core.extension - -import android.os.Bundle - -fun Map.toBundle(): Bundle { - val bundle = Bundle() - for ((key, value) in this.entries) { - value?.let { - bundle.putString(key, it.toString()) - } - } - return bundle -} diff --git a/core/src/main/java/org/openedx/core/extension/ObjectExt.kt b/core/src/main/java/org/openedx/core/extension/ObjectExt.kt new file mode 100644 index 000000000..c7a6c4db5 --- /dev/null +++ b/core/src/main/java/org/openedx/core/extension/ObjectExt.kt @@ -0,0 +1,9 @@ +package org.openedx.core.extension + +fun T?.isNotNull(): Boolean { + return this != null +} + +fun T?.isNull(): Boolean { + return this == null +} diff --git a/core/src/main/java/org/openedx/core/extension/StringExt.kt b/core/src/main/java/org/openedx/core/extension/StringExt.kt index 343398782..301e9deb9 100644 --- a/core/src/main/java/org/openedx/core/extension/StringExt.kt +++ b/core/src/main/java/org/openedx/core/extension/StringExt.kt @@ -1,39 +1,11 @@ package org.openedx.core.extension -import android.util.Patterns -import java.util.Locale -import java.util.regex.Pattern +import java.net.URL - -fun String.isEmailValid(): Boolean { - val regex = - "^[\\w!#$%&'*+/=?`{|}~^-]+(?:\\.[\\w!#$%&'*+/=?`{|}~^-]+)*@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,6}$" - return Pattern.compile(regex).matcher(this).matches() -} - -fun String.isLinkValid() = Patterns.WEB_URL.matcher(this).matches() - -fun String.replaceLinkTags(isDarkTheme: Boolean): String { - val linkColor = if (isDarkTheme) "879FF5" else "0000EE" - var text = ("" - + "" - + "" + this) + "" - var str: String - while (text.indexOf("\u0082") > 0) { - if (text.indexOf("\u0082") > 0 && text.indexOf("\u0083") > 0) { - str = text.substring(text.indexOf("\u0082") + 1, text.indexOf("\u0083")) - text = text.replace(("\u0082" + str + "\u0083").toRegex(), "$str") - } +fun String?.equalsHost(host: String?): Boolean { + return try { + host?.startsWith(URL(this).host, ignoreCase = true) == true + } catch (e: Exception) { + false } - return text -} - -fun String.replaceSpace(target: String = ""): String = this.replace(" ", target) - -fun String.tagId(): String = this.replaceSpace("_").lowercase(Locale.getDefault()) - -fun String.takeIfNotEmpty(): String? { - return if (this.isEmpty().not()) this else null } diff --git a/core/src/main/java/org/openedx/core/extension/ThrowableExt.kt b/core/src/main/java/org/openedx/core/extension/ThrowableExt.kt deleted file mode 100644 index 511da670a..000000000 --- a/core/src/main/java/org/openedx/core/extension/ThrowableExt.kt +++ /dev/null @@ -1,8 +0,0 @@ -package org.openedx.core.extension - -import java.net.SocketTimeoutException -import java.net.UnknownHostException - -fun Throwable.isInternetError(): Boolean { - return this is SocketTimeoutException || this is UnknownHostException -} \ No newline at end of file diff --git a/core/src/main/java/org/openedx/core/extension/UriExt.kt b/core/src/main/java/org/openedx/core/extension/UriExt.kt deleted file mode 100644 index cfa1b44d5..000000000 --- a/core/src/main/java/org/openedx/core/extension/UriExt.kt +++ /dev/null @@ -1,15 +0,0 @@ -package org.openedx.core.extension - -import android.net.Uri - -fun Uri.getQueryParams(): Map { - val paramsMap = mutableMapOf() - - queryParameterNames.forEach { name -> - getQueryParameter(name)?.let { value -> - paramsMap[name] = value - } - } - - return paramsMap -} diff --git a/core/src/main/java/org/openedx/core/extension/ViewExt.kt b/core/src/main/java/org/openedx/core/extension/ViewExt.kt index ff2e95d47..81a153ba1 100644 --- a/core/src/main/java/org/openedx/core/extension/ViewExt.kt +++ b/core/src/main/java/org/openedx/core/extension/ViewExt.kt @@ -1,48 +1,17 @@ package org.openedx.core.extension -import android.content.Context -import android.content.res.Resources -import android.graphics.Rect -import android.util.DisplayMetrics -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import androidx.fragment.app.DialogFragment +import android.webkit.WebView +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.openedx.core.system.AppCookieManager -fun Context.dpToPixel(dp: Int): Float { - return dp * (resources.displayMetrics.densityDpi.toFloat() / DisplayMetrics.DENSITY_DEFAULT) -} - -fun Context.dpToPixel(dp: Float): Float { - return dp * (resources.displayMetrics.densityDpi.toFloat() / DisplayMetrics.DENSITY_DEFAULT) -} - -fun View.requestApplyInsetsWhenAttached() { - if (isAttachedToWindow) { - // We're already attached, just request as normal - requestApplyInsets() +fun WebView.loadUrl(url: String, scope: CoroutineScope, cookieManager: AppCookieManager) { + if (cookieManager.isSessionCookieMissingOrExpired()) { + scope.launch { + cookieManager.tryToRefreshSessionCookie() + loadUrl(url) + } } else { - // We're not attached to the hierarchy, add a listener to - // request when we are - addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener { - override fun onViewAttachedToWindow(v: View) { - v.removeOnAttachStateChangeListener(this) - v.requestApplyInsets() - } - - override fun onViewDetachedFromWindow(v: View) = Unit - }) + loadUrl(url) } } - -fun DialogFragment.setWidthPercent(percentage: Int) { - val percent = percentage.toFloat() / 100 - val dm = Resources.getSystem().displayMetrics - val rect = dm.run { Rect(0, 0, widthPixels, heightPixels) } - val percentWidth = rect.width() * percent - dialog?.window?.setLayout(percentWidth.toInt(), ViewGroup.LayoutParams.WRAP_CONTENT) -} - -fun Context.toastMessage(message: String) { - Toast.makeText(this, message, Toast.LENGTH_SHORT).show() -} diff --git a/core/src/main/java/org/openedx/core/module/DownloadWorker.kt b/core/src/main/java/org/openedx/core/module/DownloadWorker.kt index 9234ec023..b3c211916 100644 --- a/core/src/main/java/org/openedx/core/module/DownloadWorker.kt +++ b/core/src/main/java/org/openedx/core/module/DownloadWorker.kt @@ -19,33 +19,32 @@ import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadModelEntity import org.openedx.core.module.db.DownloadedState +import org.openedx.core.module.download.AbstractDownloader.DownloadResult import org.openedx.core.module.download.CurrentProgress +import org.openedx.core.module.download.DownloadHelper import org.openedx.core.module.download.FileDownloader +import org.openedx.core.system.notifier.DownloadFailed import org.openedx.core.system.notifier.DownloadNotifier import org.openedx.core.system.notifier.DownloadProgressChanged -import java.io.File +import org.openedx.foundation.utils.FileUtil class DownloadWorker( val context: Context, - parameters: WorkerParameters + parameters: WorkerParameters, ) : CoroutineWorker(context, parameters), CoroutineScope { - private val notificationManager = - context.getSystemService(Context.NOTIFICATION_SERVICE) as - NotificationManager - + private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager private val notificationBuilder = NotificationCompat.Builder(context, CHANNEL_ID) private val notifier by inject(DownloadNotifier::class.java) private val downloadDao: DownloadDao by inject(DownloadDao::class.java) + private val downloadHelper: DownloadHelper by inject(DownloadHelper::class.java) private var downloadEnqueue = listOf() + private var downloadError = mutableListOf() - private val folder = File( - context.externalCacheDir.toString() + File.separator + - context.getString(R.string.app_name) - .replace(Regex("\\s"), "_") - ) + private val fileUtil: FileUtil by inject(FileUtil::class.java) + private val folder = fileUtil.getExternalAppDir() private var currentDownload: DownloadModel? = null private var lastUpdateTime = 0L @@ -61,7 +60,6 @@ class DownloadWorker( return Result.success() } - private fun createForegroundInfo(): ForegroundInfo { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { createChannel() @@ -119,7 +117,7 @@ class DownloadWorker( folder.mkdir() } - downloadEnqueue = downloadDao.readAllData().first() + downloadEnqueue = downloadDao.getAllDataFlow().first() .map { it.mapToDomain() } .filter { it.downloadedState == DownloadedState.WAITING } @@ -134,21 +132,34 @@ class DownloadWorker( ) ) ) - val isSuccess = fileDownloader.download(downloadTask.url, downloadTask.path) - if (isSuccess) { - downloadDao.updateDownloadModel( - DownloadModelEntity.createFrom( - downloadTask.copy( - downloadedState = DownloadedState.DOWNLOADED, - size = File(downloadTask.path).length().toInt() + val downloadResult = fileDownloader.download(downloadTask.url, downloadTask.path) + when (downloadResult) { + DownloadResult.SUCCESS -> { + val updatedModel = downloadHelper.updateDownloadStatus(downloadTask) + if (updatedModel == null) { + downloadDao.removeDownloadModel(downloadTask.id) + downloadError.add(downloadTask) + } else { + downloadDao.updateDownloadModel( + DownloadModelEntity.createFrom(updatedModel) ) - ) - ) - } else { - downloadDao.removeDownloadModel(downloadTask.id) + } + } + + DownloadResult.CANCELED -> { + downloadDao.removeDownloadModel(downloadTask.id) + } + + DownloadResult.ERROR -> { + downloadDao.removeDownloadModel(downloadTask.id) + downloadError.add(downloadTask) + } } newDownload() } else { + if (downloadError.isNotEmpty()) { + notifier.send(DownloadFailed(downloadError)) + } return } } diff --git a/core/src/main/java/org/openedx/core/module/DownloadWorkerController.kt b/core/src/main/java/org/openedx/core/module/DownloadWorkerController.kt index a4e83c07e..39612ae10 100644 --- a/core/src/main/java/org/openedx/core/module/DownloadWorkerController.kt +++ b/core/src/main/java/org/openedx/core/module/DownloadWorkerController.kt @@ -4,7 +4,6 @@ import android.content.Context import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkInfo import androidx.work.WorkManager -import com.google.common.util.concurrent.ListenableFuture import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch @@ -14,7 +13,6 @@ import org.openedx.core.module.db.DownloadModelEntity import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.download.FileDownloader import java.io.File -import java.util.concurrent.ExecutionException class DownloadWorkerController( context: Context, @@ -23,12 +21,11 @@ class DownloadWorkerController( ) { private val workManager = WorkManager.getInstance(context) - private var downloadTaskList = listOf() init { GlobalScope.launch { - downloadDao.readAllData().collect { list -> + downloadDao.getAllDataFlow().collect { list -> val domainList = list.map { it.mapToDomain() } downloadTaskList = domainList.filter { it.downloadedState == DownloadedState.WAITING || it.downloadedState == DownloadedState.DOWNLOADING @@ -46,16 +43,15 @@ class DownloadWorkerController( } private suspend fun updateList() { - downloadTaskList = - downloadDao.readAllData().first().map { it.mapToDomain() }.filter { + downloadTaskList = downloadDao.getAllDataFlow().first() + .map { it.mapToDomain() } + .filter { it.downloadedState == DownloadedState.WAITING || it.downloadedState == DownloadedState.DOWNLOADING } } suspend fun saveModels(downloadModels: List) { - downloadDao.insertDownloadModel( - downloadModels.map { DownloadModelEntity.createFrom(it) } - ) + downloadDao.insertDownloadModel(downloadModels.map { DownloadModelEntity.createFrom(it) }) } suspend fun removeModel(id: String) { @@ -69,11 +65,9 @@ class DownloadWorkerController( downloadModels.forEach { downloadModel -> removeIds.add(downloadModel.id) - if (downloadModel.downloadedState == DownloadedState.DOWNLOADING) { hasDownloading = true } - try { File(downloadModel.path).delete() } catch (e: Exception) { @@ -83,6 +77,7 @@ class DownloadWorkerController( if (hasDownloading) fileDownloader.cancelDownloading() downloadDao.removeAllDownloadModels(removeIds) + downloadDao.removeOfflineXBlockProgress(removeIds) updateList() @@ -96,19 +91,14 @@ class DownloadWorkerController( workManager.cancelAllWorkByTag(DownloadWorker.WORKER_TAG) } - private fun isWorkScheduled(tag: String): Boolean { - val statuses: ListenableFuture> = workManager.getWorkInfosByTag(tag) + val statuses = workManager.getWorkInfosByTag(tag) return try { - val workInfoList: List = statuses.get() - val workInfo = workInfoList.find { - (it.state == WorkInfo.State.RUNNING) or (it.state == WorkInfo.State.ENQUEUED) + val workInfo = statuses.get().find { + it.state == WorkInfo.State.RUNNING || it.state == WorkInfo.State.ENQUEUED } workInfo != null - } catch (e: ExecutionException) { - e.printStackTrace() - false - } catch (e: InterruptedException) { + } catch (e: Exception) { e.printStackTrace() false } diff --git a/core/src/main/java/org/openedx/core/module/TranscriptManager.kt b/core/src/main/java/org/openedx/core/module/TranscriptManager.kt index 863586900..6db81533c 100644 --- a/core/src/main/java/org/openedx/core/module/TranscriptManager.kt +++ b/core/src/main/java/org/openedx/core/module/TranscriptManager.kt @@ -1,9 +1,12 @@ package org.openedx.core.module import android.content.Context -import org.openedx.core.module.download.AbstractDownloader -import org.openedx.core.utils.* import okhttp3.OkHttpClient +import org.openedx.core.module.download.AbstractDownloader +import org.openedx.core.utils.Directories +import org.openedx.core.utils.IOUtils +import org.openedx.core.utils.Sha1Util +import org.openedx.foundation.utils.FileUtil import subtitleFile.FormatSRT import subtitleFile.TimedTextObject import java.io.File @@ -14,7 +17,8 @@ import java.nio.charset.Charset import java.util.concurrent.TimeUnit class TranscriptManager( - val context: Context + val context: Context, + val fileUtil: FileUtil ) { private val transcriptDownloader = object : AbstractDownloader() { @@ -28,7 +32,9 @@ class TranscriptManager( val transcriptDir = getTranscriptDir() ?: return false val hash = Sha1Util.SHA1(url) val file = File(transcriptDir, hash) - return file.exists() && System.currentTimeMillis() - file.lastModified() < TimeUnit.HOURS.toMillis(5) + return file.exists() && System.currentTimeMillis() - file.lastModified() < TimeUnit.HOURS.toMillis( + 5 + ) } fun get(url: String): String? { @@ -60,7 +66,7 @@ class TranscriptManager( downloadLink, file.path ) - if (result) { + if (result == AbstractDownloader.DownloadResult.SUCCESS) { getInputStream(downloadLink)?.let { val transcriptTimedTextObject = convertIntoTimedTextObject(it) @@ -113,7 +119,7 @@ class TranscriptManager( } private fun getTranscriptDir(): File? { - val externalAppDir: File = FileUtil.getExternalAppDir(context) + val externalAppDir: File = fileUtil.getExternalAppDir() if (externalAppDir.exists()) { val videosDir = File(externalAppDir, Directories.VIDEOS.name) val transcriptDir = File(videosDir, Directories.SUBTITLES.name) @@ -122,5 +128,4 @@ class TranscriptManager( } return null } - -} \ No newline at end of file +} diff --git a/core/src/main/java/org/openedx/core/module/db/CalendarDao.kt b/core/src/main/java/org/openedx/core/module/db/CalendarDao.kt new file mode 100644 index 000000000..686009b92 --- /dev/null +++ b/core/src/main/java/org/openedx/core/module/db/CalendarDao.kt @@ -0,0 +1,65 @@ +package org.openedx.core.module.db + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import org.openedx.core.data.model.room.CourseCalendarEventEntity +import org.openedx.core.data.model.room.CourseCalendarStateEntity + +@Dao +interface CalendarDao { + + // region CourseCalendarEventEntity + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertCourseCalendarEntity(vararg courseCalendarEntity: CourseCalendarEventEntity) + + @Query("DELETE FROM course_calendar_event_table WHERE course_id = :courseId") + suspend fun deleteCourseCalendarEntitiesById(courseId: String) + + @Query("SELECT * FROM course_calendar_event_table WHERE course_id=:courseId") + suspend fun readCourseCalendarEventsById(courseId: String): List + + @Query("DELETE FROM course_calendar_event_table") + suspend fun clearCourseCalendarEventsCachedData() + + // region CourseCalendarStateEntity + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertCourseCalendarStateEntity(vararg courseCalendarStateEntity: CourseCalendarStateEntity) + + @Query("SELECT * FROM course_calendar_state_table WHERE course_id=:courseId") + suspend fun readCourseCalendarStateById(courseId: String): CourseCalendarStateEntity? + + @Query("SELECT * FROM course_calendar_state_table") + suspend fun readAllCourseCalendarState(): List + + @Query("DELETE FROM course_calendar_state_table") + suspend fun clearCourseCalendarStateCachedData() + + @Query("DELETE FROM course_calendar_state_table WHERE course_id = :courseId") + suspend fun deleteCourseCalendarStateById(courseId: String) + + @Query("UPDATE course_calendar_state_table SET checksum = 0") + suspend fun resetChecksums() + + @Query( + """ + UPDATE course_calendar_state_table + SET + checksum = COALESCE(:checksum, checksum), + is_course_sync_enabled = COALESCE(:isCourseSyncEnabled, is_course_sync_enabled) + WHERE course_id = :courseId""" + ) + suspend fun updateCourseCalendarStateById( + courseId: String, + checksum: Int? = null, + isCourseSyncEnabled: Boolean? = null + ) + + @Transaction + suspend fun clearCachedData() { + clearCourseCalendarStateCachedData() + clearCourseCalendarEventsCachedData() + } +} diff --git a/core/src/main/java/org/openedx/core/module/db/DownloadDao.kt b/core/src/main/java/org/openedx/core/module/db/DownloadDao.kt index 5bdfc637b..a07329e4d 100644 --- a/core/src/main/java/org/openedx/core/module/db/DownloadDao.kt +++ b/core/src/main/java/org/openedx/core/module/db/DownloadDao.kt @@ -1,7 +1,12 @@ package org.openedx.core.module.db -import androidx.room.* +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update import kotlinx.coroutines.flow.Flow +import org.openedx.core.data.model.room.OfflineXBlockProgress @Dao interface DownloadDao { @@ -16,11 +21,29 @@ interface DownloadDao { suspend fun updateDownloadModel(downloadModelEntity: DownloadModelEntity) @Query("SELECT * FROM download_model") - fun readAllData() : Flow> + fun getAllDataFlow(): Flow> + + @Query("SELECT * FROM download_model") + suspend fun readAllData(): List @Query("SELECT * FROM download_model WHERE id in (:ids)") - fun readAllDataByIds(ids: List) : Flow> + fun readAllDataByIds(ids: List): Flow> @Query("DELETE FROM download_model WHERE id in (:ids)") suspend fun removeAllDownloadModels(ids: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertOfflineXBlockProgress(offlineXBlockProgress: OfflineXBlockProgress) + + @Query("SELECT * FROM offline_x_block_progress_table WHERE id=:id") + suspend fun getOfflineXBlockProgress(id: String): OfflineXBlockProgress? + + @Query("SELECT * FROM offline_x_block_progress_table") + suspend fun getAllOfflineXBlockProgress(): List + + @Query("DELETE FROM offline_x_block_progress_table WHERE id in (:ids)") + suspend fun removeOfflineXBlockProgress(ids: List) + + @Query("DELETE FROM offline_x_block_progress_table") + suspend fun clearOfflineProgress() } diff --git a/core/src/main/java/org/openedx/core/module/db/DownloadModel.kt b/core/src/main/java/org/openedx/core/module/db/DownloadModel.kt index 86bc31540..da736ba28 100644 --- a/core/src/main/java/org/openedx/core/module/db/DownloadModel.kt +++ b/core/src/main/java/org/openedx/core/module/db/DownloadModel.kt @@ -1,15 +1,20 @@ package org.openedx.core.module.db +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize data class DownloadModel( val id: String, val title: String, - val size: Int, + val courseId: String, + val size: Long, val path: String, val url: String, val type: FileType, val downloadedState: DownloadedState, - val progress: Float? -) + val lastModified: String? = null, +) : Parcelable enum class DownloadedState { WAITING, DOWNLOADING, DOWNLOADED, NOT_DOWNLOADED; @@ -26,5 +31,5 @@ enum class DownloadedState { } enum class FileType { - VIDEO, UNKNOWN -} \ No newline at end of file + VIDEO, X_BLOCK +} diff --git a/core/src/main/java/org/openedx/core/module/db/DownloadModelEntity.kt b/core/src/main/java/org/openedx/core/module/db/DownloadModelEntity.kt index cd12a4eea..4e1a2f2cf 100644 --- a/core/src/main/java/org/openedx/core/module/db/DownloadModelEntity.kt +++ b/core/src/main/java/org/openedx/core/module/db/DownloadModelEntity.kt @@ -11,8 +11,10 @@ data class DownloadModelEntity( val id: String, @ColumnInfo("title") val title: String, + @ColumnInfo("courseId") + val courseId: String, @ColumnInfo("size") - val size: Int, + val size: Long, @ColumnInfo("path") val path: String, @ColumnInfo("url") @@ -21,19 +23,20 @@ data class DownloadModelEntity( val type: String, @ColumnInfo("downloadedState") val downloadedState: String, - @ColumnInfo("progress") - val progress: Float? + @ColumnInfo("lastModified") + val lastModified: String? ) { fun mapToDomain() = DownloadModel( id, title, + courseId, size, path, url, FileType.valueOf(type), DownloadedState.valueOf(downloadedState), - progress + lastModified ) companion object { @@ -43,12 +46,13 @@ data class DownloadModelEntity( return DownloadModelEntity( id, title, + courseId, size, path, url, type.name, downloadedState.name, - progress + lastModified ) } } diff --git a/core/src/main/java/org/openedx/core/module/download/AbstractDownloader.kt b/core/src/main/java/org/openedx/core/module/download/AbstractDownloader.kt index 40144325e..146cc1fc3 100644 --- a/core/src/main/java/org/openedx/core/module/download/AbstractDownloader.kt +++ b/core/src/main/java/org/openedx/core/module/download/AbstractDownloader.kt @@ -35,7 +35,7 @@ abstract class AbstractDownloader : KoinComponent { open suspend fun download( url: String, path: String - ): Boolean { + ): DownloadResult { isCanceled = false return try { val response = downloadApi.downloadFile(url).body() @@ -56,20 +56,23 @@ abstract class AbstractDownloader : KoinComponent { } output?.flush() } - true + DownloadResult.SUCCESS } else { - false + DownloadResult.ERROR } } catch (e: Exception) { e.printStackTrace() - false + if (isCanceled) { + DownloadResult.CANCELED + } else { + DownloadResult.ERROR + } } finally { fos?.close() input?.close() } } - suspend fun cancelDownloading() { isCanceled = true withContext(Dispatchers.IO) { @@ -88,4 +91,7 @@ abstract class AbstractDownloader : KoinComponent { } } -} \ No newline at end of file + enum class DownloadResult { + SUCCESS, CANCELED, ERROR + } +} diff --git a/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt b/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt index 40cc94e4d..b6635047f 100644 --- a/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt +++ b/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt @@ -6,7 +6,6 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -import org.openedx.core.BaseViewModel import org.openedx.core.BlockType import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Block @@ -17,8 +16,7 @@ import org.openedx.core.module.db.DownloadedState import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.presentation.CoreAnalyticsEvent import org.openedx.core.presentation.CoreAnalyticsKey -import org.openedx.core.utils.Sha1Util -import java.io.File +import org.openedx.foundation.presentation.BaseViewModel abstract class BaseDownloadViewModel( private val courseId: String, @@ -26,9 +24,10 @@ abstract class BaseDownloadViewModel( private val preferencesManager: CorePreferences, private val workerController: DownloadWorkerController, private val analytics: CoreAnalytics, + private val downloadHelper: DownloadHelper, ) : BaseViewModel() { - private val allBlocks = hashMapOf() + val allBlocks = hashMapOf() private val downloadableChildrenMap = hashMapOf>() private val downloadModelsStatus = hashMapOf() @@ -42,7 +41,7 @@ abstract class BaseDownloadViewModel( init { viewModelScope.launch { - downloadDao.readAllData().map { list -> list.map { it.mapToDomain() } } + downloadDao.getAllDataFlow().map { list -> list.map { it.mapToDomain() } } .collect { downloadModels -> updateDownloadModelsStatus(downloadModels) _downloadModelsStatusFlow.emit(downloadModelsStatus) @@ -56,7 +55,7 @@ abstract class BaseDownloadViewModel( } private suspend fun getDownloadModelList(): List { - return downloadDao.readAllData().first().map { it.mapToDomain() } + return downloadDao.getAllDataFlow().first().map { it.mapToDomain() } } private suspend fun updateDownloadModelsStatus(models: List) { @@ -121,33 +120,16 @@ abstract class BaseDownloadViewModel( } } - private suspend fun saveDownloadModels(folder: String, saveBlocksIds: List) { + suspend fun saveDownloadModels(folder: String, saveBlocksIds: List) { val downloadModels = mutableListOf() val downloadModelList = getDownloadModelList() for (blockId in saveBlocksIds) { allBlocks[blockId]?.let { block -> - val videoInfo = - block.studentViewData?.encodedVideos?.getPreferredVideoInfoForDownloading( - preferencesManager.videoSettings.videoDownloadQuality - ) - val size = videoInfo?.fileSize ?: 0 - val url = videoInfo?.url ?: "" - val extension = url.split('.').lastOrNull() ?: "mp4" - val path = - folder + File.separator + "${Sha1Util.SHA1(block.displayName)}.$extension" - if (downloadModelList.find { it.id == blockId && it.downloadedState.isDownloaded } == null) { - downloadModels.add( - DownloadModel( - block.id, - block.displayName, - size, - path, - url, - block.downloadableType, - DownloadedState.WAITING, - null - ) - ) + val downloadModel = downloadHelper.generateDownloadModelFromBlock(folder, block, courseId) + val isNotDownloaded = + downloadModelList.find { it.id == blockId && it.downloadedState.isDownloaded } == null + if (isNotDownloaded && downloadModel != null) { + downloadModels.add(downloadModel) } } } @@ -212,6 +194,12 @@ abstract class BaseDownloadViewModel( } } + fun removeBlockDownloadModel(blockId: String) { + viewModelScope.launch { + workerController.removeModel(blockId) + } + } + protected fun addDownloadableChildrenForSequentialBlock(sequentialBlock: Block) { for (item in sequentialBlock.descendants) { allBlocks[item]?.let { blockDescendant -> @@ -229,17 +217,6 @@ abstract class BaseDownloadViewModel( } } - protected fun addDownloadableChildrenForVerticalBlock(verticalBlock: Block) { - for (unitBlockId in verticalBlock.descendants) { - val block = allBlocks[unitBlockId] - if (block?.isDownloadable == true) { - val id = verticalBlock.id - val children = downloadableChildrenMap[id] ?: listOf() - downloadableChildrenMap[id] = children + block.id - } - } - } - fun logBulkDownloadToggleEvent(toggle: Boolean) { logEvent( CoreAnalyticsEvent.VIDEO_BULK_DOWNLOAD_TOGGLE, diff --git a/core/src/main/java/org/openedx/core/module/download/DownloadHelper.kt b/core/src/main/java/org/openedx/core/module/download/DownloadHelper.kt new file mode 100644 index 000000000..79e44ab3c --- /dev/null +++ b/core/src/main/java/org/openedx/core/module/download/DownloadHelper.kt @@ -0,0 +1,114 @@ +package org.openedx.core.module.download + +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.Block +import org.openedx.core.module.db.DownloadModel +import org.openedx.core.module.db.DownloadedState +import org.openedx.core.module.db.FileType +import org.openedx.core.utils.Sha1Util +import org.openedx.core.utils.unzipFile +import org.openedx.foundation.utils.FileUtil +import java.io.File + +class DownloadHelper( + private val preferencesManager: CorePreferences, + private val fileUtil: FileUtil, +) { + + fun generateDownloadModelFromBlock( + folder: String, + block: Block, + courseId: String + ): DownloadModel? { + return when (val downloadableType = block.downloadableType) { + FileType.VIDEO -> { + val videoInfo = + block.studentViewData?.encodedVideos?.getPreferredVideoInfoForDownloading( + preferencesManager.videoSettings.videoDownloadQuality + ) + val size = videoInfo?.fileSize ?: 0 + val url = videoInfo?.url ?: "" + val extension = url.split('.').lastOrNull() ?: "mp4" + val path = + folder + File.separator + "${Sha1Util.SHA1(url)}.$extension" + DownloadModel( + block.id, + block.displayName, + courseId, + size, + path, + url, + downloadableType, + DownloadedState.WAITING, + null + ) + } + + FileType.X_BLOCK -> { + val url = if (block.downloadableType == FileType.X_BLOCK) { + block.offlineDownload?.fileUrl ?: "" + } else { + "" + } + val size = block.offlineDownload?.fileSize ?: 0 + val extension = "zip" + val path = + folder + File.separator + "${Sha1Util.SHA1(url)}.$extension" + val lastModified = block.offlineDownload?.lastModified + DownloadModel( + block.id, + block.displayName, + courseId, + size, + path, + url, + downloadableType, + DownloadedState.WAITING, + lastModified + ) + } + + null -> null + } + } + + suspend fun updateDownloadStatus(downloadModel: DownloadModel): DownloadModel? { + return when (downloadModel.type) { + FileType.VIDEO -> { + downloadModel.copy( + downloadedState = DownloadedState.DOWNLOADED, + size = File(downloadModel.path).length() + ) + } + + FileType.X_BLOCK -> { + val unzippedFolderPath = fileUtil.unzipFile(downloadModel.path) ?: return null + downloadModel.copy( + downloadedState = DownloadedState.DOWNLOADED, + size = calculateDirectorySize(File(unzippedFolderPath)), + path = unzippedFolderPath + ) + } + } + } + + private fun calculateDirectorySize(directory: File): Long { + var size: Long = 0 + + if (directory.exists()) { + val files = directory.listFiles() + + if (files != null) { + for (file in files) { + size += if (file.isDirectory) { + calculateDirectorySize(file) + } else { + file.length() + } + } + } + } + + return size + } +} diff --git a/core/src/main/java/org/openedx/core/presentation/course/CourseContainerTab.kt b/core/src/main/java/org/openedx/core/presentation/course/CourseContainerTab.kt deleted file mode 100644 index 51d235c36..000000000 --- a/core/src/main/java/org/openedx/core/presentation/course/CourseContainerTab.kt +++ /dev/null @@ -1,24 +0,0 @@ -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/DialogUI.kt b/core/src/main/java/org/openedx/core/presentation/dialog/DialogUI.kt new file mode 100644 index 000000000..17b1d2874 --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/dialog/DialogUI.kt @@ -0,0 +1,56 @@ +package org.openedx.core.presentation.dialog + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import org.openedx.core.ui.noRippleClickable +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes + +@Composable +fun DefaultDialogBox( + modifier: Modifier = Modifier, + onDismissClick: () -> Unit, + content: @Composable (BoxScope.() -> Unit) +) { + Surface( + modifier = modifier, + color = Color.Transparent + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 4.dp) + .noRippleClickable { + onDismissClick() + }, + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .widthIn(max = 640.dp) + .fillMaxWidth() + .clip(MaterialTheme.appShapes.cardShape) + .noRippleClickable {} + .background( + color = MaterialTheme.appColors.background, + shape = MaterialTheme.appShapes.cardShape + ) + ) { + content.invoke(this) + } + } + } +} diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/alert/ActionDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/alert/ActionDialogFragment.kt index b7b3167e6..451d94915 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/alert/ActionDialogFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/alert/ActionDialogFragment.kt @@ -40,7 +40,7 @@ import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography -import org.openedx.core.utils.UrlUtils +import org.openedx.foundation.utils.UrlUtils class ActionDialogFragment : DialogFragment() { diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/appreview/AppReviewUI.kt b/core/src/main/java/org/openedx/core/presentation/dialog/appreview/AppReviewUI.kt index e2d6a471f..a1df55a05 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/appreview/AppReviewUI.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/appreview/AppReviewUI.kt @@ -4,26 +4,20 @@ import android.content.res.Configuration.ORIENTATION_LANDSCAPE import android.content.res.Configuration.UI_MODE_NIGHT_NO import android.content.res.Configuration.UI_MODE_NIGHT_YES import androidx.compose.foundation.Image -import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.OutlinedTextField -import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.TextFieldDefaults import androidx.compose.material.icons.Icons @@ -40,7 +34,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale @@ -54,7 +47,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import org.openedx.core.R -import org.openedx.core.ui.noRippleClickable +import org.openedx.core.presentation.dialog.DefaultDialogBox import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes @@ -78,7 +71,7 @@ fun ThankYouDialog( DefaultDialogBox( modifier = modifier, - onDismissClock = onNotNowClick + onDismissClick = onNotNowClick ) { Column( modifier = Modifier @@ -139,7 +132,7 @@ fun FeedbackDialog( DefaultDialogBox( modifier = modifier, - onDismissClock = onNotNowClick + onDismissClick = onNotNowClick ) { Column( modifier = Modifier @@ -210,7 +203,7 @@ fun RateDialog( ) { DefaultDialogBox( modifier = modifier, - onDismissClock = onNotNowClick + onDismissClick = onNotNowClick ) { Column( modifier = Modifier @@ -252,42 +245,6 @@ fun RateDialog( } } -@Composable -fun DefaultDialogBox( - modifier: Modifier = Modifier, - onDismissClock: () -> Unit, - content: @Composable (BoxScope.() -> Unit) -) { - Surface( - modifier = modifier, - color = Color.Transparent - ) { - Box( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 4.dp) - .noRippleClickable { - onDismissClock() - }, - contentAlignment = Alignment.Center - ) { - Box( - modifier = Modifier - .widthIn(max = 640.dp) - .fillMaxWidth() - .clip(MaterialTheme.appShapes.cardShape) - .noRippleClickable {} - .background( - color = MaterialTheme.appColors.background, - shape = MaterialTheme.appShapes.cardShape - ) - ) { - content.invoke(this) - } - } - } -} - @Composable fun TransparentTextButton( text: String, @@ -320,8 +277,8 @@ fun DefaultTextButton( val textColor: Color val backgroundColor: Color if (isEnabled) { - textColor = MaterialTheme.appColors.buttonText - backgroundColor = MaterialTheme.appColors.buttonBackground + textColor = MaterialTheme.appColors.primaryButtonText + backgroundColor = MaterialTheme.appColors.primaryButtonBackground } else { textColor = MaterialTheme.appColors.inactiveButtonText backgroundColor = MaterialTheme.appColors.inactiveButtonBackground diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/appreview/BaseAppReviewDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/appreview/BaseAppReviewDialogFragment.kt index 57dcdc233..245b8fe11 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/appreview/BaseAppReviewDialogFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/appreview/BaseAppReviewDialogFragment.kt @@ -3,8 +3,8 @@ package org.openedx.core.presentation.dialog.appreview import androidx.fragment.app.DialogFragment import org.koin.android.ext.android.inject import org.openedx.core.data.storage.InAppReviewPreferences -import org.openedx.core.extension.nonZero import org.openedx.core.presentation.global.AppData +import org.openedx.foundation.extension.nonZero open class BaseAppReviewDialogFragment : DialogFragment() { diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/selectorbottomsheet/SelectBottomDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/selectorbottomsheet/SelectBottomDialogFragment.kt index e2b6bdd58..8eca02a99 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/selectorbottomsheet/SelectBottomDialogFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/selectorbottomsheet/SelectBottomDialogFragment.kt @@ -32,13 +32,13 @@ import com.google.android.material.bottomsheet.BottomSheetDialogFragment import org.koin.androidx.viewmodel.ext.android.viewModel import org.openedx.core.R import org.openedx.core.domain.model.RegistrationField -import org.openedx.core.extension.parcelableArrayList import org.openedx.core.ui.SheetContent import org.openedx.core.ui.isImeVisibleState import org.openedx.core.ui.noRippleClickable import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes +import org.openedx.foundation.extension.parcelableArrayList class SelectBottomDialogFragment : BottomSheetDialogFragment() { @@ -95,7 +95,7 @@ class SelectBottomDialogFragment : BottomSheetDialogFragment() { ) .clip(MaterialTheme.appShapes.screenBackgroundShape) .padding(bottom = if (isImeVisible) 120.dp else 0.dp) - .noRippleClickable { } + .noRippleClickable { } ) { SheetContent( searchValue = searchValue, diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/selectorbottomsheet/SelectDialogViewModel.kt b/core/src/main/java/org/openedx/core/presentation/dialog/selectorbottomsheet/SelectDialogViewModel.kt index 6a09f5724..84d6d1407 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/selectorbottomsheet/SelectDialogViewModel.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/selectorbottomsheet/SelectDialogViewModel.kt @@ -1,11 +1,11 @@ package org.openedx.core.presentation.dialog.selectorbottomsheet import androidx.lifecycle.viewModelScope -import org.openedx.core.BaseViewModel +import kotlinx.coroutines.launch import org.openedx.core.domain.model.RegistrationField import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseSubtitleLanguageChanged -import kotlinx.coroutines.launch +import org.openedx.foundation.presentation.BaseViewModel class SelectDialogViewModel( private val notifier: CourseNotifier diff --git a/core/src/main/java/org/openedx/core/presentation/global/ErrorType.kt b/core/src/main/java/org/openedx/core/presentation/global/ErrorType.kt new file mode 100644 index 000000000..481758ecb --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/global/ErrorType.kt @@ -0,0 +1,23 @@ +package org.openedx.core.presentation.global + +import org.openedx.core.R + +enum class ErrorType( + val iconResId: Int = 0, + val titleResId: Int = 0, + val descriptionResId: Int = 0, + val actionResId: Int = 0, +) { + CONNECTION_ERROR( + iconResId = R.drawable.core_no_internet_connection, + titleResId = R.string.core_no_internet_connection, + descriptionResId = R.string.core_no_internet_connection_description, + actionResId = R.string.core_reload, + ), + UNKNOWN_ERROR( + iconResId = R.drawable.core_ic_unknown_error, + titleResId = R.string.core_try_again, + descriptionResId = R.string.core_something_went_wrong_description, + actionResId = R.string.core_reload, + ), +} diff --git a/core/src/main/java/org/openedx/core/presentation/global/WindowSizeHolder.kt b/core/src/main/java/org/openedx/core/presentation/global/WindowSizeHolder.kt index 463f27ef2..510163b70 100644 --- a/core/src/main/java/org/openedx/core/presentation/global/WindowSizeHolder.kt +++ b/core/src/main/java/org/openedx/core/presentation/global/WindowSizeHolder.kt @@ -1,7 +1,7 @@ package org.openedx.core.presentation.global -import org.openedx.core.ui.WindowSize +import org.openedx.foundation.presentation.WindowSize interface WindowSizeHolder { val windowSize: WindowSize -} \ No newline at end of file +} diff --git a/core/src/main/java/org/openedx/core/presentation/global/app_upgrade/AppUpdateUI.kt b/core/src/main/java/org/openedx/core/presentation/global/app_upgrade/AppUpdateUI.kt index f0502b49d..3f8dd6fa9 100644 --- a/core/src/main/java/org/openedx/core/presentation/global/app_upgrade/AppUpdateUI.kt +++ b/core/src/main/java/org/openedx/core/presentation/global/app_upgrade/AppUpdateUI.kt @@ -292,7 +292,7 @@ fun DefaultTextButton( .testTag("btn_primary") .height(42.dp), colors = ButtonDefaults.buttonColors( - backgroundColor = MaterialTheme.appColors.buttonBackground + backgroundColor = MaterialTheme.appColors.primaryButtonBackground ), elevation = null, shape = MaterialTheme.appShapes.navigationButtonShape, @@ -305,7 +305,7 @@ fun DefaultTextButton( Text( modifier = Modifier.testTag("txt_primary"), text = text, - color = MaterialTheme.appColors.buttonText, + color = MaterialTheme.appColors.primaryButtonText, style = MaterialTheme.appTypography.labelLarge ) } @@ -401,4 +401,4 @@ private fun AppUpgradeRecommendDialogPreview() { onUpdateClick = {} ) } -} \ No newline at end of file +} diff --git a/core/src/main/java/org/openedx/core/presentation/global/webview/WebContentFragment.kt b/core/src/main/java/org/openedx/core/presentation/global/webview/WebContentFragment.kt index b1a496743..567a8ccce 100644 --- a/core/src/main/java/org/openedx/core/presentation/global/webview/WebContentFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/global/webview/WebContentFragment.kt @@ -11,8 +11,8 @@ import androidx.fragment.app.Fragment import org.koin.android.ext.android.inject import org.openedx.core.config.Config import org.openedx.core.ui.WebContentScreen -import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.foundation.presentation.rememberWindowSize class WebContentFragment : Fragment() { diff --git a/core/src/main/java/org/openedx/core/presentation/global/webview/WebViewUIState.kt b/core/src/main/java/org/openedx/core/presentation/global/webview/WebViewUIState.kt new file mode 100644 index 000000000..3a99afaaf --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/global/webview/WebViewUIState.kt @@ -0,0 +1,15 @@ +package org.openedx.core.presentation.global.webview + +import org.openedx.core.presentation.global.ErrorType + +sealed class WebViewUIState { + data object Loading : WebViewUIState() + data object Loaded : WebViewUIState() + data class Error(val errorType: ErrorType) : WebViewUIState() +} + +enum class WebViewUIAction { + WEB_PAGE_LOADED, + WEB_PAGE_ERROR, + RELOAD_WEB_PAGE +} diff --git a/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncDialog.kt b/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncDialog.kt similarity index 96% rename from course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncDialog.kt rename to core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncDialog.kt index a3775c99b..f53e27e90 100644 --- a/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncDialog.kt +++ b/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncDialog.kt @@ -1,4 +1,4 @@ -package org.openedx.course.presentation.calendarsync +package org.openedx.core.presentation.settings.calendarsync import android.content.res.Configuration import androidx.compose.foundation.background @@ -23,13 +23,13 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog -import org.openedx.core.extension.takeIfNotEmpty +import org.openedx.core.R import org.openedx.core.presentation.global.app_upgrade.TransparentTextButton import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography -import org.openedx.course.R +import org.openedx.foundation.extension.takeIfNotEmpty import androidx.compose.ui.window.DialogProperties as AlertDialogProperties import org.openedx.core.R as CoreR @@ -192,7 +192,7 @@ private fun SyncDialog() { verticalArrangement = Arrangement.spacedBy(8.dp), ) { Text( - text = stringResource(id = R.string.course_title_syncing_calendar), + text = stringResource(id = R.string.core_title_syncing_calendar), color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.titleMedium, maxLines = 2, @@ -230,5 +230,5 @@ private fun CalendarSyncDialogsPreview( } private class CalendarSyncDialogTypeProvider : PreviewParameterProvider { - override val values = CalendarSyncDialogType.values().dropLast(1).asSequence() + override val values = CalendarSyncDialogType.entries.dropLast(1).asSequence() } diff --git a/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncDialogType.kt b/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncDialogType.kt new file mode 100644 index 000000000..daab61fa5 --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncDialogType.kt @@ -0,0 +1,44 @@ +package org.openedx.core.presentation.settings.calendarsync + +import org.openedx.core.R + +enum class CalendarSyncDialogType( + val titleResId: Int = 0, + val messageResId: Int = 0, + val positiveButtonResId: Int = 0, + val negativeButtonResId: Int = 0, +) { + SYNC_DIALOG( + titleResId = R.string.core_title_add_course_calendar, + messageResId = R.string.core_message_add_course_calendar, + positiveButtonResId = R.string.core_ok, + negativeButtonResId = R.string.core_cancel + ), + UN_SYNC_DIALOG( + titleResId = R.string.core_title_remove_course_calendar, + messageResId = R.string.core_message_remove_course_calendar, + positiveButtonResId = R.string.core_label_remove, + negativeButtonResId = R.string.core_cancel + ), + PERMISSION_DIALOG( + titleResId = R.string.core_title_request_calendar_permission, + messageResId = R.string.core_message_request_calendar_permission, + positiveButtonResId = R.string.core_ok, + negativeButtonResId = R.string.core_label_do_not_allow + ), + EVENTS_DIALOG( + messageResId = R.string.core_message_course_calendar_added, + positiveButtonResId = R.string.core_label_view_events, + negativeButtonResId = R.string.core_label_done + ), + OUT_OF_SYNC_DIALOG( + titleResId = R.string.core_title_calendar_out_of_date, + messageResId = R.string.core_message_calendar_out_of_date, + positiveButtonResId = R.string.core_label_update_now, + negativeButtonResId = R.string.core_label_remove_course_calendar, + ), + LOADING_DIALOG( + titleResId = R.string.core_title_syncing_calendar + ), + NONE; +} diff --git a/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncState.kt b/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncState.kt new file mode 100644 index 000000000..95a851442 --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncState.kt @@ -0,0 +1,52 @@ +package org.openedx.core.presentation.settings.calendarsync + +import androidx.annotation.StringRes +import androidx.compose.material.MaterialTheme +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CloudSync +import androidx.compose.material.icons.filled.SyncDisabled +import androidx.compose.material.icons.rounded.EventRepeat +import androidx.compose.material.icons.rounded.FreeCancellation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import org.openedx.core.R +import org.openedx.core.ui.theme.appColors + +enum class CalendarSyncState( + @StringRes val title: Int, + @StringRes val longTitle: Int, + val icon: ImageVector +) { + OFFLINE( + R.string.core_offline, + R.string.core_offline, + Icons.Default.SyncDisabled + ), + SYNC_FAILED( + R.string.core_syncing_failed, + R.string.core_calendar_sync_failed, + Icons.Rounded.FreeCancellation + ), + SYNCED( + R.string.core_to_sync, + R.string.core_synced_to_calendar, + Icons.Rounded.EventRepeat + ), + SYNCHRONIZATION( + R.string.core_syncing_to_calendar, + R.string.core_syncing_to_calendar, + Icons.Default.CloudSync + ); + + val tint: Color + @Composable + @ReadOnlyComposable + get() = when (this) { + OFFLINE -> MaterialTheme.appColors.textFieldHint + SYNC_FAILED -> MaterialTheme.appColors.error + SYNCED -> MaterialTheme.appColors.successGreen + SYNCHRONIZATION -> MaterialTheme.appColors.primary + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncUIState.kt b/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncUIState.kt similarity index 77% rename from course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncUIState.kt rename to core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncUIState.kt index 24d2212e2..1f32f3f56 100644 --- a/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncUIState.kt +++ b/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncUIState.kt @@ -1,4 +1,4 @@ -package org.openedx.course.presentation.calendarsync +package org.openedx.core.presentation.settings.calendarsync import org.openedx.core.domain.model.CourseDateBlock import java.util.concurrent.atomic.AtomicReference @@ -11,4 +11,7 @@ data class CalendarSyncUIState( val isSynced: Boolean = false, val checkForOutOfSync: AtomicReference = AtomicReference(false), val uiMessage: AtomicReference = AtomicReference(""), -) +) { + val isDialogVisible: Boolean + get() = dialogType != CalendarSyncDialogType.NONE +} diff --git a/course/src/main/java/org/openedx/course/presentation/calendarsync/DialogProperties.kt b/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/DialogProperties.kt similarity index 78% rename from course/src/main/java/org/openedx/course/presentation/calendarsync/DialogProperties.kt rename to core/src/main/java/org/openedx/core/presentation/settings/calendarsync/DialogProperties.kt index cefded76c..cfca43193 100644 --- a/course/src/main/java/org/openedx/course/presentation/calendarsync/DialogProperties.kt +++ b/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/DialogProperties.kt @@ -1,4 +1,4 @@ -package org.openedx.course.presentation.calendarsync +package org.openedx.core.presentation.settings.calendarsync data class DialogProperties( val title: String, diff --git a/core/src/main/java/org/openedx/core/presentation/settings/VideoQualityFragment.kt b/core/src/main/java/org/openedx/core/presentation/settings/video/VideoQualityFragment.kt similarity index 95% rename from core/src/main/java/org/openedx/core/presentation/settings/VideoQualityFragment.kt rename to core/src/main/java/org/openedx/core/presentation/settings/video/VideoQualityFragment.kt index e26d882eb..660a52a94 100644 --- a/core/src/main/java/org/openedx/core/presentation/settings/VideoQualityFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/settings/video/VideoQualityFragment.kt @@ -1,4 +1,4 @@ -package org.openedx.core.presentation.settings +package org.openedx.core.presentation.settings.video import android.content.res.Configuration import android.os.Bundle @@ -49,18 +49,18 @@ import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.core.R import org.openedx.core.domain.model.VideoQuality -import org.openedx.core.extension.nonZero -import org.openedx.core.extension.tagId import org.openedx.core.ui.Toolbar -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType import org.openedx.core.ui.displayCutoutForLandscape -import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography -import org.openedx.core.ui.windowSizeValue +import org.openedx.foundation.extension.nonZero +import org.openedx.foundation.extension.tagId +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue class VideoQualityFragment : Fragment() { @@ -183,7 +183,7 @@ private fun VideoQualityScreen( .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally ) { - VideoQuality.values().forEach { videoQuality -> + VideoQuality.entries.forEach { videoQuality -> QualityOption( title = stringResource(id = videoQuality.titleResId), description = videoQuality.desResId.nonZero() diff --git a/core/src/main/java/org/openedx/core/presentation/settings/VideoQualityType.kt b/core/src/main/java/org/openedx/core/presentation/settings/video/VideoQualityType.kt similarity index 51% rename from core/src/main/java/org/openedx/core/presentation/settings/VideoQualityType.kt rename to core/src/main/java/org/openedx/core/presentation/settings/video/VideoQualityType.kt index 4c7973d6a..c39b6d220 100644 --- a/core/src/main/java/org/openedx/core/presentation/settings/VideoQualityType.kt +++ b/core/src/main/java/org/openedx/core/presentation/settings/video/VideoQualityType.kt @@ -1,4 +1,4 @@ -package org.openedx.core.presentation.settings +package org.openedx.core.presentation.settings.video enum class VideoQualityType { Streaming, Download diff --git a/core/src/main/java/org/openedx/core/presentation/settings/VideoQualityViewModel.kt b/core/src/main/java/org/openedx/core/presentation/settings/video/VideoQualityViewModel.kt similarity index 95% rename from core/src/main/java/org/openedx/core/presentation/settings/VideoQualityViewModel.kt rename to core/src/main/java/org/openedx/core/presentation/settings/video/VideoQualityViewModel.kt index c6d5176ea..2f8935e7a 100644 --- a/core/src/main/java/org/openedx/core/presentation/settings/VideoQualityViewModel.kt +++ b/core/src/main/java/org/openedx/core/presentation/settings/video/VideoQualityViewModel.kt @@ -1,10 +1,9 @@ -package org.openedx.core.presentation.settings +package org.openedx.core.presentation.settings.video import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.launch -import org.openedx.core.BaseViewModel import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.VideoQuality import org.openedx.core.presentation.CoreAnalytics @@ -12,6 +11,7 @@ import org.openedx.core.presentation.CoreAnalyticsEvent import org.openedx.core.presentation.CoreAnalyticsKey import org.openedx.core.system.notifier.VideoNotifier import org.openedx.core.system.notifier.VideoQualityChanged +import org.openedx.foundation.presentation.BaseViewModel class VideoQualityViewModel( private val qualityType: String, diff --git a/core/src/main/java/org/openedx/core/repository/CalendarRepository.kt b/core/src/main/java/org/openedx/core/repository/CalendarRepository.kt new file mode 100644 index 000000000..726709d8a --- /dev/null +++ b/core/src/main/java/org/openedx/core/repository/CalendarRepository.kt @@ -0,0 +1,68 @@ +package org.openedx.core.repository + +import org.openedx.core.data.api.CourseApi +import org.openedx.core.data.model.room.CourseCalendarEventEntity +import org.openedx.core.data.model.room.CourseCalendarStateEntity +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.CourseCalendarEvent +import org.openedx.core.domain.model.CourseCalendarState +import org.openedx.core.domain.model.EnrollmentStatus +import org.openedx.core.module.db.CalendarDao + +class CalendarRepository( + private val api: CourseApi, + private val corePreferences: CorePreferences, + private val calendarDao: CalendarDao +) { + + suspend fun getEnrollmentsStatus(): List { + val response = api.getEnrollmentsStatus(corePreferences.user?.username ?: "") + return response.map { it.mapToDomain() } + } + + suspend fun getCourseDates(courseId: String) = api.getCourseDates(courseId) + + suspend fun insertCourseCalendarEntityToCache(vararg courseCalendarEntity: CourseCalendarEventEntity) { + calendarDao.insertCourseCalendarEntity(*courseCalendarEntity) + } + + suspend fun getCourseCalendarEventsByIdFromCache(courseId: String): List { + return calendarDao.readCourseCalendarEventsById(courseId).map { it.mapToDomain() } + } + + suspend fun deleteCourseCalendarEntitiesByIdFromCache(courseId: String) { + calendarDao.deleteCourseCalendarEntitiesById(courseId) + } + + suspend fun insertCourseCalendarStateEntityToCache(vararg courseCalendarStateEntity: CourseCalendarStateEntity) { + calendarDao.insertCourseCalendarStateEntity(*courseCalendarStateEntity) + } + + suspend fun getCourseCalendarStateByIdFromCache(courseId: String): CourseCalendarState? { + return calendarDao.readCourseCalendarStateById(courseId)?.mapToDomain() + } + + suspend fun getAllCourseCalendarStateFromCache(): List { + return calendarDao.readAllCourseCalendarState().map { it.mapToDomain() } + } + + suspend fun resetChecksums() { + calendarDao.resetChecksums() + } + + suspend fun clearCalendarCachedData() { + calendarDao.clearCachedData() + } + + suspend fun updateCourseCalendarStateByIdInCache( + courseId: String, + checksum: Int? = null, + isCourseSyncEnabled: Boolean? = null + ) { + calendarDao.updateCourseCalendarStateById(courseId, checksum, isCourseSyncEnabled) + } + + suspend fun deleteCourseCalendarStateByIdFromCache(courseId: String) { + calendarDao.deleteCourseCalendarStateById(courseId) + } +} diff --git a/core/src/main/java/org/openedx/core/system/AppCookieManager.kt b/core/src/main/java/org/openedx/core/system/AppCookieManager.kt index f09e16362..7df19c627 100644 --- a/core/src/main/java/org/openedx/core/system/AppCookieManager.kt +++ b/core/src/main/java/org/openedx/core/system/AppCookieManager.kt @@ -11,8 +11,6 @@ import java.util.concurrent.TimeUnit class AppCookieManager(private val config: Config, private val api: CookiesApi) { companion object { - private const val REV_934_COOKIE = - "REV_934=mobile; expires=Tue, 31 Dec 2021 12:00:20 GMT; domain=.edx.org;" private val FRESHNESS_INTERVAL = TimeUnit.HOURS.toMillis(1) } @@ -34,19 +32,11 @@ class AppCookieManager(private val config: Config, private val api: CookiesApi) } fun clearWebViewCookie() { - CookieManager.getInstance().removeAllCookies { result -> - if (result) { - authSessionCookieExpiration = -1 - } - } + CookieManager.getInstance().removeAllCookies(null) + authSessionCookieExpiration = -1 } fun isSessionCookieMissingOrExpired(): Boolean { return authSessionCookieExpiration < System.currentTimeMillis() } - - fun setMobileCookie() { - CookieManager.getInstance().setCookie(config.getApiHostURL(), REV_934_COOKIE) - } - } diff --git a/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarManager.kt b/core/src/main/java/org/openedx/core/system/CalendarManager.kt similarity index 54% rename from course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarManager.kt rename to core/src/main/java/org/openedx/core/system/CalendarManager.kt index 54639e922..c1a393767 100644 --- a/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarManager.kt +++ b/core/src/main/java/org/openedx/core/system/CalendarManager.kt @@ -1,30 +1,27 @@ -package org.openedx.course.presentation.calendarsync +package org.openedx.core.system -import android.annotation.SuppressLint import android.content.ContentUris import android.content.ContentValues import android.content.Context -import android.content.Intent import android.content.pm.PackageManager import android.database.Cursor import android.net.Uri import android.provider.CalendarContract import androidx.core.content.ContextCompat +import io.branch.indexing.BranchUniversalObject +import io.branch.referral.util.ContentMetadata +import io.branch.referral.util.LinkProperties import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.CalendarData import org.openedx.core.domain.model.CourseDateBlock -import org.openedx.core.system.ResourceManager import org.openedx.core.utils.Logger import org.openedx.core.utils.toCalendar -import org.openedx.course.R -import java.util.Calendar import java.util.TimeZone import java.util.concurrent.TimeUnit -import org.openedx.core.R as CoreR class CalendarManager( private val context: Context, private val corePreferences: CorePreferences, - private val resourceManager: ResourceManager, ) { private val logger = Logger(TAG) @@ -33,7 +30,7 @@ class CalendarManager( android.Manifest.permission.READ_CALENDAR ) - private val accountName: String + val accountName: String get() = getUserAccountForSync() /** @@ -46,29 +43,40 @@ class CalendarManager( /** * Check if the calendar is already existed in mobile calendar app or not */ - fun isCalendarExists(calendarTitle: String): Boolean { - if (hasPermissions()) { - return getCalendarId(calendarTitle) != CALENDAR_DOES_NOT_EXIST - } - return false + fun isCalendarExist(calendarId: Long): Boolean { + val projection = arrayOf(CalendarContract.Calendars._ID) + val selection = "${CalendarContract.Calendars._ID} = ?" + val selectionArgs = arrayOf(calendarId.toString()) + + val cursor = context.contentResolver.query( + CalendarContract.Calendars.CONTENT_URI, + projection, + selection, + selectionArgs, + null + ) + + val exists = cursor != null && cursor.count > 0 + cursor?.close() + + return exists } /** * Create or update the calendar if it is already existed in mobile calendar app */ fun createOrUpdateCalendar( - calendarTitle: String + calendarId: Long = CALENDAR_DOES_NOT_EXIST, + calendarTitle: String, + calendarColor: Long ): Long { - val calendarId = getCalendarId( - calendarTitle = calendarTitle - ) - if (calendarId != CALENDAR_DOES_NOT_EXIST) { deleteCalendar(calendarId = calendarId) } return createCalendar( - calendarTitle = calendarTitle + calendarTitle = calendarTitle, + calendarColor = calendarColor ) } @@ -76,7 +84,8 @@ class CalendarManager( * Method to create a separate calendar based on course name in mobile calendar app */ private fun createCalendar( - calendarTitle: String + calendarTitle: String, + calendarColor: Long ): Long { val contentValues = ContentValues() contentValues.put(CalendarContract.Calendars.NAME, calendarTitle) @@ -95,7 +104,7 @@ class CalendarManager( contentValues.put(CalendarContract.Calendars.VISIBLE, 1) contentValues.put( CalendarContract.Calendars.CALENDAR_COLOR, - ContextCompat.getColor(context, org.openedx.core.R.color.primary) + calendarColor.toInt() ) val creationUri: Uri? = asSyncAdapter( Uri.parse(CalendarContract.Calendars.CONTENT_URI.toString()), @@ -112,39 +121,6 @@ class CalendarManager( return CALENDAR_DOES_NOT_EXIST } - /** - * Method to check if the calendar with the course name exist in the mobile calendar app or not - */ - @SuppressLint("Range") - fun getCalendarId(calendarTitle: String): Long { - var calendarId = CALENDAR_DOES_NOT_EXIST - val projection = arrayOf( - CalendarContract.Calendars._ID, - CalendarContract.Calendars.ACCOUNT_NAME, - CalendarContract.Calendars.NAME - ) - val calendarContentResolver = context.contentResolver - val cursor = calendarContentResolver.query( - CalendarContract.Calendars.CONTENT_URI, projection, - CalendarContract.Calendars.ACCOUNT_NAME + "=? and (" + - CalendarContract.Calendars.NAME + "=? or " + - CalendarContract.Calendars.CALENDAR_DISPLAY_NAME + "=?)", arrayOf( - accountName, calendarTitle, - calendarTitle - ), null - ) - if (cursor?.moveToFirst() == true) { - if (cursor.getString(cursor.getColumnIndex(CalendarContract.Calendars.NAME)) - .equals(calendarTitle) - ) { - calendarId = - cursor.getInt(cursor.getColumnIndex(CalendarContract.Calendars._ID)).toLong() - } - } - cursor?.close() - return calendarId - } - /** * Method to add important dates of course as calendar event into calendar of mobile app */ @@ -153,7 +129,7 @@ class CalendarManager( courseId: String, courseName: String, courseDateBlock: CourseDateBlock - ) { + ): Long { val date = courseDateBlock.date.toCalendar() // start time of the event, adjusted 1 hour earlier for a 1-hour duration val startMillis: Long = date.timeInMillis - TimeUnit.HOURS.toMillis(1) @@ -165,7 +141,7 @@ class CalendarManager( put(CalendarContract.Events.DTEND, endMillis) put( CalendarContract.Events.TITLE, - "${resourceManager.getString(R.string.course_assignment_due_tag)} : $courseName" + "${courseDateBlock.title} : $courseName" ) put( CalendarContract.Events.DESCRIPTION, @@ -180,6 +156,8 @@ class CalendarManager( } val uri = context.contentResolver.insert(CalendarContract.Events.CONTENT_URI, values) uri?.let { addReminderToEvent(uri = it) } + val eventId = uri?.lastPathSegment?.toLong() ?: EVENT_DOES_NOT_EXIST + return eventId } /** @@ -192,17 +170,16 @@ class CalendarManager( courseDateBlock: CourseDateBlock, isDeeplinkEnabled: Boolean ): String { - val eventDescription = courseDateBlock.title - // The following code for branch and deep links will be enabled after implementation - /* - if (isDeeplinkEnabled && !TextUtils.isEmpty(courseDateBlock.blockId)) { + var eventDescription = courseDateBlock.description + + if (isDeeplinkEnabled && courseDateBlock.blockId.isNotEmpty()) { val metaData = ContentMetadata() - .addCustomMetadata(DeepLink.Keys.SCREEN_NAME, Screen.COURSE_COMPONENT) - .addCustomMetadata(DeepLink.Keys.COURSE_ID, courseId) - .addCustomMetadata(DeepLink.Keys.COMPONENT_ID, courseDateBlock.blockId) + .addCustomMetadata("screen_name", "course_component") + .addCustomMetadata("course_id", courseId) + .addCustomMetadata("component_id", courseDateBlock.blockId) val branchUniversalObject = BranchUniversalObject() - .setCanonicalIdentifier("${Screen.COURSE_COMPONENT}\n${courseDateBlock.blockId}") + .setCanonicalIdentifier("course_component\n${courseDateBlock.blockId}") .setTitle(courseDateBlock.title) .setContentDescription(courseDateBlock.title) .setContentMetadata(metaData) @@ -210,9 +187,10 @@ class CalendarManager( val linkProperties = LinkProperties() .addControlParameter("\$desktop_url", courseDateBlock.link) - eventDescription += "\n" + branchUniversalObject.getShortUrl(context, linkProperties) + val shortUrl = branchUniversalObject.getShortUrl(context, linkProperties) + eventDescription += "\n$shortUrl" } - */ + return eventDescription } @@ -244,82 +222,6 @@ class CalendarManager( context.contentResolver.insert(CalendarContract.Reminders.CONTENT_URI, eventValues) } - /** - * Method to query the events for the given calendar id - * - * @param calendarId calendarId to query the events - * - * @return [Cursor] - * - * */ - private fun getCalendarEvents(calendarId: Long): Cursor? { - val calendarContentResolver = context.contentResolver - val projection = arrayOf( - CalendarContract.Events._ID, - CalendarContract.Events.DTEND, - CalendarContract.Events.DESCRIPTION - ) - val selection = CalendarContract.Events.CALENDAR_ID + "=?" - return calendarContentResolver.query( - CalendarContract.Events.CONTENT_URI, - projection, - selection, - arrayOf(calendarId.toString()), - null - ) - } - - /** - * Method to compare the calendar events with course dates - * @return true if the events are the same as calendar dates otherwise false - */ - @SuppressLint("Range") - private fun compareEvents( - calendarId: Long, - courseDateBlocks: List - ): Boolean { - val cursor = getCalendarEvents(calendarId) ?: return false - - val datesList = ArrayList(courseDateBlocks) - val dueDateColumnIndex = cursor.getColumnIndex(CalendarContract.Events.DTEND) - val descriptionColumnIndex = cursor.getColumnIndex(CalendarContract.Events.DESCRIPTION) - - while (cursor.moveToNext()) { - val dueDateInMillis = cursor.getLong(dueDateColumnIndex) - - val description = cursor.getString(descriptionColumnIndex) - if (description != null) { - val matchedDate = datesList.find { unit -> - description.contains(unit.title, ignoreCase = true) - } - - matchedDate?.let { unit -> - val dueDateCalendar = Calendar.getInstance().apply { - timeInMillis = dueDateInMillis - set(Calendar.SECOND, 0) - set(Calendar.MILLISECOND, 0) - } - - val unitDateCalendar = unit.date.toCalendar().apply { - set(Calendar.SECOND, 0) - set(Calendar.MILLISECOND, 0) - } - - if (dueDateCalendar == unitDateCalendar) { - datesList.remove(unit) - } else { - // If any single value isn't matched, return false - cursor.close() - return false - } - } - } - } - - cursor.close() - return datesList.isEmpty() - } - /** * Method to delete the course calendar from the mobile calendar app */ @@ -350,37 +252,6 @@ class CalendarManager( ).build() } - fun openCalendarApp() { - val builder: Uri.Builder = CalendarContract.CONTENT_URI.buildUpon() - .appendPath("time") - ContentUris.appendId(builder, Calendar.getInstance().timeInMillis) - val intent = Intent(Intent.ACTION_VIEW).setData(builder.build()) - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - context.startActivity(intent) - } - - /** - * Helper method used to check that the calendar if outdated for the course or not - * - * @param calendarTitle Title for the course Calendar - * @param courseDateBlocks Course dates events - * - * @return Calendar Id if Calendar is outdated otherwise -1 or CALENDAR_DOES_NOT_EXIST - * - */ - fun isCalendarOutOfDate( - calendarTitle: String, - courseDateBlocks: List - ): Long { - if (isCalendarExists(calendarTitle)) { - val calendarId = getCalendarId(calendarTitle) - if (compareEvents(calendarId, courseDateBlocks).not()) { - return calendarId - } - } - return CALENDAR_DOES_NOT_EXIST - } - /** * Method to get the current user account as the Calendar owner * @@ -390,19 +261,49 @@ class CalendarManager( return corePreferences.user?.email ?: LOCAL_USER } - /** - * Method to create the Calendar title for the platform against the course - * - * @param courseName Name of the course for that creating the Calendar events. - * - * @return title of the Calendar against the course - */ - fun getCourseCalendarTitle(courseName: String): String { - return "${resourceManager.getString(id = CoreR.string.platform_name)} - $courseName" + fun getCalendarData(calendarId: Long): CalendarData? { + val projection = arrayOf( + CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, + CalendarContract.Calendars.CALENDAR_COLOR + ) + val selection = "${CalendarContract.Calendars._ID} = ?" + val selectionArgs = arrayOf(calendarId.toString()) + + val cursor: Cursor? = context.contentResolver.query( + CalendarContract.Calendars.CONTENT_URI, + projection, + selection, + selectionArgs, + null + ) + + return cursor?.use { + if (it.moveToFirst()) { + val title = it.getString(it.getColumnIndexOrThrow(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME)) + val color = it.getInt(it.getColumnIndexOrThrow(CalendarContract.Calendars.CALENDAR_COLOR)) + CalendarData( + title = title, + color = color + ) + } else { + null + } + } + } + + fun deleteEvent(eventId: Long) { + val deleteUri = ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId) + val rows = context.contentResolver.delete(deleteUri, null, null) + if (rows > 0) { + logger.d { "Event deleted successfully" } + } else { + logger.d { "Event deletion failed" } + } } companion object { const val CALENDAR_DOES_NOT_EXIST = -1L + const val EVENT_DOES_NOT_EXIST = -1L private const val TAG = "CalendarManager" private const val LOCAL_USER = "local_user" } diff --git a/core/src/main/java/org/openedx/core/system/ResourceManager.kt b/core/src/main/java/org/openedx/core/system/ResourceManager.kt deleted file mode 100644 index 541eae56f..000000000 --- a/core/src/main/java/org/openedx/core/system/ResourceManager.kt +++ /dev/null @@ -1,43 +0,0 @@ -package org.openedx.core.system - -import android.content.Context -import android.graphics.Typeface -import android.graphics.drawable.Drawable -import androidx.annotation.* -import androidx.core.content.ContextCompat -import androidx.core.content.res.ResourcesCompat -import java.io.InputStream - -class ResourceManager(private val context: Context) { - - fun getString(@StringRes id: Int): String = context.getString(id) - - fun getString(@StringRes id: Int, vararg formatArgs: Any): String = - context.getString(id, *formatArgs) - - fun getStringArray(@ArrayRes id: Int): Array = context.resources.getStringArray(id) - - fun getIntArray(@ArrayRes id: Int): IntArray = context.resources.getIntArray(id) - - @ColorInt - fun getColor(@ColorRes id: Int): Int = context.getColor(id) - - fun getFont(@FontRes id: Int): Typeface? = ResourcesCompat.getFont(context, id) - - fun getRaw(@RawRes id: Int): InputStream { - return context.resources.openRawResource(id) - } - - fun getQuantityString(@PluralsRes id: Int, quantity: Int): String { - return context.resources.getQuantityString(id, quantity) - } - - fun getQuantityString(@PluralsRes id: Int, quantity: Int, vararg formatArgs: Any): String { - return context.resources.getQuantityString(id, quantity, *formatArgs) - } - - fun getDrawable(@DrawableRes id: Int): Drawable { - return ContextCompat.getDrawable(context, id)!! - } - -} \ No newline at end of file diff --git a/core/src/main/java/org/openedx/core/system/StorageManager.kt b/core/src/main/java/org/openedx/core/system/StorageManager.kt new file mode 100644 index 000000000..895072fb1 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/StorageManager.kt @@ -0,0 +1,21 @@ +package org.openedx.core.system + +import android.os.Environment +import android.os.StatFs + +object StorageManager { + + fun getTotalStorage(): Long { + val stat = StatFs(Environment.getDataDirectory().path) + val blockSize = stat.blockSizeLong + val totalBlocks = stat.blockCountLong + return totalBlocks * blockSize + } + + fun getFreeStorage(): Long { + val stat = StatFs(Environment.getDataDirectory().path) + val blockSize = stat.blockSizeLong + val availableBlocks = stat.availableBlocksLong + return availableBlocks * blockSize + } +} diff --git a/core/src/main/java/org/openedx/core/system/notifier/AppUpgradeNotifier.kt b/core/src/main/java/org/openedx/core/system/notifier/AppUpgradeNotifier.kt index 0f5a274d5..e69de29bb 100644 --- a/core/src/main/java/org/openedx/core/system/notifier/AppUpgradeNotifier.kt +++ b/core/src/main/java/org/openedx/core/system/notifier/AppUpgradeNotifier.kt @@ -1,15 +0,0 @@ -package org.openedx.core.system.notifier - -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.asSharedFlow - -class AppUpgradeNotifier { - - private val channel = MutableSharedFlow(replay = 0, extraBufferCapacity = 0) - - val notifier: Flow = channel.asSharedFlow() - - suspend fun send(event: AppUpgradeEvent) = channel.emit(event) - -} \ No newline at end of file diff --git a/core/src/main/java/org/openedx/core/system/notifier/CourseDataReady.kt b/core/src/main/java/org/openedx/core/system/notifier/CourseDataReady.kt deleted file mode 100644 index 0ad123d17..000000000 --- a/core/src/main/java/org/openedx/core/system/notifier/CourseDataReady.kt +++ /dev/null @@ -1,5 +0,0 @@ -package org.openedx.core.system.notifier - -import org.openedx.core.domain.model.CourseStructure - -data class CourseDataReady(val courseStructure: CourseStructure) : CourseEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/CourseNotifier.kt b/core/src/main/java/org/openedx/core/system/notifier/CourseNotifier.kt index 63660b4de..527a7ce51 100644 --- a/core/src/main/java/org/openedx/core/system/notifier/CourseNotifier.kt +++ b/core/src/main/java/org/openedx/core/system/notifier/CourseNotifier.kt @@ -18,6 +18,7 @@ class CourseNotifier { suspend fun send(event: CalendarSyncEvent) = channel.emit(event) suspend fun send(event: CourseDatesShifted) = channel.emit(event) suspend fun send(event: CourseLoading) = channel.emit(event) - suspend fun send(event: CourseDataReady) = channel.emit(event) - suspend fun send(event: CourseRefresh) = channel.emit(event) + suspend fun send(event: CourseOpenBlock) = channel.emit(event) + suspend fun send(event: RefreshDates) = channel.emit(event) + suspend fun send(event: RefreshDiscussions) = channel.emit(event) } diff --git a/core/src/main/java/org/openedx/core/system/notifier/CourseOpenBlock.kt b/core/src/main/java/org/openedx/core/system/notifier/CourseOpenBlock.kt new file mode 100644 index 000000000..6704f1256 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/CourseOpenBlock.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier + +data class CourseOpenBlock(val blockId: String) : CourseEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/CourseRefresh.kt b/core/src/main/java/org/openedx/core/system/notifier/CourseRefresh.kt deleted file mode 100644 index c85fc595d..000000000 --- a/core/src/main/java/org/openedx/core/system/notifier/CourseRefresh.kt +++ /dev/null @@ -1,5 +0,0 @@ -package org.openedx.core.system.notifier - -import org.openedx.core.presentation.course.CourseContainerTab - -data class CourseRefresh(val courseContainerTab: CourseContainerTab) : CourseEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/DownloadFailed.kt b/core/src/main/java/org/openedx/core/system/notifier/DownloadFailed.kt new file mode 100644 index 000000000..c5812f57f --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/DownloadFailed.kt @@ -0,0 +1,7 @@ +package org.openedx.core.system.notifier + +import org.openedx.core.module.db.DownloadModel + +data class DownloadFailed( + val downloadModel: List +) : DownloadEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/DownloadNotifier.kt b/core/src/main/java/org/openedx/core/system/notifier/DownloadNotifier.kt index eb16cf99f..9c0c698cf 100644 --- a/core/src/main/java/org/openedx/core/system/notifier/DownloadNotifier.kt +++ b/core/src/main/java/org/openedx/core/system/notifier/DownloadNotifier.kt @@ -11,5 +11,6 @@ class DownloadNotifier { val notifier: Flow = channel.asSharedFlow() suspend fun send(event: DownloadProgressChanged) = channel.emit(event) + suspend fun send(event: DownloadFailed) = channel.emit(event) } diff --git a/core/src/main/java/org/openedx/core/system/notifier/RefreshDates.kt b/core/src/main/java/org/openedx/core/system/notifier/RefreshDates.kt new file mode 100644 index 000000000..779d1b924 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/RefreshDates.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier + +object RefreshDates : CourseEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/RefreshDiscussions.kt b/core/src/main/java/org/openedx/core/system/notifier/RefreshDiscussions.kt new file mode 100644 index 000000000..5c51f605b --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/RefreshDiscussions.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier + +object RefreshDiscussions : CourseEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/app/AppEvent.kt b/core/src/main/java/org/openedx/core/system/notifier/app/AppEvent.kt new file mode 100644 index 000000000..7dd8f0407 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/app/AppEvent.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier.app + +interface AppEvent diff --git a/app/src/main/java/org/openedx/app/system/notifier/AppNotifier.kt b/core/src/main/java/org/openedx/core/system/notifier/app/AppNotifier.kt similarity index 67% rename from app/src/main/java/org/openedx/app/system/notifier/AppNotifier.kt rename to core/src/main/java/org/openedx/core/system/notifier/app/AppNotifier.kt index d0c579d8f..804d84a65 100644 --- a/app/src/main/java/org/openedx/app/system/notifier/AppNotifier.kt +++ b/core/src/main/java/org/openedx/core/system/notifier/app/AppNotifier.kt @@ -1,4 +1,4 @@ -package org.openedx.app.system.notifier +package org.openedx.core.system.notifier.app import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -10,6 +10,10 @@ class AppNotifier { val notifier: Flow = channel.asSharedFlow() + suspend fun send(event: SignInEvent) = channel.emit(event) + suspend fun send(event: LogoutEvent) = channel.emit(event) -} \ No newline at end of file + suspend fun send(event: AppUpgradeEvent) = channel.emit(event) + +} diff --git a/core/src/main/java/org/openedx/core/system/notifier/AppUpgradeEvent.kt b/core/src/main/java/org/openedx/core/system/notifier/app/AppUpgradeEvent.kt similarity index 61% rename from core/src/main/java/org/openedx/core/system/notifier/AppUpgradeEvent.kt rename to core/src/main/java/org/openedx/core/system/notifier/app/AppUpgradeEvent.kt index f99086a11..81dba6177 100644 --- a/core/src/main/java/org/openedx/core/system/notifier/AppUpgradeEvent.kt +++ b/core/src/main/java/org/openedx/core/system/notifier/app/AppUpgradeEvent.kt @@ -1,6 +1,6 @@ -package org.openedx.core.system.notifier +package org.openedx.core.system.notifier.app -sealed class AppUpgradeEvent { +sealed class AppUpgradeEvent: AppEvent { object UpgradeRequiredEvent : AppUpgradeEvent() class UpgradeRecommendedEvent(val newVersionName: String) : AppUpgradeEvent() } diff --git a/core/src/main/java/org/openedx/core/system/notifier/app/LogoutEvent.kt b/core/src/main/java/org/openedx/core/system/notifier/app/LogoutEvent.kt new file mode 100644 index 000000000..12154f3f1 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/app/LogoutEvent.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier.app + +class LogoutEvent(val isForced: Boolean) : AppEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/app/SignInEvent.kt b/core/src/main/java/org/openedx/core/system/notifier/app/SignInEvent.kt new file mode 100644 index 000000000..340d04476 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/app/SignInEvent.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier.app + +class SignInEvent : AppEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarCreated.kt b/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarCreated.kt new file mode 100644 index 000000000..028b0d3e3 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarCreated.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier.calendar + +object CalendarCreated : CalendarEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarEvent.kt b/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarEvent.kt new file mode 100644 index 000000000..1bdf92dca --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarEvent.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier.calendar + +interface CalendarEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarNotifier.kt b/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarNotifier.kt new file mode 100644 index 000000000..b0baa674b --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarNotifier.kt @@ -0,0 +1,14 @@ +package org.openedx.core.system.notifier.calendar + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +class CalendarNotifier { + + private val channel = MutableSharedFlow(replay = 0, extraBufferCapacity = 0) + + val notifier: Flow = channel.asSharedFlow() + + suspend fun send(event: CalendarEvent) = channel.emit(event) +} diff --git a/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarSyncDisabled.kt b/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarSyncDisabled.kt new file mode 100644 index 000000000..ec9d61e84 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarSyncDisabled.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier.calendar + +object CalendarSyncDisabled : CalendarEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarSyncFailed.kt b/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarSyncFailed.kt new file mode 100644 index 000000000..af7f507ea --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarSyncFailed.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier.calendar + +object CalendarSyncFailed : CalendarEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarSyncOffline.kt b/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarSyncOffline.kt new file mode 100644 index 000000000..ac78a4a4c --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarSyncOffline.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier.calendar + +object CalendarSyncOffline : CalendarEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarSynced.kt b/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarSynced.kt new file mode 100644 index 000000000..71bfed3ef --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarSynced.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier.calendar + +object CalendarSynced : CalendarEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarSyncing.kt b/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarSyncing.kt new file mode 100644 index 000000000..edfe066a9 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarSyncing.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier.calendar + +object CalendarSyncing : CalendarEvent diff --git a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt index 3b97742f1..23f0d3315 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -28,14 +29,18 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.text.ClickableText +import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults +import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Divider import androidx.compose.material.Icon import androidx.compose.material.IconButton @@ -48,6 +53,8 @@ import androidx.compose.material.TextFieldDefaults import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.ManageAccounts import androidx.compose.material.icons.filled.Search import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -59,7 +66,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithContent @@ -72,6 +78,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager @@ -96,21 +103,24 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex import coil.ImageLoader import coil.compose.AsyncImage import coil.decode.GifDecoder import coil.decode.ImageDecoderDecoder import kotlinx.coroutines.launch +import org.openedx.core.NoContentScreenType import org.openedx.core.R -import org.openedx.core.UIMessage import org.openedx.core.domain.model.RegistrationField import org.openedx.core.extension.LinkedImageText -import org.openedx.core.extension.tagId -import org.openedx.core.extension.toastMessage +import org.openedx.core.presentation.global.ErrorType import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography +import org.openedx.foundation.extension.tagId +import org.openedx.foundation.extension.toastMessage +import org.openedx.foundation.presentation.UIMessage @Composable fun StaticSearchBar( @@ -199,8 +209,8 @@ fun Toolbar( onClick = { onSettingsClick() } ) { Icon( - painter = painterResource(id = R.drawable.core_ic_settings), - tint = MaterialTheme.appColors.primary, + imageVector = Icons.Default.ManageAccounts, + tint = MaterialTheme.appColors.textAccent, contentDescription = stringResource(id = R.string.core_accessibility_settings) ) } @@ -208,7 +218,6 @@ fun Toolbar( } } -@OptIn(ExperimentalComposeUiApi::class) @Composable fun SearchBar( modifier: Modifier, @@ -308,7 +317,6 @@ fun SearchBar( ) } -@OptIn(ExperimentalComposeUiApi::class) @Composable fun SearchBarStateless( modifier: Modifier, @@ -434,7 +442,7 @@ fun HyperlinkText( append(fullText) addStyle( style = SpanStyle( - color = MaterialTheme.appColors.textPrimary, + color = MaterialTheme.appColors.textPrimaryLight, fontSize = fontSize ), start = 0, @@ -450,7 +458,7 @@ fun HyperlinkText( color = linkTextColor, fontSize = fontSize, fontWeight = linkTextFontWeight, - textDecoration = linkTextDecoration + textDecoration = linkTextDecoration, ), start = startIndex, end = endIndex @@ -473,18 +481,19 @@ fun HyperlinkText( val uriHandler = LocalUriHandler.current - ClickableText( - modifier = modifier, + BasicText( text = annotatedString, - style = textStyle, - onClick = { - annotatedString - .getStringAnnotations("URL", it, it) - .firstOrNull()?.let { stringAnnotation -> - action?.invoke(stringAnnotation.item) - ?: uriHandler.openUri(stringAnnotation.item) - } - } + modifier = modifier.pointerInput(Unit) { + detectTapGestures { offset -> + val position = offset.x.toInt() + annotatedString.getStringAnnotations("URL", position, position) + .firstOrNull()?.let { stringAnnotation -> + action?.invoke(stringAnnotation.item) + ?: uriHandler.openUri(stringAnnotation.item) + } + } + }, + style = textStyle ) } @@ -584,17 +593,18 @@ fun HyperlinkImageText( .build() Column(Modifier.fillMaxWidth()) { - ClickableText( - modifier = modifier, + BasicText( text = annotatedString, - style = textStyle, - onClick = { - annotatedString - .getStringAnnotations("URL", it, it) - .firstOrNull()?.let { stringAnnotation -> - uriHandler.openUri(stringAnnotation.item) - } - } + modifier = modifier.pointerInput(Unit) { + detectTapGestures { offset -> + val position = offset.x.toInt() + annotatedString.getStringAnnotations("URL", position, position) + .firstOrNull()?.let { stringAnnotation -> + uriHandler.openUri(stringAnnotation.item) + } + } + }, + style = textStyle ) imageText.imageLinks.values.forEach { Spacer(Modifier.height(8.dp)) @@ -635,7 +645,8 @@ fun SheetContent( .padding(10.dp), textAlign = TextAlign.Center, style = MaterialTheme.appTypography.titleMedium, - text = title + text = title, + color = MaterialTheme.appColors.onBackground ) SearchBarStateless( modifier = Modifier @@ -667,6 +678,7 @@ fun SheetContent( onItemClick(item) } .padding(vertical = 12.dp), + color = MaterialTheme.appColors.onBackground, text = item.name, style = MaterialTheme.appTypography.bodyLarge, textAlign = TextAlign.Center @@ -829,6 +841,7 @@ fun AutoSizeText( style: TextStyle, color: Color = Color.Unspecified, maxLines: Int = Int.MAX_VALUE, + minSize: Float = 0f ) { var scaledTextStyle by remember { mutableStateOf(style) } var readyToDraw by remember { mutableStateOf(false) } @@ -845,9 +858,8 @@ fun AutoSizeText( softWrap = false, maxLines = maxLines, onTextLayout = { textLayoutResult -> - if (textLayoutResult.didOverflowWidth) { - scaledTextStyle = - scaledTextStyle.copy(fontSize = scaledTextStyle.fontSize * 0.9) + if (textLayoutResult.didOverflowWidth && scaledTextStyle.fontSize.value > minSize) { + scaledTextStyle = scaledTextStyle.copy(fontSize = scaledTextStyle.fontSize * 0.9) } else { readyToDraw = true } @@ -933,25 +945,27 @@ fun IconText( @Composable fun TextIcon( + modifier: Modifier = Modifier, text: String, icon: ImageVector, color: Color, textStyle: TextStyle = MaterialTheme.appTypography.bodySmall, + iconModifier: Modifier? = null, onClick: (() -> Unit)? = null, ) { - val modifier = if (onClick == null) { - Modifier + val rowModifier = if (onClick == null) { + modifier } else { - Modifier.noRippleClickable { onClick.invoke() } + modifier.clickable { onClick.invoke() } } Row( - modifier = modifier, + modifier = rowModifier, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) ) { Text(text = text, color = color, style = textStyle) Icon( - modifier = Modifier.size((textStyle.fontSize.value + 4).dp), + modifier = iconModifier ?: Modifier.size((textStyle.fontSize.value + 4).dp), imageVector = icon, contentDescription = null, tint = color @@ -961,11 +975,11 @@ fun TextIcon( @Composable fun TextIcon( + iconModifier: Modifier = Modifier, text: String, painter: Painter, color: Color, textStyle: TextStyle = MaterialTheme.appTypography.bodySmall, - iconModifier: Modifier = Modifier, onClick: (() -> Unit)? = null, ) { val modifier = if (onClick == null) { @@ -1049,8 +1063,9 @@ fun OpenEdXButton( text: String = "", onClick: () -> Unit, enabled: Boolean = true, - backgroundColor: Color = MaterialTheme.appColors.buttonBackground, - content: (@Composable RowScope.() -> Unit)? = null, + textColor: Color = MaterialTheme.appColors.primaryButtonText, + backgroundColor: Color = MaterialTheme.appColors.primaryButtonBackground, + content: (@Composable RowScope.() -> Unit)? = null ) { Button( modifier = Modifier @@ -1068,7 +1083,7 @@ fun OpenEdXButton( Text( modifier = Modifier.testTag("txt_${text.tagId()}"), text = text, - color = MaterialTheme.appColors.buttonText, + color = textColor, style = MaterialTheme.appTypography.labelLarge ) } else { @@ -1084,6 +1099,7 @@ fun OpenEdXOutlinedButton( borderColor: Color, textColor: Color, text: String = "", + enabled: Boolean = true, onClick: () -> Unit, content: (@Composable RowScope.() -> Unit)? = null, ) { @@ -1093,6 +1109,7 @@ fun OpenEdXOutlinedButton( .then(modifier) .height(42.dp), onClick = onClick, + enabled = enabled, border = BorderStroke(1.dp, borderColor), shape = MaterialTheme.appShapes.buttonShape, colors = ButtonDefaults.outlinedButtonColors(backgroundColor = backgroundColor) @@ -1127,25 +1144,33 @@ fun BackBtn( } @Composable -fun ConnectionErrorView( - modifier: Modifier, - onReloadClick: () -> Unit, +fun ConnectionErrorView(onReloadClick: () -> Unit) { + FullScreenErrorView(errorType = ErrorType.CONNECTION_ERROR, onReloadClick = onReloadClick) +} + +@Composable +fun FullScreenErrorView( + modifier: Modifier = Modifier, + errorType: ErrorType, + onReloadClick: () -> Unit ) { Column( - modifier = modifier, + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.appColors.background), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { Icon( modifier = Modifier.size(100.dp), - painter = painterResource(id = R.drawable.core_no_internet_connection), + painter = painterResource(id = errorType.iconResId), contentDescription = null, tint = MaterialTheme.appColors.onSurface ) Spacer(Modifier.height(28.dp)) Text( modifier = Modifier.fillMaxWidth(0.8f), - text = stringResource(id = R.string.core_no_internet_connection), + text = stringResource(id = errorType.titleResId), color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.titleLarge, textAlign = TextAlign.Center @@ -1153,7 +1178,7 @@ fun ConnectionErrorView( Spacer(Modifier.height(16.dp)) Text( modifier = Modifier.fillMaxWidth(0.8f), - text = stringResource(id = R.string.core_no_internet_connection_description), + text = stringResource(id = errorType.descriptionResId), color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.bodyLarge, textAlign = TextAlign.Center @@ -1162,8 +1187,45 @@ fun ConnectionErrorView( OpenEdXButton( modifier = Modifier .widthIn(Dp.Unspecified, 162.dp), - text = stringResource(id = R.string.core_reload), - onClick = onReloadClick + text = stringResource(id = errorType.actionResId), + textColor = MaterialTheme.appColors.primaryButtonText, + backgroundColor = MaterialTheme.appColors.secondaryButtonBackground, + onClick = onReloadClick, + ) + } +} + +@Composable +fun NoContentScreen(noContentScreenType: NoContentScreenType) { + NoContentScreen( + message = stringResource(id = noContentScreenType.messageResId), + icon = painterResource(id = noContentScreenType.iconResId) + ) +} + +@Composable +fun NoContentScreen(message: String, icon: Painter) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + modifier = Modifier.size(80.dp), + painter = icon, + contentDescription = null, + tint = MaterialTheme.appColors.progressBarBackgroundColor, + ) + Spacer(Modifier.height(24.dp)) + Text( + modifier = Modifier.fillMaxWidth(0.8f), + text = message, + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.bodyMedium, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center ) } } @@ -1172,26 +1234,37 @@ fun ConnectionErrorView( fun AuthButtonsPanel( onRegisterClick: () -> Unit, onSignInClick: () -> Unit, + showRegisterButton: Boolean, ) { Row { - OpenEdXButton( - modifier = Modifier - .testTag("btn_register") - .width(0.dp) - .weight(1f), - text = stringResource(id = R.string.core_register), - onClick = { onRegisterClick() } - ) + if (showRegisterButton) { + OpenEdXButton( + modifier = Modifier + .testTag("btn_register") + .width(0.dp) + .weight(1f), + text = stringResource(id = R.string.core_register), + textColor = MaterialTheme.appColors.primaryButtonText, + backgroundColor = MaterialTheme.appColors.secondaryButtonBackground, + onClick = { onRegisterClick() } + ) + } OpenEdXOutlinedButton( modifier = Modifier .testTag("btn_sign_in") - .width(100.dp) - .padding(start = 16.dp), + .then( + if (showRegisterButton) { + Modifier.width(100.dp).padding(start = 16.dp) + } else { + Modifier.weight(1f) + } + ), text = stringResource(id = R.string.core_sign_in), onClick = { onSignInClick() }, - borderColor = MaterialTheme.appColors.textFieldBorder, - textColor = MaterialTheme.appColors.primary + textColor = MaterialTheme.appColors.secondaryButtonBorderedText, + backgroundColor = MaterialTheme.appColors.secondaryButtonBorderedBackground, + borderColor = MaterialTheme.appColors.secondaryButtonBorder, ) } } @@ -1202,17 +1275,22 @@ fun RoundTabsBar( modifier: Modifier = Modifier, items: List, pagerState: PagerState, + contentPadding: PaddingValues = PaddingValues(), + withPager: Boolean = false, rowState: LazyListState = rememberLazyListState(), - onPageChange: (Int) -> Unit + onTabClicked: (Int) -> Unit = { } ) { + // The pager state does not work without the pager and the tabs do not change. + if (!withPager) { + HorizontalPager(state = pagerState) { } + } + val scope = rememberCoroutineScope() - val windowSize = rememberWindowSize() - val horizontalPadding = if (!windowSize.isTablet) 12.dp else 98.dp LazyRow( modifier = modifier, state = rowState, horizontalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(vertical = 16.dp, horizontal = horizontalPadding), + contentPadding = contentPadding, ) { itemsIndexed(items) { index, item -> val isSelected = pagerState.currentPage == index @@ -1234,11 +1312,12 @@ fun RoundTabsBar( .then(border) .clickable { scope.launch { + onTabClicked(index) pagerState.scrollToPage(index) - onPageChange(index) + rowState.animateScrollToItem(index) } } - .padding(horizontal = 12.dp), + .padding(horizontal = 16.dp), item = item, contentColor = contentColor ) @@ -1246,6 +1325,19 @@ fun RoundTabsBar( } } +@Composable +fun CircularProgress() { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.appColors.background) + .zIndex(1f), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } +} + @Composable private fun RoundTab( modifier: Modifier = Modifier, @@ -1257,12 +1349,15 @@ private fun RoundTab( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center ) { - Icon( - painter = rememberVectorPainter(item.icon), - tint = contentColor, - contentDescription = null - ) - Spacer(modifier = Modifier.width(4.dp)) + val icon = item.icon + if (icon != null) { + Icon( + painter = rememberVectorPainter(icon), + tint = contentColor, + contentDescription = null + ) + Spacer(modifier = Modifier.width(4.dp)) + } Text( text = stringResource(item.labelResId), color = contentColor @@ -1310,7 +1405,7 @@ private fun ToolbarPreview() { @Preview @Composable private fun AuthButtonsPanelPreview() { - AuthButtonsPanel(onRegisterClick = {}, onSignInClick = {}) + AuthButtonsPanel(onRegisterClick = {}, onSignInClick = {}, showRegisterButton = true) } @Preview @@ -1341,11 +1436,7 @@ private fun IconTextPreview() { @Composable private fun ConnectionErrorViewPreview() { OpenEdXTheme(darkTheme = true) { - ConnectionErrorView( - modifier = Modifier - .fillMaxSize(), - onReloadClick = {} - ) + ConnectionErrorView(onReloadClick = {}) } } @@ -1363,7 +1454,18 @@ private fun RoundTabsBarPreview() { items = listOf(mockTab, mockTab, mockTab), rowState = rememberLazyListState(), pagerState = rememberPagerState(pageCount = { 3 }), - onPageChange = { } + onTabClicked = { } + ) + } +} + +@Preview +@Composable +private fun PreviewNoContentScreen() { + OpenEdXTheme(darkTheme = true) { + NoContentScreen( + "No Content available", + rememberVectorPainter(image = Icons.Filled.Info) ) } } diff --git a/core/src/main/java/org/openedx/core/ui/ComposeExtensions.kt b/core/src/main/java/org/openedx/core/ui/ComposeExtensions.kt index 6cf198f53..5165619b6 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeExtensions.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeExtensions.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.foundation.pager.PagerState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -30,6 +31,7 @@ import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.layout import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity @@ -37,6 +39,7 @@ import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.launch @@ -75,6 +78,16 @@ fun LazyListState.shouldLoadMore(rememberedIndex: MutableState, threshold: return false } +fun LazyGridState.shouldLoadMore(rememberedIndex: MutableState, threshold: Int): Boolean { + val firstVisibleIndex = this.firstVisibleItemIndex + if (rememberedIndex.value != firstVisibleIndex) { + rememberedIndex.value = firstVisibleIndex + val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + return lastVisibleIndex >= layoutInfo.totalItemsCount - 1 - threshold + } + return false +} + fun Modifier.statusBarsInset(): Modifier = composed { val topInset = (LocalContext.current as? InsetHolder)?.topInset ?: 0 return@composed this @@ -192,4 +205,19 @@ fun Modifier.settingsHeaderBackground(): Modifier = composed { contentScale = ContentScale.FillWidth, alignment = Alignment.TopCenter ) -} \ No newline at end of file +} + +fun Modifier.crop( + horizontal: Dp = 0.dp, + vertical: Dp = 0.dp, +): Modifier = this.layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + fun Dp.toPxInt(): Int = this.toPx().toInt() + + layout( + placeable.width - (horizontal * 2).toPxInt(), + placeable.height - (vertical * 2).toPxInt() + ) { + placeable.placeRelative(-horizontal.toPx().toInt(), -vertical.toPx().toInt()) + } +} diff --git a/core/src/main/java/org/openedx/core/ui/TabItem.kt b/core/src/main/java/org/openedx/core/ui/TabItem.kt index 65a88861e..d6952c010 100644 --- a/core/src/main/java/org/openedx/core/ui/TabItem.kt +++ b/core/src/main/java/org/openedx/core/ui/TabItem.kt @@ -6,5 +6,5 @@ import androidx.compose.ui.graphics.vector.ImageVector interface TabItem { @get:StringRes val labelResId: Int - val icon: ImageVector + val icon: ImageVector? } diff --git a/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt b/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt index c9c7c4ba1..807acd918 100644 --- a/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt +++ b/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt @@ -6,7 +6,6 @@ import android.net.Uri import android.webkit.WebResourceRequest import android.webkit.WebView import android.webkit.WebViewClient -import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -14,7 +13,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn -import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold import androidx.compose.material.Surface @@ -37,10 +35,13 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.zIndex -import org.openedx.core.extension.isEmailValid -import org.openedx.core.extension.replaceLinkTags import org.openedx.core.ui.theme.appColors import org.openedx.core.utils.EmailUtil +import org.openedx.foundation.extension.applyDarkModeIfEnabled +import org.openedx.foundation.extension.isEmailValid +import org.openedx.foundation.extension.replaceLinkTags +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.windowSizeValue import java.nio.charset.StandardCharsets @OptIn(ExperimentalComposeUiApi::class) @@ -100,15 +101,7 @@ fun WebContentScreen( color = MaterialTheme.appColors.background ) { if (htmlBody.isNullOrEmpty() && contentUrl.isNullOrEmpty()) { - Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.appColors.background) - .zIndex(1f), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator(color = MaterialTheme.appColors.primary) - } + CircularProgress() } else { var webViewAlpha by rememberSaveable { mutableFloatStateOf(0f) } Surface( @@ -195,6 +188,7 @@ private fun WebViewContent( contentUrl?.let { loadUrl(it) } + applyDarkModeIfEnabled(isDarkTheme) } }, update = { webView -> diff --git a/core/src/main/java/org/openedx/core/ui/WindowSize.kt b/core/src/main/java/org/openedx/core/ui/WindowSize.kt deleted file mode 100644 index 735dfc209..000000000 --- a/core/src/main/java/org/openedx/core/ui/WindowSize.kt +++ /dev/null @@ -1,55 +0,0 @@ -package org.openedx.core.ui - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalConfiguration - -data class WindowSize( - val width: WindowType, - val height: WindowType -) { - val isTablet: Boolean - get() = height != WindowType.Compact && width != WindowType.Compact -} - -fun WindowSize.windowSizeValue(expanded: T, compact: T): T { - return if (height != WindowType.Compact && width != WindowType.Compact) { - expanded - } else { - compact - } -} - -enum class WindowType { - Compact, Medium, Expanded -} - -@Composable -fun rememberWindowSize(): WindowSize { - val configuration = LocalConfiguration.current - val screenWidth by remember(key1 = configuration) { - mutableStateOf(configuration.screenWidthDp) - } - val screenHeight by remember(key1 = configuration) { - mutableStateOf(configuration.screenHeightDp) - } - - return WindowSize( - width = getScreenWidth(screenWidth), - height = getScreenHeight(screenHeight) - ) -} - -fun getScreenWidth(width: Int): WindowType = when { - width < 600 -> WindowType.Compact - width < 840 -> WindowType.Medium - else -> WindowType.Expanded -} - -fun getScreenHeight(height: Int): WindowType = when { - height < 480 -> WindowType.Compact - height < 900 -> WindowType.Medium - else -> WindowType.Expanded -} \ No newline at end of file diff --git a/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt b/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt index 4b7a0ba10..37783b820 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt @@ -8,6 +8,8 @@ data class AppColors( val textPrimary: Color, val textPrimaryVariant: Color, + val textPrimaryLight: Color, + val textHyperLink: Color, val textSecondary: Color, val textDark: Color, val textAccent: Color, @@ -19,9 +21,18 @@ data class AppColors( val textFieldText: Color, val textFieldHint: Color, - val buttonBackground: Color, - val buttonSecondaryBackground: Color, - val buttonText: Color, + val primaryButtonBackground: Color, + val primaryButtonText: Color, + val primaryButtonBorder: Color, + val primaryButtonBorderedText: Color, + + // The default secondary button styling is identical to the primary button styling. + // However, you can customize it if your brand utilizes two accent colors. + val secondaryButtonBackground: Color, + val secondaryButtonText: Color, + val secondaryButtonBorder: Color, + val secondaryButtonBorderedBackground: Color, + val secondaryButtonBorderedText: Color, val cardViewBackground: Color, val cardViewBorder: Color, @@ -31,12 +42,16 @@ data class AppColors( val bottomSheetToggle: Color, val warning: Color, val info: Color, + val info_variant: Color, + val onWarning: Color, + val onInfo: Color, val rateStars: Color, val inactiveButtonBackground: Color, val inactiveButtonText: Color, - val accessGreen: Color, + val successGreen: Color, + val successBackground: Color, val datesSectionBarPastDue: Color, val datesSectionBarToday: Color, @@ -44,6 +59,8 @@ data class AppColors( val datesSectionBarNextWeek: Color, val datesSectionBarUpcoming: Color, + val authSSOSuccessBackground: Color, + val authGoogleButtonBackground: Color, val authFacebookButtonBackground: Color, val authMicrosoftButtonBackground: Color, @@ -58,7 +75,10 @@ data class AppColors( val courseHomeHeaderShade: Color, val courseHomeBackBtnBackground: Color, - val settingsTitleContent: Color + val settingsTitleContent: Color, + + val progressBarColor: Color, + val progressBarBackgroundColor: Color ) { val primary: Color get() = material.primary val primaryVariant: Color get() = material.primaryVariant diff --git a/core/src/main/java/org/openedx/core/ui/theme/Theme.kt b/core/src/main/java/org/openedx/core/ui/theme/Theme.kt index 1ffa3c73d..8fe1eb8ff 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/Theme.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/Theme.kt @@ -27,10 +27,12 @@ private val DarkColorPalette = AppColors( ), textPrimary = dark_text_primary, textPrimaryVariant = dark_text_primary_variant, + textPrimaryLight = dark_text_primary_light, textSecondary = dark_text_secondary, textDark = dark_text_dark, textAccent = dark_text_accent, textWarning = dark_text_warning, + textHyperLink = dark_text_hyper_link, textFieldBackground = dark_text_field_background, textFieldBackgroundVariant = dark_text_field_background_variant, @@ -38,9 +40,16 @@ private val DarkColorPalette = AppColors( textFieldText = dark_text_field_text, textFieldHint = dark_text_field_hint, - buttonBackground = dark_button_background, - buttonSecondaryBackground = dark_button_secondary_background, - buttonText = dark_button_text, + primaryButtonBackground = dark_primary_button_background, + primaryButtonText = dark_primary_button_text, + primaryButtonBorder = dark_primary_button_border, + primaryButtonBorderedText = dark_primary_button_bordered_text, + + secondaryButtonBackground = dark_secondary_button_background, + secondaryButtonText = dark_secondary_button_text, + secondaryButtonBorder = dark_secondary_button_border, + secondaryButtonBorderedBackground = dark_secondary_button_bordered_background, + secondaryButtonBorderedText = dark_secondary_button_bordered_text, cardViewBackground = dark_card_view_background, cardViewBorder = dark_card_view_border, @@ -51,12 +60,16 @@ private val DarkColorPalette = AppColors( warning = dark_warning, info = dark_info, + info_variant = dark_info_variant, + onWarning = dark_onWarning, + onInfo = dark_onInfo, rateStars = dark_rate_stars, inactiveButtonBackground = dark_inactive_button_background, - inactiveButtonText = dark_button_text, + inactiveButtonText = dark_primary_button_text, - accessGreen = dark_access_green, + successGreen = dark_success_green, + successBackground = dark_success_background, datesSectionBarPastDue = dark_dates_section_bar_past_due, datesSectionBarToday = dark_dates_section_bar_today, @@ -64,6 +77,8 @@ private val DarkColorPalette = AppColors( datesSectionBarNextWeek = dark_dates_section_bar_next_week, datesSectionBarUpcoming = dark_dates_section_bar_upcoming, + authSSOSuccessBackground = dark_auth_sso_success_background, + authGoogleButtonBackground = dark_auth_google_button_background, authFacebookButtonBackground = dark_auth_facebook_button_background, authMicrosoftButtonBackground = dark_auth_microsoft_button_background, @@ -78,7 +93,10 @@ private val DarkColorPalette = AppColors( courseHomeHeaderShade = dark_course_home_header_shade, courseHomeBackBtnBackground = dark_course_home_back_btn_background, - settingsTitleContent = dark_settings_title_content + settingsTitleContent = dark_settings_title_content, + + progressBarColor = dark_progress_bar_color, + progressBarBackgroundColor = dark_progress_bar_background_color ) private val LightColorPalette = AppColors( @@ -98,10 +116,12 @@ private val LightColorPalette = AppColors( ), textPrimary = light_text_primary, textPrimaryVariant = light_text_primary_variant, + textPrimaryLight = light_text_primary_light, textSecondary = light_text_secondary, textDark = light_text_dark, textAccent = light_text_accent, textWarning = light_text_warning, + textHyperLink = light_text_hyper_link, textFieldBackground = light_text_field_background, textFieldBackgroundVariant = light_text_field_background_variant, @@ -109,9 +129,16 @@ private val LightColorPalette = AppColors( textFieldText = light_text_field_text, textFieldHint = light_text_field_hint, - buttonBackground = light_button_background, - buttonSecondaryBackground = light_button_secondary_background, - buttonText = light_button_text, + primaryButtonBackground = light_primary_button_background, + primaryButtonText = light_primary_button_text, + primaryButtonBorder = light_primary_button_border, + primaryButtonBorderedText = light_primary_button_bordered_text, + + secondaryButtonBackground = light_secondary_button_background, + secondaryButtonText = light_secondary_button_text, + secondaryButtonBorder = light_secondary_button_border, + secondaryButtonBorderedBackground = light_secondary_button_bordered_background, + secondaryButtonBorderedText = light_secondary_button_bordered_text, cardViewBackground = light_card_view_background, cardViewBorder = light_card_view_border, @@ -122,12 +149,16 @@ private val LightColorPalette = AppColors( warning = light_warning, info = light_info, + info_variant = light_info_variant, + onWarning = light_onWarning, + onInfo = light_onInfo, rateStars = light_rate_stars, inactiveButtonBackground = light_inactive_button_background, - inactiveButtonText = light_button_text, + inactiveButtonText = light_primary_button_text, - accessGreen = light_access_green, + successGreen = light_success_green, + successBackground = light_success_background, datesSectionBarPastDue = light_dates_section_bar_past_due, datesSectionBarToday = light_dates_section_bar_today, @@ -135,6 +166,8 @@ private val LightColorPalette = AppColors( datesSectionBarNextWeek = light_dates_section_bar_next_week, datesSectionBarUpcoming = light_dates_section_bar_upcoming, + authSSOSuccessBackground = light_auth_sso_success_background, + authGoogleButtonBackground = light_auth_google_button_background, authFacebookButtonBackground = light_auth_facebook_button_background, authMicrosoftButtonBackground = light_auth_microsoft_button_background, @@ -149,7 +182,10 @@ private val LightColorPalette = AppColors( courseHomeHeaderShade = light_course_home_header_shade, courseHomeBackBtnBackground = light_course_home_back_btn_background, - settingsTitleContent = light_settings_title_content + settingsTitleContent = light_settings_title_content, + + progressBarColor = light_progress_bar_color, + progressBarBackgroundColor = light_progress_bar_background_color ) val MaterialTheme.appColors: AppColors diff --git a/core/src/main/java/org/openedx/core/ui/theme/Type.kt b/core/src/main/java/org/openedx/core/ui/theme/Type.kt index edd2afcc7..52d9adebb 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/Type.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/Type.kt @@ -17,6 +17,7 @@ data class AppTypography( val displayLarge: TextStyle, val displayMedium: TextStyle, val displaySmall: TextStyle, + val headlineBold: TextStyle, val headlineLarge: TextStyle, val headlineMedium: TextStyle, val headlineSmall: TextStyle, @@ -34,7 +35,6 @@ data class AppTypography( val fontFamily = FontFamily( Font(R.font.regular, FontWeight.Black, FontStyle.Normal), Font(R.font.bold, FontWeight.Bold, FontStyle.Normal), - Font(R.font.bold, FontWeight.Bold, FontStyle.Normal), Font(R.font.extra_light, FontWeight.Light, FontStyle.Normal), Font(R.font.light, FontWeight.Light, FontStyle.Normal), Font(R.font.medium, FontWeight.Medium, FontStyle.Normal), @@ -43,7 +43,6 @@ val fontFamily = FontFamily( Font(R.font.thin, FontWeight.Thin, FontStyle.Normal), ) - internal val LocalTypography = staticCompositionLocalOf { AppTypography( displayLarge = TextStyle( @@ -74,6 +73,13 @@ internal val LocalTypography = staticCompositionLocalOf { letterSpacing = 0.sp, fontFamily = fontFamily ), + headlineBold = TextStyle( + fontSize = 34.sp, + lineHeight = 24.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.sp, + fontFamily = fontFamily + ), headlineMedium = TextStyle( fontSize = 28.sp, lineHeight = 36.sp, diff --git a/core/src/main/java/org/openedx/core/utils/FileUtil.kt b/core/src/main/java/org/openedx/core/utils/FileUtil.kt index 001d03f4f..5f890e690 100644 --- a/core/src/main/java/org/openedx/core/utils/FileUtil.kt +++ b/core/src/main/java/org/openedx/core/utils/FileUtil.kt @@ -1,19 +1,28 @@ package org.openedx.core.utils -import android.content.Context +import net.lingala.zip4j.ZipFile +import net.lingala.zip4j.exception.ZipException +import org.openedx.foundation.utils.FileUtil import java.io.File -object FileUtil { - - fun getExternalAppDir(context: Context): File { - val dir = context.externalCacheDir.toString() + File.separator + - context.getString(org.openedx.core.R.string.app_name).replace(Regex("\\s"), "_") - val file = File(dir) - file.mkdirs() - return file +fun FileUtil.unzipFile(filepath: String): String? { + val archive = File(filepath) + val destinationFolder = File( + archive.parentFile.absolutePath + "/" + archive.name + "-unzipped" + ) + try { + if (!destinationFolder.exists()) { + destinationFolder.mkdirs() + } + val zip = ZipFile(archive) + zip.extractAll(destinationFolder.absolutePath) + deleteFile(archive.absolutePath) + return destinationFolder.absolutePath + } catch (e: ZipException) { + e.printStackTrace() + deleteFile(destinationFolder.absolutePath) } - - + return null } enum class Directories { diff --git a/core/src/main/java/org/openedx/core/utils/TimeUtils.kt b/core/src/main/java/org/openedx/core/utils/TimeUtils.kt index d77a1ab5e..a2fb3cfc7 100644 --- a/core/src/main/java/org/openedx/core/utils/TimeUtils.kt +++ b/core/src/main/java/org/openedx/core/utils/TimeUtils.kt @@ -5,7 +5,8 @@ import android.text.format.DateUtils import com.google.gson.internal.bind.util.ISO8601Utils import org.openedx.core.R import org.openedx.core.domain.model.StartType -import org.openedx.core.system.ResourceManager +import org.openedx.foundation.system.ResourceManager +import java.text.DateFormat import java.text.ParseException import java.text.ParsePosition import java.text.SimpleDateFormat @@ -13,15 +14,69 @@ import java.util.Calendar import java.util.Date import java.util.Locale import java.util.concurrent.TimeUnit -import kotlin.math.ceil 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 SEVEN_DAYS_IN_MILLIS = 604800000L + fun formatToString(context: Context, date: Date, useRelativeDates: Boolean): String { + if (!useRelativeDates) { + val locale = Locale(Locale.getDefault().language) + val dateFormat = DateFormat.getDateInstance(DateFormat.MEDIUM, locale) + return dateFormat.format(date) + } + + val now = Calendar.getInstance() + val inputDate = Calendar.getInstance().apply { time = date } + val daysDiff = ((now.timeInMillis - inputDate.timeInMillis) / (1000 * 60 * 60 * 24)).toInt() + return when { + daysDiff in -5..-1 -> DateUtils.formatDateTime( + context, + date.time, + DateUtils.FORMAT_SHOW_WEEKDAY + ).toString() + + daysDiff == -6 -> context.getString(R.string.core_next) + " " + DateUtils.formatDateTime( + context, + date.time, + DateUtils.FORMAT_SHOW_WEEKDAY + ).toString() + + daysDiff in -1..1 -> DateUtils.getRelativeTimeSpanString( + date.time, + now.timeInMillis, + DateUtils.DAY_IN_MILLIS, + DateUtils.FORMAT_ABBREV_TIME + ).toString() + + daysDiff in 2..6 -> DateUtils.getRelativeTimeSpanString( + date.time, + now.timeInMillis, + DateUtils.DAY_IN_MILLIS + ).toString() + + inputDate.get(Calendar.YEAR) == now.get(Calendar.YEAR) -> { + DateUtils.getRelativeTimeSpanString( + date.time, + now.timeInMillis, + DateUtils.DAY_IN_MILLIS, + DateUtils.FORMAT_SHOW_DATE + ).toString() + } + + else -> { + DateUtils.getRelativeTimeSpanString( + date.time, + now.timeInMillis, + DateUtils.DAY_IN_MILLIS, + DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_YEAR + ).toString() + } + } + } + fun getCurrentTime(): Long { return Calendar.getInstance().timeInMillis } @@ -59,7 +114,7 @@ object TimeUtils { private fun dateToCourseDate(resourceManager: ResourceManager, date: Date?): String { return formatDate( - format = resourceManager.getString(R.string.core_date_format_MMMM_dd), date = date + format = resourceManager.getString(R.string.core_date_format_MMM_dd_yyyy), date = date ) } @@ -116,12 +171,12 @@ object TimeUtils { DateUtils.SECOND_IN_MILLIS, DateUtils.FORMAT_ABBREV_RELATIVE ).toString() - resourceManager.getString(R.string.core_label_expired, timeSpan) + resourceManager.getString(R.string.core_label_access_expired, timeSpan) } } else { formattedDate = if (dayDifferenceInMillis > SEVEN_DAYS_IN_MILLIS) { resourceManager.getString( - R.string.core_label_expires_on, + R.string.core_label_expires, dateToCourseDate(resourceManager, expiry) ) } else { @@ -152,7 +207,7 @@ object TimeUtils { ) } else { resourceManager.getString( - R.string.core_label_ending, dateToCourseDate(resourceManager, end) + R.string.core_label_ends, dateToCourseDate(resourceManager, end) ) } } @@ -172,83 +227,11 @@ object TimeUtils { } /** - * Method to get the formatted time string in terms of relative time with minimum resolution of minutes. - * For example, if the time difference is 1 minute, it will return "1m ago". - * - * @param date Date object to be formatted. - */ - fun getFormattedTime(date: Date): String { - return DateUtils.getRelativeTimeSpanString( - date.time, - getCurrentTime(), - DateUtils.MINUTE_IN_MILLIS, - DateUtils.FORMAT_ABBREV_TIME - ).toString() - } - - /** - * Returns a formatted date string for the given date. - */ - fun getCourseFormattedDate(context: Context, date: Date): String { - val inputDate = Calendar.getInstance().also { - it.time = date - it.clearTimeComponents() - } - val daysDifference = getDayDifference(inputDate) - - return when { - daysDifference == 0 -> { - context.getString(R.string.core_date_format_today) - } - - daysDifference == 1 -> { - context.getString(R.string.core_date_format_tomorrow) - } - - daysDifference == -1 -> { - context.getString(R.string.core_date_format_yesterday) - } - - daysDifference in -2 downTo -7 -> { - context.getString( - R.string.core_date_format_days_ago, - ceil(-daysDifference.toDouble()).toInt().toString() - ) - } - - daysDifference in 2..7 -> { - DateUtils.formatDateTime( - context, - date.time, - DateUtils.FORMAT_SHOW_WEEKDAY - ) - } - - inputDate.get(Calendar.YEAR) != Calendar.getInstance().get(Calendar.YEAR) -> { - DateUtils.formatDateTime( - context, - date.time, - DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_YEAR - ) - } - - else -> { - DateUtils.formatDateTime( - context, - date.time, - DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_NO_YEAR - ) - } - } - } - - /** - * Returns the number of days difference between the given date and the current date. + * Returns a formatted date string for the given date using context. */ - private fun getDayDifference(inputDate: Calendar): Int { - val currentDate = Calendar.getInstance().also { it.clearTimeComponents() } - val difference = inputDate.timeInMillis - currentDate.timeInMillis - return TimeUnit.MILLISECONDS.toDays(difference).toInt() + fun getCourseAccessFormattedDate(context: Context, date: Date): String { + val resourceManager = ResourceManager(context) + return dateToCourseDate(resourceManager, date) } } @@ -296,16 +279,6 @@ fun Date.clearTime(): Date { return calendar.time } -/** - * Extension function to check if the time difference between the given date and the current date is less than 24 hours. - */ -fun Date.isTimeLessThan24Hours(): Boolean { - val calendar = Calendar.getInstance() - calendar.time = this - val timeInMillis = (calendar.timeInMillis - TimeUtils.getCurrentTime()).unaryPlus() - return timeInMillis < TimeUnit.DAYS.toMillis(1) -} - fun Date.toCalendar(): Calendar { val calendar = Calendar.getInstance() calendar.time = this diff --git a/core/src/main/java/org/openedx/core/utils/UrlUtils.kt b/core/src/main/java/org/openedx/core/utils/UrlUtils.kt deleted file mode 100644 index 191edd4da..000000000 --- a/core/src/main/java/org/openedx/core/utils/UrlUtils.kt +++ /dev/null @@ -1,67 +0,0 @@ -package org.openedx.core.utils - -import android.content.Context -import android.content.Intent -import android.net.Uri - -object UrlUtils { - - const val QUERY_PARAM_SEARCH = "q" - - fun openInBrowser(activity: Context, apiHostUrl: String, url: String) { - if (url.isEmpty()) { - return - } - if (url.startsWith("/")) { - // Use API host as the base URL for relative paths - val absoluteUrl = "$apiHostUrl$url" - openInBrowser(activity, absoluteUrl) - return - } - openInBrowser(activity, url) - } - - private fun openInBrowser(context: Context, url: String) { - val intent = Intent(Intent.ACTION_VIEW) - intent.setData(Uri.parse(url)) - context.startActivity(intent) - } - - /** - * Utility function to remove the given query parameter from the URL - * Ref: https://stackoverflow.com/a/56108097 - * - * @param url that needs to update - * @param queryParam that needs to remove from the URL - * @return The URL after removing the given params - */ - private fun removeQueryParameterFromURL(url: String, queryParam: String): String { - val uri = Uri.parse(url) - val params = uri.queryParameterNames - val newUri = uri.buildUpon().clearQuery() - for (param in params) { - if (queryParam != param) { - newUri.appendQueryParameter(param, uri.getQueryParameter(param)) - } - } - return newUri.build().toString() - } - - /** - * Builds a valid URL with the given query params. - * - * @param url The base URL. - * @param queryParams The query params to add in the URL. - * @return URL String with query params added to it. - */ - fun buildUrlWithQueryParams(url: String, queryParams: Map): String { - val uriBuilder = Uri.parse(url).buildUpon() - for ((key, value) in queryParams) { - if (url.contains(key)) { - removeQueryParameterFromURL(url, key) - } - uriBuilder.appendQueryParameter(key, value) - } - return uriBuilder.build().toString() - } -} diff --git a/core/src/main/java/org/openedx/core/worker/CalendarSyncScheduler.kt b/core/src/main/java/org/openedx/core/worker/CalendarSyncScheduler.kt new file mode 100644 index 000000000..b74d7c9da --- /dev/null +++ b/core/src/main/java/org/openedx/core/worker/CalendarSyncScheduler.kt @@ -0,0 +1,39 @@ +package org.openedx.core.worker + +import android.content.Context +import androidx.work.Data +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import java.util.concurrent.TimeUnit + +class CalendarSyncScheduler(private val context: Context) { + + fun scheduleDailySync() { + val periodicWorkRequest = PeriodicWorkRequestBuilder(1, TimeUnit.DAYS) + .addTag(CalendarSyncWorker.WORKER_TAG) + .build() + + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + CalendarSyncWorker.WORKER_TAG, + ExistingPeriodicWorkPolicy.KEEP, + periodicWorkRequest + ) + } + + fun requestImmediateSync() { + val syncWorkRequest = OneTimeWorkRequestBuilder().build() + WorkManager.getInstance(context).enqueue(syncWorkRequest) + } + + fun requestImmediateSync(courseId: String) { + val inputData = Data.Builder() + .putString(CalendarSyncWorker.ARG_COURSE_ID, courseId) + .build() + val syncWorkRequest = OneTimeWorkRequestBuilder() + .setInputData(inputData) + .build() + WorkManager.getInstance(context).enqueue(syncWorkRequest) + } +} diff --git a/core/src/main/java/org/openedx/core/worker/CalendarSyncWorker.kt b/core/src/main/java/org/openedx/core/worker/CalendarSyncWorker.kt new file mode 100644 index 000000000..39a6c5507 --- /dev/null +++ b/core/src/main/java/org/openedx/core/worker/CalendarSyncWorker.kt @@ -0,0 +1,224 @@ +package org.openedx.core.worker + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.content.pm.ServiceInfo +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.core.app.NotificationCompat +import androidx.work.CoroutineWorker +import androidx.work.ForegroundInfo +import androidx.work.WorkerParameters +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.openedx.core.R +import org.openedx.core.data.model.CourseDates +import org.openedx.core.data.model.room.CourseCalendarEventEntity +import org.openedx.core.data.model.room.CourseCalendarStateEntity +import org.openedx.core.data.storage.CalendarPreferences +import org.openedx.core.domain.interactor.CalendarInteractor +import org.openedx.core.domain.model.CourseDateBlock +import org.openedx.core.domain.model.EnrollmentStatus +import org.openedx.core.system.CalendarManager +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.system.notifier.calendar.CalendarNotifier +import org.openedx.core.system.notifier.calendar.CalendarSyncFailed +import org.openedx.core.system.notifier.calendar.CalendarSyncOffline +import org.openedx.core.system.notifier.calendar.CalendarSynced +import org.openedx.core.system.notifier.calendar.CalendarSyncing + +class CalendarSyncWorker( + private val context: Context, + workerParams: WorkerParameters +) : CoroutineWorker(context, workerParams), KoinComponent { + + private val calendarManager: CalendarManager by inject() + private val calendarInteractor: CalendarInteractor by inject() + private val calendarNotifier: CalendarNotifier by inject() + private val calendarPreferences: CalendarPreferences by inject() + private val networkConnection: NetworkConnection by inject() + + private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + private val notificationBuilder = NotificationCompat.Builder(context, NOTIFICATION_CHANEL_ID) + + private val failedCoursesSync = mutableSetOf() + + override suspend fun doWork(): Result { + return try { + setForeground(createForegroundInfo()) + val courseId = inputData.getString(ARG_COURSE_ID) + tryToSyncCalendar(courseId) + Result.success() + } catch (e: Exception) { + calendarNotifier.send(CalendarSyncFailed) + Result.failure() + } + } + + private fun createForegroundInfo(): ForegroundInfo { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + createChannel() + } + val serviceType = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC else 0 + + return ForegroundInfo( + NOTIFICATION_ID, + notificationBuilder + .setSmallIcon(R.drawable.core_ic_calendar) + .setContentText(context.getString(R.string.core_title_syncing_calendar)) + .setContentTitle("") + .build(), + serviceType + ) + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun createChannel() { + val notificationChannel = + NotificationChannel( + NOTIFICATION_CHANEL_ID, + context.getString(R.string.core_header_sync_to_calendar), + NotificationManager.IMPORTANCE_LOW + ) + notificationManager.createNotificationChannel(notificationChannel) + } + + private suspend fun tryToSyncCalendar(courseId: String?) { + val isCalendarCreated = calendarPreferences.calendarId != CalendarManager.CALENDAR_DOES_NOT_EXIST + val isCalendarSyncEnabled = calendarPreferences.isCalendarSyncEnabled + if (!networkConnection.isOnline()) { + calendarNotifier.send(CalendarSyncOffline) + } else if (isCalendarCreated && isCalendarSyncEnabled) { + calendarNotifier.send(CalendarSyncing) + val enrollmentsStatus = calendarInteractor.getEnrollmentsStatus() + if (courseId.isNullOrEmpty()) { + syncCalendar(enrollmentsStatus) + } else { + syncCalendar(enrollmentsStatus, courseId) + } + removeUnenrolledCourseEvents(enrollmentsStatus) + if (failedCoursesSync.isEmpty()) { + calendarNotifier.send(CalendarSynced) + } else { + calendarNotifier.send(CalendarSyncFailed) + } + } + } + + private suspend fun removeUnenrolledCourseEvents(enrollmentStatus: List) { + val enrolledCourseIds = enrollmentStatus.map { it.courseId } + val cachedCourseIds = calendarInteractor.getAllCourseCalendarStateFromCache().map { it.courseId } + val unenrolledCourseIds = cachedCourseIds.filter { it !in enrolledCourseIds } + unenrolledCourseIds.forEach { courseId -> + removeCalendarEvents(courseId) + calendarInteractor.deleteCourseCalendarStateByIdFromCache(courseId) + } + } + + private suspend fun syncCalendar(enrollmentsStatus: List, courseId: String) { + enrollmentsStatus + .find { it.courseId == courseId } + ?.let { enrollmentStatus -> + syncCourseEvents(enrollmentStatus) + } + } + + private suspend fun syncCalendar(enrollmentsStatus: List) { + enrollmentsStatus.forEach { enrollmentStatus -> + syncCourseEvents(enrollmentStatus) + } + } + + private suspend fun syncCourseEvents(enrollmentStatus: EnrollmentStatus) { + val courseId = enrollmentStatus.courseId + try { + createCalendarState(enrollmentStatus) + if (enrollmentStatus.recentlyActive && isCourseSyncEnabled(courseId)) { + val courseDates = calendarInteractor.getCourseDates(courseId) + val isCourseCalendarUpToDate = isCourseCalendarUpToDate(courseId, courseDates) + if (!isCourseCalendarUpToDate) { + removeCalendarEvents(courseId) + updateCourseEvents(courseDates, enrollmentStatus) + } + } else { + removeCalendarEvents(courseId) + } + } catch (e: Exception) { + failedCoursesSync.add(courseId) + e.printStackTrace() + } + } + + private suspend fun updateCourseEvents(courseDates: CourseDates, enrollmentStatus: EnrollmentStatus) { + courseDates.courseDateBlocks.forEach { courseDateBlock -> + courseDateBlock.mapToDomain()?.let { domainCourseDateBlock -> + createEvent(domainCourseDateBlock, enrollmentStatus) + } + } + calendarInteractor.updateCourseCalendarStateByIdInCache( + courseId = enrollmentStatus.courseId, + checksum = getCourseChecksum(courseDates) + ) + } + + private suspend fun removeCalendarEvents(courseId: String) { + calendarInteractor.getCourseCalendarEventsByIdFromCache(courseId).forEach { + calendarManager.deleteEvent(it.eventId) + } + calendarInteractor.deleteCourseCalendarEntitiesByIdFromCache(courseId) + calendarInteractor.updateCourseCalendarStateByIdInCache(courseId = courseId, checksum = 0) + } + + private suspend fun createEvent(courseDateBlock: CourseDateBlock, enrollmentStatus: EnrollmentStatus) { + val eventId = calendarManager.addEventsIntoCalendar( + calendarId = calendarPreferences.calendarId, + courseId = enrollmentStatus.courseId, + courseName = enrollmentStatus.courseName, + courseDateBlock = courseDateBlock + ) + val courseCalendarEventEntity = CourseCalendarEventEntity( + courseId = enrollmentStatus.courseId, + eventId = eventId + ) + calendarInteractor.insertCourseCalendarEntityToCache(courseCalendarEventEntity) + } + + private suspend fun createCalendarState(enrollmentStatus: EnrollmentStatus) { + val courseCalendarStateChecksum = getCourseCalendarStateChecksum(enrollmentStatus.courseId) + if (courseCalendarStateChecksum == null) { + val courseCalendarStateEntity = CourseCalendarStateEntity( + courseId = enrollmentStatus.courseId, + isCourseSyncEnabled = enrollmentStatus.recentlyActive + ) + calendarInteractor.insertCourseCalendarStateEntityToCache(courseCalendarStateEntity) + } + } + + private suspend fun isCourseCalendarUpToDate(courseId: String, courseDates: CourseDates): Boolean { + val oldChecksum = getCourseCalendarStateChecksum(courseId) + val newChecksum = getCourseChecksum(courseDates) + return newChecksum == oldChecksum + } + + private suspend fun isCourseSyncEnabled(courseId: String): Boolean { + return calendarInteractor.getCourseCalendarStateByIdFromCache(courseId)?.isCourseSyncEnabled ?: true + } + + private fun getCourseChecksum(courseDates: CourseDates): Int { + return courseDates.courseDateBlocks.sumOf { it.mapToDomain().hashCode() } + } + + private suspend fun getCourseCalendarStateChecksum(courseId: String): Int? { + return calendarInteractor.getCourseCalendarStateByIdFromCache(courseId)?.checksum + } + + companion object { + const val ARG_COURSE_ID = "ARG_COURSE_ID" + const val WORKER_TAG = "calendar_sync_worker_tag" + const val NOTIFICATION_ID = 1234 + const val NOTIFICATION_CHANEL_ID = "calendar_sync_channel" + } +} diff --git a/core/src/main/res/drawable/core_ic_book.xml b/core/src/main/res/drawable/core_ic_book.xml new file mode 100644 index 000000000..dd802ee92 --- /dev/null +++ b/core/src/main/res/drawable/core_ic_book.xml @@ -0,0 +1,44 @@ + + + + + + + + + + diff --git a/core/src/main/res/drawable/core_ic_no_announcements.xml b/core/src/main/res/drawable/core_ic_no_announcements.xml new file mode 100644 index 000000000..fc85b3fe1 --- /dev/null +++ b/core/src/main/res/drawable/core_ic_no_announcements.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/core/src/main/res/drawable/core_ic_no_content.xml b/core/src/main/res/drawable/core_ic_no_content.xml new file mode 100644 index 000000000..94a134d7e --- /dev/null +++ b/core/src/main/res/drawable/core_ic_no_content.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/core/src/main/res/drawable/core_ic_no_handouts.xml b/core/src/main/res/drawable/core_ic_no_handouts.xml new file mode 100644 index 000000000..d1f19a3d3 --- /dev/null +++ b/core/src/main/res/drawable/core_ic_no_handouts.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/core/src/main/res/drawable/core_ic_no_videos.xml b/core/src/main/res/drawable/core_ic_no_videos.xml new file mode 100644 index 000000000..f8a55d1b9 --- /dev/null +++ b/core/src/main/res/drawable/core_ic_no_videos.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/core/src/main/res/drawable/core_ic_settings.xml b/core/src/main/res/drawable/core_ic_settings.xml deleted file mode 100644 index a86316516..000000000 --- a/core/src/main/res/drawable/core_ic_settings.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - diff --git a/core/src/main/res/drawable/core_ic_unknown_error.xml b/core/src/main/res/drawable/core_ic_unknown_error.xml new file mode 100644 index 000000000..d7d2c0c02 --- /dev/null +++ b/core/src/main/res/drawable/core_ic_unknown_error.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + diff --git a/core/src/main/res/drawable/ic_core_chapter_icon.xml b/core/src/main/res/drawable/ic_core_chapter_icon.xml new file mode 100644 index 000000000..9ee00fed7 --- /dev/null +++ b/core/src/main/res/drawable/ic_core_chapter_icon.xml @@ -0,0 +1,30 @@ + + + + + + + + diff --git a/course/src/main/res/drawable/ic_course_check.xml b/core/src/main/res/drawable/ic_core_check.xml similarity index 100% rename from course/src/main/res/drawable/ic_course_check.xml rename to core/src/main/res/drawable/ic_core_check.xml diff --git a/core/src/main/res/values-night/colors.xml b/core/src/main/res/values-night/colors.xml index 5a7d9d3bd..d6f9f1a14 100644 --- a/core/src/main/res/values-night/colors.xml +++ b/core/src/main/res/values-night/colors.xml @@ -3,4 +3,6 @@ #FF19212F #5478F9 #19212F - \ No newline at end of file + #879FF5 + #8E9BAE + diff --git a/core/src/main/res/values-uk/strings.xml b/core/src/main/res/values-uk/strings.xml deleted file mode 100644 index f20cd28e1..000000000 --- a/core/src/main/res/values-uk/strings.xml +++ /dev/null @@ -1,75 +0,0 @@ - - - Результати - Неправильні облікові дані - Повільне або відсутнє з\'єднання з Інтернетом - Щось пішло не так - Спробуйте ще раз - Політика конфіденційності - Умови використання - Профіль - Скасувати - Пошук - Виберіть значення - Починається %1$s - Закінчився %1$s - Закінчується %1$s - Термін дії курсу закінчується %1$s - Термін дії курсу закінчується %1$s - Термін дії курсу минув %1$s - Термін дії курсу минув %1$s - Пароль - незабаром - Авто - Рекомендовано - Менше використання трафіку - Найкраща якість - Офлайн - Закрити - Перезавантажити - Завантаження у процесі. - Обліковий запис користувача не активовано. Будь ласка, спочатку активуйте свій обліковий запис. - Надіслати електронний лист за допомогою ... - Не встановлено жодного поштового клієнта - dd MMMM - dd MMM yyyy HH:mm - Оновлення додатку - Ми рекомендуємо вам оновитись до останньої версії. Оновіться зараз, щоб отримати останні функції та виправлення. - Доступне нове оновлення! Оновіть зараз, щоб отримати останні можливості та виправлення - Не зараз - Оновити - Застаріла версія додатку - Налаштування аккаунту - Необхідне оновлення додатку - Ця версія додатка %1$s застаріла. Щоб продовжити навчання та отримати останні можливості та виправлення, будь ласка, оновіть до останньої версії. - Чому мені потрібно оновити? - Версія: %1$s - Оновлено - Натисніть, щоб оновити до версії %1$s - Натисніть, щоб встановити обов\'язкове оновлення додатку - Підтвердити - Вам подобається %1$s? - Ваш відгук має значення для нас. Будь ласка, оцініть додаток, натиснувши на зірочку нижче. Дякуємо за вашу підтримку! - Залиште відгук - Нам шкода, що ваш досвід навчання був з деякими проблемами. Ми цінуємо всі відгуки. - Що могло б бути краще? - Поділитися відгуком - Дякуємо - Оцінити нас - Дякуємо за надання відгуку. Чи бажаєте ви поділитися своєю оцінкою цього додатка з іншими користувачами в магазині додатків? - Ми отримали ваш відгук і використовуватимемо його, щоб покращити ваш досвід навчання в майбутньому. Дякуємо, що поділилися! - - Зареєструватися - Увійти - - - %1$s зображення профілю - Заглавне зображення для курсу %1$s - - Якість транслювання відео - - Курс - Відео - Обговорення - Матеріали - diff --git a/core/src/main/res/values/colors.xml b/core/src/main/res/values/colors.xml index d6d7f456d..57a25d9ed 100644 --- a/core/src/main/res/values/colors.xml +++ b/core/src/main/res/values/colors.xml @@ -3,4 +3,6 @@ #FFFFFF #3C68FF #517BFE - \ No newline at end of file + #3C68FF + #97A5BB + diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index ed4b1d99d..c8d529afa 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -16,13 +16,12 @@ Cancel Search Select value - Starting %1$s - Ended %1$s - Ending %1$s - Course access expires %1$s - Course access expires on %1$s - Course access expired %1$s - Course access expired on %1$s + Starts %1$s + Ended on %1$s + Ends %1$s + Access expires %1$s + Access expired %1$s + Expired on %1$s Password Soon Offline @@ -46,7 +45,7 @@ OS version: Device model: Feedback - MMMM dd + MMM dd, yyyy dd MMM yyyy hh:mm aaa App Update We recommend that you update to the latest version. Upgrade now to receive the latest features and fixes. @@ -76,6 +75,8 @@ We received your feedback and will use it to help improve your learning experience going forward. Thank you for sharing! No internet connection Please connect to the internet to view this content. + Try Again + Something went wrong OK Continue Leaving the app @@ -92,10 +93,7 @@ Next Week Upcoming None - Today - Tomorrow - Yesterday - %1$s days ago + Due %1$s %d Item Hidden %d Items Hidden @@ -132,9 +130,61 @@ Video download quality Manage Account + Assignment Due + Syncing calendar… + + + Sync to calendar + Automatically sync all deadlines and due dates for this course to your calendar. + + \“%s\” Would Like to Access Your Calendar + %s would like to use your calendar list to subscribe to your personalized %s calendar for this course. + Don’t allow + + Add Course Dates to Calendar + Would you like to add \“%s\” dates to your calendar? \n\nYou can edit or remove your course dates at any time from your calendar or settings. + + \“%s\” has been added to your phone\'s calendar. + View Events + Done + + Remove Course Dates from Calendar + Would you like to remove the \“%s\” dates from your calendar? + Remove + + Your course calendar is out of date + Your course dates have been shifted and your course calendar is no longer up to date with your new schedule. + Update Now + Remove Course Calendar + + Your course calendar has been added. + Your course calendar has been removed. + Your course calendar has been updated. + Error Adding Calendar, Please try later + + + Home Videos Discussions More Dates + No course content is currently available. + There are currently no videos for this course. + Course dates are currently not available. + Unable to load discussions.\n Please try again later. + There are currently no handouts for this course. + There are currently no announcements for this course. + Confirm Download + Edit + Offline Progress Sync + Close + + Calendar Sync Failed + Synced to Calendar + Sync Failed + To Sync + Not Synced + Syncing to calendar… + Next diff --git a/core/src/openedx/org/openedx/core/ui/theme/Colors.kt b/core/src/openedx/org/openedx/core/ui/theme/Colors.kt index 1cc4c3495..69f550018 100644 --- a/core/src/openedx/org/openedx/core/ui/theme/Colors.kt +++ b/core/src/openedx/org/openedx/core/ui/theme/Colors.kt @@ -8,26 +8,40 @@ val light_secondary = Color(0xFF94D3DD) val light_secondary_variant = Color(0xFF94D3DD) val light_background = Color.White val light_surface = Color(0xFFF7F7F8) -val light_error = Color(0xFFFF3D71) +val light_error = Color(0xFFE8174F) val light_onPrimary = Color.White -val light_onSecondary = Color.Black +val light_onSecondary = Color.White val light_onBackground = Color.Black val light_onSurface = Color.Black val light_onError = Color.White +val light_onWarning = Color.White +val light_onInfo = Color.White +val light_info_variant = Color(0xFF3C68FF) val light_text_primary = Color(0xFF212121) val light_text_primary_variant = Color(0xFF3D4964) +val light_text_primary_light = light_text_primary val light_text_secondary = Color(0xFFB3B3B3) val light_text_dark = Color(0xFF19212F) val light_text_accent = Color(0xFF3C68FF) -val light_text_warning= Color(0xFF19212F) +val light_text_warning = Color(0xFF19212F) val light_text_field_background = Color(0xFFF7F7F8) val light_text_field_background_variant = Color.White val light_text_field_border = Color(0xFF97A5BB) val light_text_field_text = Color(0xFF3D4964) val light_text_field_hint = Color(0xFF97A5BB) -val light_button_background = Color(0xFF3C68FF) -val light_button_secondary_background = Color(0xFF79889F) -val light_button_text = Color.White +val light_text_hyper_link = Color(0xFF3C68FF) + +val light_primary_button_background = Color(0xFF3C68FF) +val light_primary_button_border = Color(0xFF97A5BB) +val light_primary_button_text = Color.White +val light_primary_button_bordered_text = Color(0xFF3C68FF) + +val light_secondary_button_background = light_primary_button_background +val light_secondary_button_text = light_primary_button_text +val light_secondary_button_border = light_primary_button_border +val light_secondary_button_bordered_background = Color.White +val light_secondary_button_bordered_text = light_primary_button_bordered_text + val light_card_view_background = Color(0xFFF9FAFB) val light_card_view_border = Color(0xFFCCD4E0) val light_divider = Color(0xFFCCD4E0) @@ -37,13 +51,15 @@ val light_warning = Color(0xFFFFC94D) val light_info = Color(0xFF42AAFF) val light_rate_stars = Color(0xFFFFC94D) val light_inactive_button_background = Color(0xFFCCD4E0) -val light_inactive_button_text = Color(0xFF3D4964) -val light_access_green = Color(0xFF23BCA0) +val light_success_green = Color(0xFF198571) +val light_success_background = Color(0xFF0D7D4D) val light_dates_section_bar_past_due = light_warning val light_dates_section_bar_today = light_info val light_dates_section_bar_this_week = light_text_primary_variant val light_dates_section_bar_next_week = light_text_field_border val light_dates_section_bar_upcoming = Color(0xFFCCD4E0) +val light_auth_sso_success_background = light_secondary +val light_auth_google_button_background = Color.White val light_auth_facebook_button_background = Color(0xFF0866FF) val light_auth_microsoft_button_background = Color(0xFA000000) val light_component_horizontal_progress_completed_and_selected = Color(0xFF30a171) @@ -56,9 +72,11 @@ val light_tab_selected_btn_content = Color.White val light_course_home_header_shade = Color(0xFFBABABA) val light_course_home_back_btn_background = Color.White val light_settings_title_content = Color.White +val light_progress_bar_color = light_primary +val light_progress_bar_background_color = Color(0xFFCCD4E0) -val dark_primary = Color(0xFF5478F9) +val dark_primary = Color(0xFF3F68F8) val dark_primary_variant = Color(0xFF3700B3) val dark_secondary = Color(0xFF03DAC6) val dark_secondary_variant = Color(0xFF373E4F) @@ -66,40 +84,56 @@ val dark_background = Color(0xFF19212F) val dark_surface = Color(0xFF273346) val dark_error = Color(0xFFFF3D71) val dark_onPrimary = Color.Black -val dark_onSecondary = Color.Black +val dark_onSecondary = Color.White val dark_onBackground = Color.White val dark_onSurface = Color.White val dark_onError = Color.Black val dark_text_primary = Color.White -val dark_text_primary_variant = Color(0xFF79889F) +val dark_text_primary_light = dark_text_primary +val dark_text_primary_variant = Color.White val dark_text_secondary = Color(0xFFB3B3B3) val dark_text_dark = Color.White val dark_text_accent = Color(0xFF879FF5) -val dark_text_warning= Color(0xFF19212F) +val dark_text_warning = Color(0xFF19212F) val dark_text_field_background = Color(0xFF273346) val dark_text_field_background_variant = Color(0xFF273346) val dark_text_field_border = Color(0xFF4E5A70) val dark_text_field_text = Color.White val dark_text_field_hint = Color(0xFF79889F) -val dark_button_background = Color(0xFF5478F9) -val dark_button_secondary_background = Color(0xFF79889F) -val dark_button_text = Color.White +val dark_text_hyper_link = Color(0xFF5478F9) + +val dark_primary_button_background = Color(0xFF5478F9) +val dark_primary_button_text = Color.White +val dark_primary_button_border = Color(0xFF4E5A70) +val dark_primary_button_bordered_text = Color(0xFF5478F9) + +val dark_secondary_button_background = dark_primary_button_background +val dark_secondary_button_text = dark_primary_button_text +val dark_secondary_button_border = dark_primary_button_border +val dark_secondary_button_bordered_background = Color(0xFF19212F) +val dark_secondary_button_bordered_text = dark_primary_button_bordered_text + val dark_card_view_background = Color(0xFF273346) val dark_card_view_border = Color(0xFF4E5A70) val dark_divider = Color(0xFF4E5A70) val dark_certificate_foreground = Color(0xD92EB865) val dark_bottom_sheet_toggle = Color(0xFF4E5A70) val dark_warning = Color(0xFFFFC248) +val dark_onWarning = Color.White val dark_info = Color(0xFF0095FF) +val dark_info_variant = Color(0xFF5478F9) +val dark_onInfo = Color.White val dark_rate_stars = Color(0xFFFFC94D) val dark_inactive_button_background = Color(0xFFCCD4E0) -val dark_inactive_button_text = Color(0xFF3D4964) -val dark_access_green = Color(0xFF23BCA0) +val dark_success_green = Color(0xFF198571) +val dark_success_background = Color.White val dark_dates_section_bar_past_due = dark_warning val dark_dates_section_bar_today = dark_info val dark_dates_section_bar_this_week = dark_text_primary_variant val dark_dates_section_bar_next_week = dark_text_field_border val dark_dates_section_bar_upcoming = Color(0xFFCCD4E0) +val dark_auth_sso_success_background = dark_secondary +val dark_auth_google_button_background = Color(0xFF19212F) val dark_auth_facebook_button_background = Color(0xFF0866FF) val dark_auth_microsoft_button_background = Color(0xFA000000) val dark_component_horizontal_progress_completed_and_selected = Color(0xFF30a171) @@ -112,3 +146,5 @@ val dark_tab_selected_btn_content = Color.White val dark_course_home_header_shade = Color(0xFF999999) val dark_course_home_back_btn_background = Color.Black val dark_settings_title_content = Color.White +val dark_progress_bar_color = light_primary +val dark_progress_bar_background_color = Color(0xFF8E9BAE) diff --git a/course/build.gradle b/course/build.gradle index f746f4d09..3b8096dc4 100644 --- a/course/build.gradle +++ b/course/build.gradle @@ -2,6 +2,7 @@ plugins { id 'com.android.library' id 'org.jetbrains.kotlin.android' id 'kotlin-parcelize' + id "org.jetbrains.kotlin.plugin.compose" } android { @@ -29,15 +30,13 @@ android { } kotlinOptions { jvmTarget = JavaVersion.VERSION_17 + freeCompilerArgs = List.of("-Xstring-concat=inline") } buildFeatures { viewBinding true compose true } - composeOptions { - kotlinCompilerExtensionVersion = "$compose_compiler_version" - } flavorDimensions += "env" productFlavors { @@ -67,13 +66,10 @@ dependencies { implementation "androidx.media3:media3-cast:$media3_version" implementation "me.saket.extendedspans:extendedspans:$extented_spans_version" - androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' - androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version" - debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version" + androidTestImplementation 'androidx.test.ext:junit:1.2.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' testImplementation "junit:junit:$junit_version" testImplementation "io.mockk:mockk:$mockk_version" testImplementation "io.mockk:mockk-android:$mockk_version" - testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version" testImplementation "androidx.arch.core:core-testing:$android_arch_version" } \ No newline at end of file diff --git a/course/proguard-rules.pro b/course/proguard-rules.pro index 481bb4348..dccbe504f 100644 --- a/course/proguard-rules.pro +++ b/course/proguard-rules.pro @@ -1,21 +1,7 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +# Prevent shrinking, optimization, and obfuscation of the library when consumed by other modules. +# This ensures that all classes and methods remain available for use by the consumer of the library. +# Disabling these steps at the library level is important because the main app module will handle +# shrinking, optimization, and obfuscation for the entire application, including this library. +-dontshrink +-dontoptimize +-dontobfuscate \ No newline at end of file diff --git a/course/src/main/AndroidManifest.xml b/course/src/main/AndroidManifest.xml deleted file mode 100644 index 5c18ebdbf..000000000 --- a/course/src/main/AndroidManifest.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/course/src/main/java/org/openedx/course/DatesShiftedSnackBar.kt b/course/src/main/java/org/openedx/course/DatesShiftedSnackBar.kt index fd2a3ce6b..1c7d5fcaf 100644 --- a/course/src/main/java/org/openedx/course/DatesShiftedSnackBar.kt +++ b/course/src/main/java/org/openedx/course/DatesShiftedSnackBar.kt @@ -1,5 +1,5 @@ package org.openedx.course -import org.openedx.core.UIMessage +import org.openedx.foundation.presentation.UIMessage class DatesShiftedSnackBar : UIMessage() diff --git a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt index 17dc6a240..f79e46066 100644 --- a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt +++ b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt @@ -1,60 +1,78 @@ package org.openedx.course.data.repository import kotlinx.coroutines.flow.map +import okhttp3.MultipartBody import org.openedx.core.ApiConstants import org.openedx.core.data.api.CourseApi import org.openedx.core.data.model.BlocksCompletionBody +import org.openedx.core.data.model.room.OfflineXBlockProgress +import org.openedx.core.data.model.room.XBlockProgressData import org.openedx.core.data.storage.CorePreferences -import org.openedx.core.domain.model.* +import org.openedx.core.domain.model.CourseComponentStatus +import org.openedx.core.domain.model.CourseEnrollmentDetails +import org.openedx.core.domain.model.CourseStructure import org.openedx.core.exception.NoCachedDataException import org.openedx.core.module.db.DownloadDao +import org.openedx.core.system.connection.NetworkConnection import org.openedx.course.data.storage.CourseDao +import java.net.URLDecoder +import java.nio.charset.StandardCharsets class CourseRepository( private val api: CourseApi, private val courseDao: CourseDao, private val downloadDao: DownloadDao, private val preferencesManager: CorePreferences, + private val networkConnection: NetworkConnection, ) { - private var courseStructure: CourseStructure? = null + private var courseStructure = mutableMapOf() suspend fun removeDownloadModel(id: String) { downloadDao.removeDownloadModel(id) } - fun getDownloadModels() = downloadDao.readAllData().map { list -> + fun getDownloadModels() = downloadDao.getAllDataFlow().map { list -> list.map { it.mapToDomain() } } - suspend fun preloadCourseStructure(courseId: String) { - val response = api.getCourseStructure( - "stale-if-error=0", - "v3", - preferencesManager.user?.username, - courseId - ) - courseDao.insertCourseStructureEntity(response.mapToRoomEntity()) - courseStructure = null - courseStructure = response.mapToDomain() - } + suspend fun getAllDownloadModels() = downloadDao.readAllData().map { it.mapToDomain() } - suspend fun preloadCourseStructureFromCache(courseId: String) { + suspend fun getCourseStructureFromCache(courseId: String): CourseStructure { val cachedCourseStructure = courseDao.getCourseStructureById(courseId) - courseStructure = null if (cachedCourseStructure != null) { - courseStructure = cachedCourseStructure.mapToDomain() + return cachedCourseStructure.mapToDomain() } else { throw NoCachedDataException() } } - @Throws(IllegalStateException::class) - fun getCourseStructureFromCache(): CourseStructure { - if (courseStructure != null) { - return courseStructure!! + suspend fun getCourseStructure(courseId: String, isNeedRefresh: Boolean): CourseStructure { + if (!isNeedRefresh) courseStructure[courseId]?.let { return it } + + if (networkConnection.isOnline()) { + val response = api.getCourseStructure( + "stale-if-error=0", + "v4", + preferencesManager.user?.username, + courseId + ) + courseDao.insertCourseStructureEntity(response.mapToRoomEntity()) + courseStructure[courseId] = response.mapToDomain() + } else { - throw IllegalStateException("Course structure is empty") + val cachedCourseStructure = courseDao.getCourseStructureById(courseId) + if (cachedCourseStructure != null) { + courseStructure[courseId] = cachedCourseStructure.mapToDomain() + } else { + throw NoCachedDataException() + } } + + return courseStructure[courseId]!! + } + + suspend fun getEnrollmentDetails(courseId: String): CourseEnrollmentDetails { + return api.getEnrollmentDetails(courseId = courseId).mapToDomain() } suspend fun getCourseStatus(courseId: String): CourseComponentStatus { @@ -85,4 +103,41 @@ class CourseRepository( suspend fun getAnnouncements(courseId: String) = api.getAnnouncements(courseId).map { it.mapToDomain() } + + suspend fun saveOfflineXBlockProgress(blockId: String, courseId: String, jsonProgress: String) { + val offlineXBlockProgress = OfflineXBlockProgress( + blockId = blockId, + courseId = courseId, + jsonProgress = XBlockProgressData.parseJson(jsonProgress) + ) + downloadDao.insertOfflineXBlockProgress(offlineXBlockProgress) + } + + suspend fun getXBlockProgress(blockId: String) = downloadDao.getOfflineXBlockProgress(blockId) + + suspend fun submitAllOfflineXBlockProgress() { + val allOfflineXBlockProgress = downloadDao.getAllOfflineXBlockProgress() + allOfflineXBlockProgress.forEach { + submitOfflineXBlockProgress(it.blockId, it.courseId, it.jsonProgress.data) + } + } + + suspend fun submitOfflineXBlockProgress(blockId: String, courseId: String) { + val jsonProgressData = getXBlockProgress(blockId)?.jsonProgress?.data + submitOfflineXBlockProgress(blockId, courseId, jsonProgressData) + } + + private suspend fun submitOfflineXBlockProgress(blockId: String, courseId: String, jsonProgressData: String?) { + if (!jsonProgressData.isNullOrEmpty()) { + val parts = mutableListOf() + val decodedQuery = URLDecoder.decode(jsonProgressData, StandardCharsets.UTF_8.name()) + val keyValuePairs = decodedQuery.split("&") + for (pair in keyValuePairs) { + val (key, value) = pair.split("=") + parts.add(MultipartBody.Part.createFormData(key, value)) + } + api.submitOfflineXBlockProgress(courseId, blockId, parts) + downloadDao.removeOfflineXBlockProgress(listOf(blockId)) + } + } } diff --git a/course/src/main/java/org/openedx/course/data/storage/CourseConverter.kt b/course/src/main/java/org/openedx/course/data/storage/CourseConverter.kt index 91ac5a610..f71c8593f 100644 --- a/course/src/main/java/org/openedx/course/data/storage/CourseConverter.kt +++ b/course/src/main/java/org/openedx/course/data/storage/CourseConverter.kt @@ -4,7 +4,8 @@ import androidx.room.TypeConverter import com.google.gson.Gson import org.openedx.core.data.model.room.BlockDb import org.openedx.core.data.model.room.VideoInfoDb -import org.openedx.core.extension.genericType +import org.openedx.core.data.model.room.discovery.CourseDateBlockDb +import org.openedx.foundation.extension.genericType class CourseConverter { @@ -57,4 +58,16 @@ class CourseConverter { return gson.toJson(map) } + @TypeConverter + fun fromListOfCourseDateBlockDb(value: List): String { + val json = Gson().toJson(value) + return json.toString() + } + + @TypeConverter + fun toListOfCourseDateBlockDb(value: String): List { + val type = genericType>() + return Gson().fromJson(value, type) + } + } diff --git a/course/src/main/java/org/openedx/course/data/storage/CourseDao.kt b/course/src/main/java/org/openedx/course/data/storage/CourseDao.kt index ca7286e48..63bd1c4d9 100644 --- a/course/src/main/java/org/openedx/course/data/storage/CourseDao.kt +++ b/course/src/main/java/org/openedx/course/data/storage/CourseDao.kt @@ -5,17 +5,16 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import org.openedx.core.data.model.room.CourseStructureEntity -import org.openedx.core.data.model.room.discovery.EnrolledCourseEntity @Dao interface CourseDao { - @Query("SELECT * FROM course_enrolled_table WHERE id=:id") - suspend fun getEnrolledCourseById(id: String): EnrolledCourseEntity? - @Query("SELECT * FROM course_structure_table WHERE id=:id") suspend fun getCourseStructureById(id: String): CourseStructureEntity? @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertCourseStructureEntity(vararg courseStructureEntity: CourseStructureEntity) + + @Query("DELETE FROM course_structure_table") + suspend fun clearCachedData() } diff --git a/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt b/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt index 6c8bd1009..e91b309c3 100644 --- a/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt +++ b/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt @@ -2,6 +2,7 @@ package org.openedx.course.domain.interactor import org.openedx.core.BlockType import org.openedx.core.domain.model.Block +import org.openedx.core.domain.model.CourseEnrollmentDetails import org.openedx.core.domain.model.CourseStructure import org.openedx.course.data.repository.CourseRepository @@ -9,18 +10,26 @@ class CourseInteractor( private val repository: CourseRepository ) { - suspend fun preloadCourseStructure(courseId: String) = - repository.preloadCourseStructure(courseId) + suspend fun getCourseStructure( + courseId: String, + isNeedRefresh: Boolean = false + ): CourseStructure { + return repository.getCourseStructure(courseId, isNeedRefresh) + } - suspend fun preloadCourseStructureFromCache(courseId: String) = - repository.preloadCourseStructureFromCache(courseId) + suspend fun getCourseStructureFromCache(courseId: String): CourseStructure { + return repository.getCourseStructureFromCache(courseId) + } - @Throws(IllegalStateException::class) - fun getCourseStructureFromCache() = repository.getCourseStructureFromCache() + suspend fun getEnrollmentDetails(courseId: String): CourseEnrollmentDetails { + return repository.getEnrollmentDetails(courseId = courseId) + } - @Throws(IllegalStateException::class) - fun getCourseStructureForVideos(): CourseStructure { - val courseStructure = repository.getCourseStructureFromCache() + suspend fun getCourseStructureForVideos( + courseId: String, + isNeedRefresh: Boolean = false + ): CourseStructure { + val courseStructure = repository.getCourseStructure(courseId, isNeedRefresh) val blocks = courseStructure.blockData val videoBlocks = blocks.filter { it.type == BlockType.VIDEO } val resultBlocks = ArrayList() @@ -72,4 +81,16 @@ class CourseInteractor( fun getDownloadModels() = repository.getDownloadModels() + suspend fun getAllDownloadModels() = repository.getAllDownloadModels() + + suspend fun saveXBlockProgress(blockId: String, courseId: String, jsonProgress: String) { + repository.saveOfflineXBlockProgress(blockId, courseId, jsonProgress) + } + + suspend fun getXBlockProgress(blockId: String) = repository.getXBlockProgress(blockId) + + suspend fun submitAllOfflineXBlockProgress() = repository.submitAllOfflineXBlockProgress() + + suspend fun submitOfflineXBlockProgress(blockId: String, courseId: String) = + repository.submitOfflineXBlockProgress(blockId, courseId) } diff --git a/course/src/main/java/org/openedx/course/domain/model/DownloadDialogResource.kt b/course/src/main/java/org/openedx/course/domain/model/DownloadDialogResource.kt new file mode 100644 index 000000000..cded4944a --- /dev/null +++ b/course/src/main/java/org/openedx/course/domain/model/DownloadDialogResource.kt @@ -0,0 +1,9 @@ +package org.openedx.course.domain.model + +import androidx.compose.ui.graphics.painter.Painter + +data class DownloadDialogResource( + val title: String, + val description: String, + val icon: Painter? = null, +) diff --git a/course/src/main/java/org/openedx/course/presentation/ChapterEndFragmentDialog.kt b/course/src/main/java/org/openedx/course/presentation/ChapterEndFragmentDialog.kt index e84766780..13380ddde 100644 --- a/course/src/main/java/org/openedx/course/presentation/ChapterEndFragmentDialog.kt +++ b/course/src/main/java/org/openedx/course/presentation/ChapterEndFragmentDialog.kt @@ -40,7 +40,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.os.bundleOf import androidx.fragment.app.DialogFragment -import org.openedx.core.extension.setWidthPercent import org.openedx.core.ui.AutoSizeText import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.OpenEdXOutlinedButton @@ -50,6 +49,7 @@ import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography import org.openedx.course.R +import org.openedx.foundation.extension.setWidthPercent class ChapterEndFragmentDialog : DialogFragment() { @@ -209,7 +209,7 @@ private fun ChapterEndDialogScreen( TextIcon( text = stringResource(id = R.string.course_next_section), painter = painterResource(org.openedx.core.R.drawable.core_ic_forward), - color = MaterialTheme.appColors.buttonText, + color = MaterialTheme.appColors.primaryButtonText, textStyle = MaterialTheme.appTypography.labelLarge, iconModifier = Modifier.rotate(if (isVerticalNavigation) 90f else 0f) ) @@ -219,15 +219,15 @@ private fun ChapterEndDialogScreen( Spacer(Modifier.height(16.dp)) } OpenEdXOutlinedButton( - borderColor = MaterialTheme.appColors.buttonBackground, - textColor = MaterialTheme.appColors.buttonBackground, + borderColor = MaterialTheme.appColors.primaryButtonBackground, + textColor = MaterialTheme.appColors.primaryButtonBackground, text = stringResource(id = R.string.course_back_to_outline), onClick = onBackButtonClick, content = { AutoSizeText( text = stringResource(id = R.string.course_back_to_outline), style = MaterialTheme.appTypography.bodyMedium, - color = MaterialTheme.appColors.buttonBackground + color = MaterialTheme.appColors.primaryButtonBorderedText ) } ) @@ -326,7 +326,7 @@ private fun ChapterEndDialogScreenLandscape( TextIcon( text = stringResource(id = R.string.course_next_section), painter = painterResource(org.openedx.core.R.drawable.core_ic_forward), - color = MaterialTheme.appColors.buttonText, + color = MaterialTheme.appColors.primaryButtonText, textStyle = MaterialTheme.appTypography.labelLarge ) }, @@ -335,15 +335,15 @@ private fun ChapterEndDialogScreenLandscape( Spacer(Modifier.height(16.dp)) } OpenEdXOutlinedButton( - borderColor = MaterialTheme.appColors.buttonBackground, - textColor = MaterialTheme.appColors.buttonBackground, + borderColor = MaterialTheme.appColors.primaryButtonBackground, + textColor = MaterialTheme.appColors.primaryButtonBackground, text = stringResource(id = R.string.course_back_to_outline), onClick = onBackButtonClick, content = { AutoSizeText( text = stringResource(id = R.string.course_back_to_outline), style = MaterialTheme.appTypography.bodyMedium, - color = MaterialTheme.appColors.buttonBackground + color = MaterialTheme.appColors.primaryButtonBorderedText ) } ) diff --git a/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt b/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt index 8151226c0..0dbe660e5 100644 --- a/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt +++ b/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt @@ -38,6 +38,7 @@ interface CourseAnalytics { fun finishVerticalBackClickedEvent(courseId: String, courseName: String) fun logEvent(event: String, params: Map) + fun logScreenEvent(screenName: String, params: Map) } enum class CourseAnalyticsEvent(val eventName: String, val biValue: String) { diff --git a/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt b/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt index b2f520679..65ce5f012 100644 --- a/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt +++ b/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt @@ -2,7 +2,7 @@ package org.openedx.course.presentation import androidx.fragment.app.FragmentManager import org.openedx.core.presentation.course.CourseViewMode -import org.openedx.core.presentation.settings.VideoQualityType +import org.openedx.core.presentation.settings.video.VideoQualityType import org.openedx.course.presentation.handouts.HandoutsType interface CourseRouter { @@ -56,10 +56,12 @@ interface CourseRouter { ) fun navigateToHandoutsWebView( - fm: FragmentManager, courseId: String, title: String, type: HandoutsType + fm: FragmentManager, courseId: String, type: HandoutsType ) fun navigateToDownloadQueue(fm: FragmentManager, descendants: List = arrayListOf()) fun navigateToVideoQuality(fm: FragmentManager, videoQualityType: VideoQualityType) + + fun navigateToDiscover(fm: FragmentManager) } diff --git a/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncDialogType.kt b/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncDialogType.kt deleted file mode 100644 index 57d6c0dac..000000000 --- a/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncDialogType.kt +++ /dev/null @@ -1,45 +0,0 @@ -package org.openedx.course.presentation.calendarsync - -import org.openedx.course.R -import org.openedx.core.R as CoreR - -enum class CalendarSyncDialogType( - val titleResId: Int = 0, - val messageResId: Int = 0, - val positiveButtonResId: Int = 0, - val negativeButtonResId: Int = 0, -) { - SYNC_DIALOG( - titleResId = R.string.course_title_add_course_calendar, - messageResId = R.string.course_message_add_course_calendar, - positiveButtonResId = CoreR.string.core_ok, - negativeButtonResId = CoreR.string.core_cancel - ), - UN_SYNC_DIALOG( - titleResId = R.string.course_title_remove_course_calendar, - messageResId = R.string.course_message_remove_course_calendar, - positiveButtonResId = R.string.course_label_remove, - negativeButtonResId = CoreR.string.core_cancel - ), - PERMISSION_DIALOG( - titleResId = R.string.course_title_request_calendar_permission, - messageResId = R.string.course_message_request_calendar_permission, - positiveButtonResId = CoreR.string.core_ok, - negativeButtonResId = R.string.course_label_do_not_allow - ), - EVENTS_DIALOG( - messageResId = R.string.course_message_course_calendar_added, - positiveButtonResId = R.string.course_label_view_events, - negativeButtonResId = R.string.course_label_done - ), - OUT_OF_SYNC_DIALOG( - titleResId = R.string.course_title_calendar_out_of_date, - messageResId = R.string.course_message_calendar_out_of_date, - positiveButtonResId = R.string.course_label_update_now, - negativeButtonResId = R.string.course_label_remove_course_calendar, - ), - LOADING_DIALOG( - titleResId = R.string.course_title_syncing_calendar - ), - NONE; -} diff --git a/course/src/main/java/org/openedx/course/presentation/container/CollapsingLayout.kt b/course/src/main/java/org/openedx/course/presentation/container/CollapsingLayout.kt index b5d73adaf..c4d1bd844 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CollapsingLayout.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CollapsingLayout.kt @@ -53,6 +53,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Density @@ -64,13 +65,13 @@ 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.R import org.openedx.core.ui.RoundTabsBar import org.openedx.core.ui.displayCutoutForLandscape -import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors +import org.openedx.foundation.presentation.rememberWindowSize import kotlin.math.roundToInt @Composable @@ -78,6 +79,7 @@ internal fun CollapsingLayout( modifier: Modifier = Modifier, courseImage: Bitmap, imageHeight: Int, + isEnabled: Boolean, expandedTop: @Composable BoxScope.() -> Unit, collapsedTop: @Composable BoxScope.() -> Unit, navigation: @Composable BoxScope.() -> Unit, @@ -105,7 +107,8 @@ internal fun CollapsingLayout( val factor = if (rawFactor.isNaN() || rawFactor < 0) 0f else rawFactor val blurImagePadding = 40.dp val blurImagePaddingPx = with(localDensity) { blurImagePadding.toPx() } - val toolbarOffset = (offset.value + backgroundImageHeight.floatValue - blurImagePaddingPx).roundToInt() + val toolbarOffset = + (offset.value + backgroundImageHeight.floatValue - blurImagePaddingPx).roundToInt() val imageStartY = (backgroundImageHeight.floatValue - blurImagePaddingPx) * 0.5f val imageOffsetY = -(offset.value + imageStartY) val toolbarBackgroundOffset = if (toolbarOffset >= 0) { @@ -164,10 +167,15 @@ internal fun CollapsingLayout( } } + val collapsingModifier = if (isEnabled) { + modifier + .nestedScroll(nestedScrollConnection) + } else { + modifier + } Box( - modifier = modifier + modifier = collapsingModifier .fillMaxSize() - .nestedScroll(nestedScrollConnection) .pointerInput(Unit) { var yStart = 0f coroutineScope { @@ -219,6 +227,7 @@ internal fun CollapsingLayout( backBtnStartPadding = backBtnStartPadding, courseImage = courseImage, imageHeight = imageHeight, + isEnabled = isEnabled, onBackClick = onBackClick, expandedTop = expandedTop, navigation = navigation, @@ -242,6 +251,7 @@ internal fun CollapsingLayout( courseImage = courseImage, imageHeight = imageHeight, toolbarBackgroundOffset = toolbarBackgroundOffset, + isEnabled = isEnabled, onBackClick = onBackClick, expandedTop = expandedTop, collapsedTop = collapsedTop, @@ -263,6 +273,7 @@ private fun CollapsingLayoutTablet( backBtnStartPadding: Dp, courseImage: Bitmap, imageHeight: Int, + isEnabled: Boolean, onBackClick: () -> Unit, expandedTop: @Composable BoxScope.() -> Unit, navigation: @Composable BoxScope.() -> Unit, @@ -394,22 +405,34 @@ private fun CollapsingLayoutTablet( Box( modifier = Modifier - .offset { IntOffset(x = 0, y = (backgroundImageHeight.value + expandedTopHeight.value).roundToInt()) } + .offset { + IntOffset( + x = 0, + y = (backgroundImageHeight.value + expandedTopHeight.value).roundToInt() + ) + } .onSizeChanged { size -> navigationHeight.value = size.height.toFloat() }, content = navigation, ) - Box( - modifier = Modifier + val bodyPadding = expandedTopHeight.value + backgroundImageHeight.value + navigationHeight.value + val bodyModifier = if (isEnabled) { + Modifier .offset { IntOffset( x = 0, - y = (expandedTopHeight.value + backgroundImageHeight.value + navigationHeight.value).roundToInt() + y = bodyPadding.roundToInt() ) } - .padding(bottom = with(localDensity) { (expandedTopHeight.value + navigationHeight.value + backgroundImageHeight.value).toDp() }), + .padding(bottom = with(localDensity) { bodyPadding.toDp() }) + } else { + Modifier + .padding(top = with(localDensity) { if (bodyPadding < 0) 0.toDp() else bodyPadding.toDp() }) + } + Box( + modifier = bodyModifier, content = bodyContent, ) } @@ -432,6 +455,7 @@ private fun CollapsingLayoutMobile( courseImage: Bitmap, imageHeight: Int, toolbarBackgroundOffset: Int, + isEnabled: Boolean, onBackClick: () -> Unit, expandedTop: @Composable BoxScope.() -> Unit, collapsedTop: @Composable BoxScope.() -> Unit, @@ -517,7 +541,7 @@ private fun CollapsingLayoutMobile( }, imageVector = Icons.AutoMirrored.Filled.ArrowBack, tint = MaterialTheme.appColors.textPrimary, - contentDescription = null + contentDescription = stringResource(id = R.string.core_accessibility_btn_back) ) Spacer(modifier = Modifier.width(8.dp)) Box( @@ -680,7 +704,7 @@ private fun CollapsingLayoutMobile( }, imageVector = Icons.AutoMirrored.Filled.ArrowBack, tint = MaterialTheme.appColors.textPrimary, - contentDescription = null + contentDescription = stringResource(id = R.string.core_accessibility_btn_back) ) Spacer(modifier = Modifier.width(8.dp)) Box( @@ -705,15 +729,23 @@ private fun CollapsingLayoutMobile( content = navigation, ) - Box( - modifier = Modifier + val bodyPadding = + expandedTopHeight.value + offset.value + backgroundImageHeight.value + navigationHeight.value - blurImagePaddingPx * factor + val bodyModifier = if (isEnabled) { + Modifier .offset { IntOffset( x = 0, - y = (expandedTopHeight.value + offset.value + backgroundImageHeight.value + navigationHeight.value - blurImagePaddingPx * factor).roundToInt() + y = bodyPadding.roundToInt() ) } - .padding(bottom = with(localDensity) { (collapsedTopHeight.value + navigationHeight.value).toDp() }), + .padding(bottom = with(localDensity) { (collapsedTopHeight.value + navigationHeight.value).toDp() }) + } else { + Modifier + .padding(top = with(localDensity) { if (bodyPadding < 0) 0.toDp() else bodyPadding.toDp() }) + } + Box( + modifier = bodyModifier, content = bodyContent, ) } @@ -722,8 +754,14 @@ private fun CollapsingLayoutMobile( @OptIn(ExperimentalFoundationApi::class) @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) -@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, device = "spec:parent=pixel_5,orientation=landscape") -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = "spec:parent=pixel_5,orientation=landscape") +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_NO, + device = "spec:parent=pixel_5,orientation=landscape" +) +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES, + device = "spec:parent=pixel_5,orientation=landscape" +) @Preview(device = Devices.NEXUS_9, uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(device = Devices.NEXUS_9, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable @@ -748,10 +786,10 @@ private fun CollapsingLayoutPreview() { RoundTabsBar( items = CourseContainerTab.entries, rowState = rememberLazyListState(), - pagerState = rememberPagerState(pageCount = { 5 }), - onPageChange = { } + pagerState = rememberPagerState(pageCount = { CourseContainerTab.entries.size }) ) }, + isEnabled = true, onBackClick = {}, bodyContent = {} ) @@ -760,7 +798,7 @@ private fun CollapsingLayoutPreview() { suspend fun PointerInputScope.routePointerChangesTo( onDown: (PointerInputChange) -> Unit = {}, - onUp: (PointerInputChange) -> Unit = {} + onUp: (PointerInputChange) -> Unit = {}, ) { awaitEachGesture { do { @@ -778,7 +816,7 @@ suspend fun PointerInputScope.routePointerChangesTo( @Immutable data class PixelAlignment( val offsetX: Float, - val offsetY: Float + val offsetY: Float, ) : Alignment { override fun align(size: IntSize, space: IntSize, layoutDirection: LayoutDirection): IntOffset { diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt index c44733948..c6f452c10 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt @@ -1,20 +1,29 @@ package org.openedx.course.presentation.container +import android.content.res.Configuration +import android.os.Build import android.os.Bundle +import android.util.Log import android.view.View import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold @@ -22,6 +31,7 @@ import androidx.compose.material.SnackbarData import androidx.compose.material.SnackbarDuration import androidx.compose.material.SnackbarHost import androidx.compose.material.SnackbarHostState +import androidx.compose.material.Text import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState @@ -30,6 +40,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -37,12 +48,20 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.os.bundleOf import androidx.core.view.isVisible import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentManager import androidx.lifecycle.lifecycleScope import com.google.android.material.snackbar.Snackbar @@ -51,28 +70,33 @@ 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.domain.model.CourseAccessError +import org.openedx.core.extension.isFalse import org.openedx.core.presentation.global.viewBinding import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OfflineModeDialog +import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.RoundTabsBar -import org.openedx.core.ui.WindowSize -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.utils.TimeUtils import org.openedx.course.DatesShiftedSnackBar import org.openedx.course.R import org.openedx.course.databinding.FragmentCourseContainerBinding -import org.openedx.course.presentation.calendarsync.CalendarSyncDialog -import org.openedx.course.presentation.calendarsync.CalendarSyncDialogType import org.openedx.course.presentation.dates.CourseDatesScreen import org.openedx.course.presentation.handouts.HandoutsScreen import org.openedx.course.presentation.handouts.HandoutsType +import org.openedx.course.presentation.offline.CourseOfflineScreen import org.openedx.course.presentation.outline.CourseOutlineScreen import org.openedx.course.presentation.ui.CourseVideosScreen import org.openedx.course.presentation.ui.DatesShiftedSnackBar import org.openedx.discussion.presentation.topics.DiscussionTopicsScreen +import org.openedx.foundation.extension.takeIfNotEmpty +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.rememberWindowSize +import java.util.Date class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { @@ -82,22 +106,19 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { parametersOf( requireArguments().getString(ARG_COURSE_ID, ""), requireArguments().getString(ARG_TITLE, ""), - requireArguments().getString(ARG_ENROLLMENT_MODE, "") + requireArguments().getString(ARG_RESUME_BLOCK, "") ) } - private val permissionLauncher = registerForActivityResult( - ActivityResultContracts.RequestMultiplePermissions() - ) { isGranted -> - viewModel.logCalendarPermissionAccess(!isGranted.containsValue(false)) - if (!isGranted.containsValue(false)) { - viewModel.setCalendarSyncDialogType(CalendarSyncDialogType.SYNC_DIALOG) - } + private val pushNotificationPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + Log.d(CourseContainerFragment::class.java.simpleName, "Permission granted: $granted") } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - viewModel.preloadCourseStructure() + viewModel.fetchCourseDetails() } private var snackBar: Snackbar? = null @@ -111,6 +132,13 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { observe() } + override fun onResume() { + super.onResume() + if (viewModel.courseAccessStatus.value == CourseAccessError.NONE) { + viewModel.updateData() + } + } + override fun onDestroyView() { snackBar?.dismiss() super.onDestroyView() @@ -118,22 +146,32 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { private fun observe() { viewModel.dataReady.observe(viewLifecycleOwner) { isReady -> - if (isReady == false) { + if (isReady.isFalse()) { viewModel.courseRouter.navigateToNoAccess( requireActivity().supportFragmentManager, viewModel.courseName ) + } else { + if (viewModel.calendarSyncUIState.value.isCalendarSyncEnabled) { + setUpCourseCalendar() + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + pushNotificationPermissionLauncher.launch( + android.Manifest.permission.POST_NOTIFICATIONS + ) + } } } viewModel.errorMessage.observe(viewLifecycleOwner) { snackBar = Snackbar.make(binding.root, it, Snackbar.LENGTH_INDEFINITE) .setAction(org.openedx.core.R.string.core_error_try_again) { - viewModel.preloadCourseStructure() + viewModel.fetchCourseDetails() } snackBar?.show() } - lifecycleScope.launch { + viewLifecycleOwner.lifecycleScope.launch { viewModel.showProgress.collect { binding.progressBar.isVisible = it } @@ -141,18 +179,21 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { } private fun onRefresh(currentPage: Int) { - viewModel.onRefresh(CourseContainerTab.entries[currentPage]) + if (viewModel.courseAccessStatus.value == CourseAccessError.NONE) { + viewModel.onRefresh(CourseContainerTab.entries[currentPage]) + } } private fun initCourseView() { binding.composeCollapsingLayout.setContent { val isNavigationEnabled by viewModel.isNavigationEnabled.collectAsState() + val fm = requireActivity().supportFragmentManager CourseDashboard( viewModel = viewModel, isNavigationEnabled = isNavigationEnabled, isResumed = isResumed, - fragmentManager = requireActivity().supportFragmentManager, - bundle = requireArguments(), + openTab = requireArguments().getString(ARG_OPEN_TAB, CourseContainerTab.HOME.name), + fragmentManager = fm, onRefresh = { page -> onRefresh(page) } @@ -167,84 +208,12 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { OpenEdXTheme { val syncState by viewModel.calendarSyncUIState.collectAsState() - LaunchedEffect(key1 = syncState.checkForOutOfSync) { - if (syncState.isCalendarSyncEnabled && syncState.checkForOutOfSync.get()) { - viewModel.checkIfCalendarOutOfDate() - } - } - LaunchedEffect(syncState.uiMessage.get()) { syncState.uiMessage.get().takeIfNotEmpty()?.let { Snackbar.make(binding.root, it, Snackbar.LENGTH_SHORT).show() syncState.uiMessage.set("") } } - - CalendarSyncDialog( - syncDialogType = syncState.dialogType, - calendarTitle = syncState.calendarTitle, - syncDialogPosAction = { dialog -> - when (dialog) { - CalendarSyncDialogType.SYNC_DIALOG -> { - viewModel.logCalendarAddDates(true) - viewModel.addOrUpdateEventsInCalendar( - updatedEvent = false, - ) - } - - CalendarSyncDialogType.UN_SYNC_DIALOG -> { - viewModel.logCalendarRemoveDates(true) - viewModel.deleteCourseCalendar() - } - - CalendarSyncDialogType.PERMISSION_DIALOG -> { - permissionLauncher.launch(viewModel.calendarPermissions) - } - - CalendarSyncDialogType.OUT_OF_SYNC_DIALOG -> { - viewModel.logCalendarSyncUpdate(true) - viewModel.addOrUpdateEventsInCalendar( - updatedEvent = true, - ) - } - - CalendarSyncDialogType.EVENTS_DIALOG -> { - viewModel.logCalendarSyncedConfirmation(true) - viewModel.openCalendarApp() - } - - else -> {} - } - }, - syncDialogNegAction = { dialog -> - when (dialog) { - CalendarSyncDialogType.SYNC_DIALOG -> - viewModel.logCalendarAddDates(false) - - CalendarSyncDialogType.UN_SYNC_DIALOG -> - viewModel.logCalendarRemoveDates(false) - - CalendarSyncDialogType.OUT_OF_SYNC_DIALOG -> { - viewModel.logCalendarSyncUpdate(false) - viewModel.deleteCourseCalendar() - } - - CalendarSyncDialogType.EVENTS_DIALOG -> - viewModel.logCalendarSyncedConfirmation(false) - - CalendarSyncDialogType.LOADING_DIALOG, - CalendarSyncDialogType.PERMISSION_DIALOG, - CalendarSyncDialogType.NONE, - -> { - } - } - - viewModel.setCalendarSyncDialogType(CalendarSyncDialogType.NONE) - }, - dismissSyncDialog = { - viewModel.setCalendarSyncDialogType(CalendarSyncDialogType.NONE) - } - ) } } } @@ -253,17 +222,20 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { companion object { const val ARG_COURSE_ID = "courseId" const val ARG_TITLE = "title" - const val ARG_ENROLLMENT_MODE = "enrollmentMode" + const val ARG_OPEN_TAB = "open_tab" + const val ARG_RESUME_BLOCK = "resume_block" fun newInstance( courseId: String, courseTitle: String, - enrollmentMode: String, + openTab: String = CourseContainerTab.HOME.name, + resumeBlockId: String = "", ): CourseContainerFragment { val fragment = CourseContainerFragment() fragment.arguments = bundleOf( ARG_COURSE_ID to courseId, ARG_TITLE to courseTitle, - ARG_ENROLLMENT_MODE to enrollmentMode + ARG_OPEN_TAB to openTab, + ARG_RESUME_BLOCK to resumeBlockId ) return fragment } @@ -274,11 +246,11 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { @Composable fun CourseDashboard( viewModel: CourseContainerViewModel, - onRefresh: (page: Int) -> Unit, isNavigationEnabled: Boolean, isResumed: Boolean, + openTab: String, fragmentManager: FragmentManager, - bundle: Bundle + onRefresh: (page: Int) -> Unit, ) { OpenEdXTheme { val windowSize = rememberWindowSize() @@ -294,7 +266,20 @@ fun CourseDashboard( 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 requiredTab = when (openTab.uppercase()) { + CourseContainerTab.HOME.name -> CourseContainerTab.HOME + CourseContainerTab.VIDEOS.name -> CourseContainerTab.VIDEOS + CourseContainerTab.DATES.name -> CourseContainerTab.DATES + CourseContainerTab.DISCUSSIONS.name -> CourseContainerTab.DISCUSSIONS + CourseContainerTab.MORE.name -> CourseContainerTab.MORE + else -> CourseContainerTab.HOME + } + + val pagerState = rememberPagerState( + initialPage = CourseContainerTab.entries.indexOf(requiredTab), + pageCount = { CourseContainerTab.entries.size } + ) + val accessStatus = viewModel.courseAccessStatus.observeAsState() val tabState = rememberLazyListState() val snackState = remember { SnackbarHostState() } val pullRefreshState = rememberPullRefreshState( @@ -316,89 +301,116 @@ fun CourseDashboard( tabState.animateScrollToItem(pagerState.currentPage) } - Box { - CollapsingLayout( - modifier = Modifier - .fillMaxWidth() - .padding(paddingValues) - .pullRefresh(pullRefreshState), - courseImage = courseImage, - imageHeight = 200, - expandedTop = { - ExpandedHeaderContent( - courseTitle = viewModel.courseName, - org = viewModel.organization - ) - }, - collapsedTop = { - CollapsedHeaderContent( - courseTitle = viewModel.courseName - ) - }, - navigation = { - if (isNavigationEnabled) { - RoundTabsBar( - items = CourseContainerTab.entries, - 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 + Column( + modifier = Modifier.fillMaxSize() + ) { + Box( + modifier = Modifier.weight(1f) + ) { + CollapsingLayout( + modifier = Modifier .fillMaxWidth() - .align(Alignment.BottomCenter), - onDismissCLick = { - isInternetConnectionShown = true + .padding(paddingValues) + .pullRefresh(pullRefreshState), + courseImage = courseImage, + imageHeight = 200, + expandedTop = { + ExpandedHeaderContent( + courseTitle = viewModel.courseName, + org = viewModel.courseDetails?.courseInfoOverview?.org ?: "" + ) }, - 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) + collapsedTop = { + CollapsedHeaderContent( + courseTitle = viewModel.courseName + ) + }, + navigation = { + if (isNavigationEnabled) { + RoundTabsBar( + items = CourseContainerTab.entries, + contentPadding = PaddingValues( + horizontal = 12.dp, + vertical = 16.dp + ), + rowState = tabState, + pagerState = pagerState, + withPager = true, + onTabClicked = viewModel::courseContainerTabClickedEvent + ) + } }, - onClose = { - snackbarData.dismiss() + isEnabled = CourseAccessError.NONE == accessStatus.value, + onBackClick = { + fragmentManager.popBackStack() + }, + bodyContent = { + when (accessStatus.value) { + CourseAccessError.AUDIT_EXPIRED_NOT_UPGRADABLE, + CourseAccessError.NOT_YET_STARTED, + CourseAccessError.UNKNOWN, + -> { + CourseAccessErrorView( + viewModel = viewModel, + accessError = accessStatus.value, + fragmentManager = fragmentManager, + ) + } + + CourseAccessError.NONE -> { + DashboardPager( + windowSize = windowSize, + viewModel = viewModel, + pagerState = pagerState, + isNavigationEnabled = isNavigationEnabled, + isResumed = isResumed, + fragmentManager = fragmentManager, + ) + } + + else -> { + } + } } ) + PullRefreshIndicator( + refreshing, + pullRefreshState, + Modifier.align(Alignment.TopCenter) + ) + + var isInternetConnectionShown by rememberSaveable { + mutableStateOf(false) + } + if (!isInternetConnectionShown && !viewModel.hasInternetConnection) { + OfflineModeDialog( + Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + onDismissCLick = { + isInternetConnectionShown = true + }, + onReloadClick = { + isInternetConnectionShown = true + onRefresh(pagerState.currentPage) + } + ) + } + + SnackbarHost( + modifier = Modifier.align(Alignment.BottomStart), + hostState = snackState + ) { snackbarData: SnackbarData -> + DatesShiftedSnackBar( + showAction = CourseContainerTab.entries[pagerState.currentPage] != CourseContainerTab.DATES, + onViewDates = { + scrollToDates(scope, pagerState) + }, + onClose = { + snackbarData.dismiss() + } + ) + } } } } @@ -407,33 +419,26 @@ fun CourseDashboard( @OptIn(ExperimentalFoundationApi::class) @Composable -fun DashboardPager( +private fun DashboardPager( windowSize: WindowSize, viewModel: CourseContainerViewModel, pagerState: PagerState, isNavigationEnabled: Boolean, isResumed: Boolean, fragmentManager: FragmentManager, - bundle: Bundle, ) { HorizontalPager( state = pagerState, userScrollEnabled = isNavigationEnabled, - beyondBoundsPageCount = CourseContainerTab.entries.size + beyondViewportPageCount = 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, "") - ) - } + viewModel = koinViewModel( + parameters = { parametersOf(viewModel.courseId, viewModel.courseName) } ), - courseRouter = viewModel.courseRouter, fragmentManager = fragmentManager, onResetDatesClick = { viewModel.onRefresh(CourseContainerTab.DATES) @@ -444,30 +449,25 @@ fun DashboardPager( CourseContainerTab.VIDEOS -> { CourseVideosScreen( windowSize = windowSize, - courseVideoViewModel = koinViewModel( - parameters = { - parametersOf( - bundle.getString(CourseContainerFragment.ARG_COURSE_ID, ""), - bundle.getString(CourseContainerFragment.ARG_TITLE, "") - ) - } + viewModel = koinViewModel( + parameters = { parametersOf(viewModel.courseId, viewModel.courseName) } ), - fragmentManager = fragmentManager, - courseRouter = viewModel.courseRouter, + fragmentManager = fragmentManager ) } CourseContainerTab.DATES -> { CourseDatesScreen( - courseDatesViewModel = koinViewModel( + viewModel = koinViewModel( parameters = { parametersOf( - bundle.getString(CourseContainerFragment.ARG_ENROLLMENT_MODE, "") + viewModel.courseId, + viewModel.courseName, + viewModel.courseDetails?.enrollmentDetails?.mode ?: "" ) } ), windowSize = windowSize, - courseRouter = viewModel.courseRouter, fragmentManager = fragmentManager, isFragmentResumed = isResumed, updateCourseStructure = { @@ -476,31 +476,40 @@ fun DashboardPager( ) } + CourseContainerTab.OFFLINE -> { + CourseOfflineScreen( + windowSize = windowSize, + viewModel = koinViewModel( + parameters = { parametersOf(viewModel.courseId, viewModel.courseName) } + ), + fragmentManager = fragmentManager, + ) + } + CourseContainerTab.DISCUSSIONS -> { DiscussionTopicsScreen( + discussionTopicsViewModel = koinViewModel( + parameters = { parametersOf(viewModel.courseId, viewModel.courseName) } + ), 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, + viewModel.courseId, HandoutsType.Handouts ) }, onAnnouncementsClick = { viewModel.courseRouter.navigateToHandoutsWebView( fragmentManager, - bundle.getString(CourseContainerFragment.ARG_COURSE_ID, ""), - announcementsString, + viewModel.courseId, HandoutsType.Announcements ) }) @@ -509,9 +518,128 @@ fun DashboardPager( } } +@Composable +private fun CourseAccessErrorView( + viewModel: CourseContainerViewModel?, + accessError: CourseAccessError?, + fragmentManager: FragmentManager, +) { + var icon: Painter = painterResource(id = R.drawable.course_ic_circled_arrow_up) + var message = "" + when (accessError) { + CourseAccessError.AUDIT_EXPIRED_NOT_UPGRADABLE -> { + message = stringResource( + R.string.course_error_expired_not_upgradeable_title, + TimeUtils.getCourseAccessFormattedDate( + LocalContext.current, + viewModel?.courseDetails?.courseAccessDetails?.auditAccessExpires ?: Date() + ) + ) + } + + CourseAccessError.NOT_YET_STARTED -> { + icon = painterResource(id = R.drawable.course_ic_calendar) + message = stringResource( + R.string.course_error_not_started_title, + viewModel?.courseDetails?.courseInfoOverview?.startDisplay ?: "" + ) + } + + CourseAccessError.UNKNOWN -> { + icon = painterResource(id = R.drawable.course_ic_not_supported_block) + message = stringResource(R.string.course_an_error_occurred) + } + + else -> {} + } + + + Box( + modifier = Modifier + .fillMaxSize() + .statusBarsInset() + .background(MaterialTheme.appColors.background), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + if (LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT) { + Image( + modifier = Modifier + .size(96.dp) + .padding(bottom = 12.dp), + painter = icon, + contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.appColors.progressBarBackgroundColor), + ) + } + Text( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp), + textAlign = TextAlign.Center, + text = message, + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.textDark + ) + } + SetupCourseAccessErrorButtons( + accessError = accessError, + fragmentManager = fragmentManager, + ) + } + } +} + +@Composable +private fun SetupCourseAccessErrorButtons( + accessError: CourseAccessError?, + fragmentManager: FragmentManager, +) { + when (accessError) { + CourseAccessError.AUDIT_EXPIRED_NOT_UPGRADABLE, + CourseAccessError.NOT_YET_STARTED, + CourseAccessError.UNKNOWN, + -> { + OpenEdXButton( + text = stringResource(R.string.course_label_back), + onClick = { fragmentManager.popBackStack() }, + ) + } + + else -> {} + } +} + @OptIn(ExperimentalFoundationApi::class) private fun scrollToDates(scope: CoroutineScope, pagerState: PagerState) { scope.launch { pagerState.animateScrollToPage(CourseContainerTab.entries.indexOf(CourseContainerTab.DATES)) } } + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun CourseAccessErrorViewPreview() { + val context = LocalContext.current + OpenEdXTheme { + CourseAccessErrorView( + viewModel = null, + accessError = CourseAccessError.AUDIT_EXPIRED_NOT_UPGRADABLE, + fragmentManager = (context as? FragmentActivity)?.supportFragmentManager!! + ) + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerTab.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerTab.kt new file mode 100644 index 000000000..255b7e88b --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerTab.kt @@ -0,0 +1,26 @@ +package org.openedx.course.presentation.container + +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.outlined.CloudDownload +import androidx.compose.material.icons.rounded.PlayCircleFilled +import androidx.compose.ui.graphics.vector.ImageVector +import org.openedx.core.ui.TabItem +import org.openedx.course.R + +enum class CourseContainerTab( + @StringRes + override val labelResId: Int, + override val icon: ImageVector, +) : TabItem { + HOME(R.string.course_container_nav_home, Icons.Default.Home), + VIDEOS(R.string.course_container_nav_videos, Icons.Rounded.PlayCircleFilled), + DATES(R.string.course_container_nav_dates, Icons.Outlined.CalendarMonth), + OFFLINE(R.string.course_container_nav_downloads, Icons.Outlined.CloudDownload), + DISCUSSIONS(R.string.course_container_nav_discussions, Icons.AutoMirrored.Filled.Chat), + MORE(R.string.course_container_nav_more, Icons.AutoMirrored.Filled.TextSnippet) +} diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt index c61d7e165..a743730ec 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt @@ -3,11 +3,12 @@ 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.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -17,65 +18,67 @@ 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.domain.model.CourseAccessError +import org.openedx.core.domain.model.CourseEnrollmentDetails 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.extension.isFalse +import org.openedx.core.extension.isTrue +import org.openedx.core.presentation.settings.calendarsync.CalendarSyncDialogType +import org.openedx.core.presentation.settings.calendarsync.CalendarSyncUIState import org.openedx.core.system.connection.NetworkConnection -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.CourseOpenBlock import org.openedx.core.system.notifier.CourseStructureUpdated -import org.openedx.core.utils.TimeUtils +import org.openedx.core.system.notifier.RefreshDates +import org.openedx.core.system.notifier.RefreshDiscussions +import org.openedx.core.worker.CalendarSyncScheduler import org.openedx.course.DatesShiftedSnackBar -import org.openedx.course.R -import org.openedx.course.data.storage.CoursePreferences import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CalendarSyncDialog -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 -import java.util.Date +import org.openedx.course.utils.ImageProcessor +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.extension.toImageLink +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.SingleEventLiveData +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import java.util.concurrent.atomic.AtomicReference import org.openedx.core.R as CoreR class CourseContainerViewModel( val courseId: String, var courseName: String, - private val enrollmentMode: String, + private var resumeBlockId: String, private val config: Config, private val interactor: CourseInteractor, - private val calendarManager: CalendarManager, private val resourceManager: ResourceManager, private val courseNotifier: CourseNotifier, private val networkConnection: NetworkConnection, private val corePreferences: CorePreferences, - private val coursePreferences: CoursePreferences, private val courseAnalytics: CourseAnalytics, private val imageProcessor: ImageProcessor, - val courseRouter: CourseRouter + private val calendarSyncScheduler: CalendarSyncScheduler, + val courseRouter: CourseRouter, ) : BaseViewModel() { private val _dataReady = MutableLiveData() val dataReady: LiveData get() = _dataReady + private val _courseAccessStatus = MutableLiveData() + val courseAccessStatus: LiveData + get() = _courseAccessStatus + private val _errorMessage = SingleEventLiveData() val errorMessage: LiveData get() = _errorMessage @@ -96,21 +99,13 @@ class CourseContainerViewModel( val uiMessage: SharedFlow get() = _uiMessage.asSharedFlow() - private var _isSelfPaced: Boolean = true - val isSelfPaced: Boolean - get() = _isSelfPaced - - private var _organization: String = "" - val organization: String - get() = _organization - - val calendarPermissions: Array - get() = calendarManager.permissions + private var _courseDetails: CourseEnrollmentDetails? = null + val courseDetails: CourseEnrollmentDetails? + get() = _courseDetails private val _calendarSyncUIState = MutableStateFlow( CalendarSyncUIState( isCalendarSyncEnabled = isCalendarSyncEnabled(), - calendarTitle = calendarManager.getCourseCalendarTitle(courseName), courseDates = emptyList(), dialogType = CalendarSyncDialogType.NONE, checkForOutOfSync = AtomicReference(false), @@ -135,6 +130,10 @@ class CourseContainerViewModel( } is CreateCalendarSyncEvent -> { + // Skip out-of-sync check if any calendar dialog is visible + if (event.checkOutOfSync && _calendarSyncUIState.value.isDialogVisible) { + return@collect + } _calendarSyncUIState.update { val dialogType = CalendarSyncDialogType.valueOf(event.dialogType) it.copy( @@ -146,6 +145,7 @@ class CourseContainerViewModel( } is CourseDatesShifted -> { + calendarSyncScheduler.requestImmediateSync(courseId) _uiMessage.emit(DatesShiftedSnackBar()) } @@ -160,7 +160,7 @@ class CourseContainerViewModel( } } - fun preloadCourseStructure() { + fun fetchCourseDetails() { courseDashboardViewed() if (_dataReady.value != null) { return @@ -169,39 +169,58 @@ class CourseContainerViewModel( _showProgress.value = true viewModelScope.launch { try { - if (networkConnection.isOnline()) { - interactor.preloadCourseStructure(courseId) - } else { - interactor.preloadCourseStructureFromCache(courseId) + val deferredCourse = async(SupervisorJob()) { + interactor.getCourseStructure(courseId, isNeedRefresh = true) + } + val deferredEnrollment = async(SupervisorJob()) { + interactor.getEnrollmentDetails(courseId) } - val courseStructure = interactor.getCourseStructureFromCache() - courseName = courseStructure.name - _organization = courseStructure.org - _isSelfPaced = courseStructure.isSelfPaced - loadCourseImage(courseStructure.media?.image?.large) - _dataReady.value = courseStructure.start?.let { start -> - val isReady = start < Date() - if (isReady) { + val (_, enrollment) = awaitAll(deferredCourse, deferredEnrollment) + _courseDetails = enrollment as? CourseEnrollmentDetails + _showProgress.value = false + _courseDetails?.let { courseDetails -> + courseName = courseDetails.courseInfoOverview.name + loadCourseImage(courseDetails.courseInfoOverview.media?.image?.large) + if (courseDetails.hasAccess.isFalse()) { + _dataReady.value = false + if (courseDetails.isAuditAccessExpired) { + _courseAccessStatus.value = + CourseAccessError.AUDIT_EXPIRED_NOT_UPGRADABLE + } else if (courseDetails.courseInfoOverview.isStarted.not()) { + _courseAccessStatus.value = CourseAccessError.NOT_YET_STARTED + } else { + _courseAccessStatus.value = CourseAccessError.UNKNOWN + } + } else { + _courseAccessStatus.value = CourseAccessError.NONE _isNavigationEnabled.value = true - courseNotifier.send(CourseDataReady(courseStructure)) + _calendarSyncUIState.update { state -> + state.copy(isCalendarSyncEnabled = isCalendarSyncEnabled()) + } + if (resumeBlockId.isNotEmpty()) { + delay(500L) + courseNotifier.send(CourseOpenBlock(resumeBlockId)) + } } - isReady + } ?: run { + _courseAccessStatus.value = CourseAccessError.UNKNOWN } } catch (e: Exception) { + e.printStackTrace() if (e.isInternetError() || e is NoCachedDataException) { _errorMessage.value = resourceManager.getString(CoreR.string.core_error_no_connection) } else { - _errorMessage.value = - resourceManager.getString(CoreR.string.core_error_unknown_error) + _courseAccessStatus.value = CourseAccessError.UNKNOWN } + _showProgress.value = false } } } private fun loadCourseImage(imageUrl: String?) { imageProcessor.loadImage( - imageUrl = config.getApiHostURL() + imageUrl, + imageUrl = imageUrl?.toImageLink(config.getApiHostURL()) ?: "", defaultImage = CoreR.drawable.core_no_image_course, onComplete = { drawable -> val bitmap = (drawable as BitmapDrawable).bitmap.apply { @@ -227,15 +246,19 @@ class CourseContainerViewModel( updateData() } + CourseContainerTab.OFFLINE -> { + updateData() + } + CourseContainerTab.DATES -> { viewModelScope.launch { - courseNotifier.send(CourseRefresh(courseContainerTab)) + courseNotifier.send(RefreshDates) } } CourseContainerTab.DISCUSSIONS -> { viewModelScope.launch { - courseNotifier.send(CourseRefresh(courseContainerTab)) + courseNotifier.send(RefreshDiscussions) } } @@ -248,7 +271,7 @@ class CourseContainerViewModel( fun updateData() { viewModelScope.launch { try { - interactor.preloadCourseStructure(courseId) + interactor.getCourseStructure(courseId, isNeedRefresh = true) } catch (e: Exception) { if (e.isInternetError()) { _errorMessage.value = @@ -270,10 +293,10 @@ class CourseContainerViewModel( CourseContainerTab.DISCUSSIONS -> discussionTabClickedEvent() CourseContainerTab.DATES -> datesTabClickedEvent() CourseContainerTab.MORE -> moreTabClickedEvent() + CourseContainerTab.OFFLINE -> {} } } - fun setCalendarSyncDialogType(dialogType: CalendarSyncDialogType) { val currentState = _calendarSyncUIState.value if (currentState.dialogType != dialogType) { @@ -281,117 +304,10 @@ class CourseContainerViewModel( } } - fun addOrUpdateEventsInCalendar( - updatedEvent: Boolean, - ) { - setCalendarSyncDialogType(CalendarSyncDialogType.LOADING_DIALOG) - - val startSyncTime = TimeUtils.getCurrentTime() - val calendarId = getCalendarId() - - if (calendarId == CalendarManager.CALENDAR_DOES_NOT_EXIST) { - setUiMessage(R.string.course_snackbar_course_calendar_error) - setCalendarSyncDialogType(CalendarSyncDialogType.NONE) - - return - } - - viewModelScope.launch(Dispatchers.IO) { - val courseDates = _calendarSyncUIState.value.courseDates - if (courseDates.isNotEmpty()) { - courseDates.forEach { courseDateBlock -> - calendarManager.addEventsIntoCalendar( - calendarId = calendarId, - courseId = courseId, - courseName = courseName, - courseDateBlock = courseDateBlock - ) - } - } - val elapsedSyncTime = TimeUtils.getCurrentTime() - startSyncTime - val delayRemaining = maxOf(0, 1000 - elapsedSyncTime) - - // Ensure minimum 1s delay to prevent flicker for rapid event creation - if (delayRemaining > 0) { - delay(delayRemaining) - } - - setCalendarSyncDialogType(CalendarSyncDialogType.NONE) - updateCalendarSyncState() - - if (updatedEvent) { - logCalendarSyncSnackbar(CalendarSyncSnackbar.UPDATED) - setUiMessage(R.string.course_snackbar_course_calendar_updated) - } else if (coursePreferences.isCalendarSyncEventsDialogShown(courseName)) { - logCalendarSyncSnackbar(CalendarSyncSnackbar.ADDED) - setUiMessage(R.string.course_snackbar_course_calendar_added) - } else { - coursePreferences.setCalendarSyncEventsDialogShown(courseName) - setCalendarSyncDialogType(CalendarSyncDialogType.EVENTS_DIALOG) - } - } - } - - private fun updateCalendarSyncState() { - viewModelScope.launch { - val isCalendarSynced = calendarManager.isCalendarExists( - calendarTitle = _calendarSyncUIState.value.calendarTitle - ) - courseNotifier.send(CheckCalendarSyncEvent(isSynced = isCalendarSynced)) - } - } - - fun checkIfCalendarOutOfDate() { - val courseDates = _calendarSyncUIState.value.courseDates - if (courseDates.isNotEmpty()) { - _calendarSyncUIState.value.checkForOutOfSync.set(false) - val outdatedCalendarId = calendarManager.isCalendarOutOfDate( - calendarTitle = _calendarSyncUIState.value.calendarTitle, - courseDateBlocks = courseDates - ) - if (outdatedCalendarId != CalendarManager.CALENDAR_DOES_NOT_EXIST) { - setCalendarSyncDialogType(CalendarSyncDialogType.OUT_OF_SYNC_DIALOG) - } - } - } - - fun deleteCourseCalendar() { - if (calendarManager.hasPermissions()) { - viewModelScope.launch(Dispatchers.IO) { - val calendarId = getCalendarId() - if (calendarId != CalendarManager.CALENDAR_DOES_NOT_EXIST) { - calendarManager.deleteCalendar( - calendarId = calendarId, - ) - } - updateCalendarSyncState() - - } - logCalendarSyncSnackbar(CalendarSyncSnackbar.REMOVED) - setUiMessage(R.string.course_snackbar_course_calendar_removed) - } - } - - fun openCalendarApp() { - calendarManager.openCalendarApp() - } - - private fun setUiMessage(@StringRes stringResId: Int) { - _calendarSyncUIState.update { - it.copy(uiMessage = AtomicReference(resourceManager.getString(stringResId))) - } - } - - private fun getCalendarId(): Long { - return calendarManager.createOrUpdateCalendar( - calendarTitle = _calendarSyncUIState.value.calendarTitle - ) - } - private fun isCalendarSyncEnabled(): Boolean { val calendarSync = corePreferences.appConfig.courseDatesCalendarSync - return calendarSync.isEnabled && ((calendarSync.isSelfPacedEnabled && isSelfPaced) || - (calendarSync.isInstructorPacedEnabled && !isSelfPaced)) + return calendarSync.isEnabled && ((calendarSync.isSelfPacedEnabled && _courseDetails?.courseInfoOverview?.isSelfPaced.isTrue()) || + (calendarSync.isInstructorPacedEnabled && _courseDetails?.courseInfoOverview?.isSelfPaced.isFalse())) } private fun courseDashboardViewed() { @@ -419,8 +335,8 @@ class CourseContainerViewModel( } private fun logCourseContainerEvent(event: CourseAnalyticsEvent) { - courseAnalytics.logEvent( - event = event.eventName, + courseAnalytics.logScreenEvent( + screenName = event.eventName, params = buildMap { put(CourseAnalyticsKey.NAME.key, event.biValue) put(CourseAnalyticsKey.COURSE_ID.key, courseId) @@ -436,41 +352,6 @@ class CourseContainerViewModel( ) } - fun logCalendarAddDates(action: Boolean) { - logCalendarSyncEvent( - CourseAnalyticsEvent.DATES_CALENDAR_SYNC_DIALOG_ACTION, - CalendarSyncDialog.ADD.getBuildMap(action) - ) - } - - fun logCalendarRemoveDates(action: Boolean) { - logCalendarSyncEvent( - CourseAnalyticsEvent.DATES_CALENDAR_SYNC_DIALOG_ACTION, - CalendarSyncDialog.REMOVE.getBuildMap(action) - ) - } - - fun logCalendarSyncedConfirmation(action: Boolean) { - logCalendarSyncEvent( - CourseAnalyticsEvent.DATES_CALENDAR_SYNC_DIALOG_ACTION, - CalendarSyncDialog.CONFIRMED.getBuildMap(action) - ) - } - - fun logCalendarSyncUpdate(action: Boolean) { - logCalendarSyncEvent( - CourseAnalyticsEvent.DATES_CALENDAR_SYNC_DIALOG_ACTION, - CalendarSyncDialog.UPDATE.getBuildMap(action) - ) - } - - private fun logCalendarSyncSnackbar(snackbar: CalendarSyncSnackbar) { - logCalendarSyncEvent( - CourseAnalyticsEvent.DATES_CALENDAR_SYNC_SNACKBAR, - snackbar.getBuildMap() - ) - } - private fun logCalendarSyncEvent( event: CourseAnalyticsEvent, param: Map = emptyMap(), @@ -480,10 +361,13 @@ class CourseContainerViewModel( params = buildMap { put(CourseAnalyticsKey.NAME.key, event.biValue) put(CourseAnalyticsKey.COURSE_ID.key, courseId) - put(CourseAnalyticsKey.ENROLLMENT_MODE.key, enrollmentMode) + put( + CourseAnalyticsKey.ENROLLMENT_MODE.key, + _courseDetails?.enrollmentDetails?.mode ?: "" + ) put( CourseAnalyticsKey.PACING.key, - if (isSelfPaced) CourseAnalyticsKey.SELF_PACED.key + if (_courseDetails?.courseInfoOverview?.isSelfPaced.isTrue()) CourseAnalyticsKey.SELF_PACED.key else CourseAnalyticsKey.INSTRUCTOR_PACED.key ) putAll(param) diff --git a/course/src/main/java/org/openedx/course/presentation/container/HeaderContent.kt b/course/src/main/java/org/openedx/course/presentation/container/HeaderContent.kt index a2070eb66..5b1625d49 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/HeaderContent.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/HeaderContent.kt @@ -11,10 +11,10 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography +import org.openedx.foundation.presentation.rememberWindowSize @Composable internal fun ExpandedHeaderContent( diff --git a/course/src/main/java/org/openedx/course/presentation/container/NoAccessCourseContainerFragment.kt b/course/src/main/java/org/openedx/course/presentation/container/NoAccessCourseContainerFragment.kt index f6f5d8e7d..e9b3b2e89 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/NoAccessCourseContainerFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/NoAccessCourseContainerFragment.kt @@ -4,10 +4,24 @@ import android.content.res.Configuration import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup -import androidx.compose.foundation.layout.* -import androidx.compose.material.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Error +import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -25,12 +39,15 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import androidx.core.os.bundleOf import androidx.fragment.app.Fragment -import org.openedx.core.extension.parcelable -import org.openedx.core.ui.* +import org.openedx.core.ui.BackBtn +import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography -import java.util.* +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue import org.openedx.course.R as courseR class NoAccessCourseContainerFragment : Fragment() { diff --git a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt index 715584497..adb633b98 100644 --- a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt @@ -33,17 +33,15 @@ import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold import androidx.compose.material.Surface -import androidx.compose.material.Switch -import androidx.compose.material.SwitchDefaults import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -56,89 +54,87 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentManager -import org.openedx.core.UIMessage +import org.openedx.core.NoContentScreenType import org.openedx.core.data.model.DateType import org.openedx.core.domain.model.CourseDateBlock import org.openedx.core.domain.model.CourseDatesBannerInfo import org.openedx.core.domain.model.CourseDatesResult import org.openedx.core.domain.model.DatesSection -import org.openedx.core.extension.isNotEmptyThenLet import org.openedx.core.presentation.CoreAnalyticsScreen import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.presentation.dialog.alert.ActionDialogFragment +import org.openedx.core.presentation.settings.calendarsync.CalendarSyncState +import org.openedx.core.ui.CircularProgress import org.openedx.core.ui.HandleUIMessage -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType +import org.openedx.core.ui.NoContentScreen import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors -import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography -import org.openedx.core.ui.windowSizeValue import org.openedx.core.utils.TimeUtils +import org.openedx.core.utils.TimeUtils.formatToString import org.openedx.core.utils.clearTime -import org.openedx.course.R -import org.openedx.course.presentation.CourseRouter -import org.openedx.course.presentation.calendarsync.CalendarSyncUIState import org.openedx.course.presentation.ui.CourseDatesBanner import org.openedx.course.presentation.ui.CourseDatesBannerTablet -import java.util.concurrent.atomic.AtomicReference +import org.openedx.foundation.extension.isNotEmptyThenLet +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.windowSizeValue +import java.util.Date import org.openedx.core.R as CoreR @Composable fun CourseDatesScreen( windowSize: WindowSize, - courseDatesViewModel: CourseDatesViewModel, - courseRouter: CourseRouter, + viewModel: CourseDatesViewModel, fragmentManager: FragmentManager, isFragmentResumed: Boolean, updateCourseStructure: () -> Unit ) { - val uiState by courseDatesViewModel.uiState.observeAsState(DatesUIState.Loading) - val uiMessage by courseDatesViewModel.uiMessage.collectAsState(null) - val calendarSyncUIState by courseDatesViewModel.calendarSyncUIState.collectAsState() + val uiState by viewModel.uiState.collectAsState(CourseDatesUIState.Loading) + val uiMessage by viewModel.uiMessage.collectAsState(null) val context = LocalContext.current CourseDatesUI( windowSize = windowSize, uiState = uiState, uiMessage = uiMessage, - isSelfPaced = courseDatesViewModel.isSelfPaced, - calendarSyncUIState = calendarSyncUIState, + isSelfPaced = viewModel.isSelfPaced, + useRelativeDates = viewModel.useRelativeDates, onItemClick = { block -> if (block.blockId.isNotEmpty()) { - courseDatesViewModel.getVerticalBlock(block.blockId) + viewModel.getVerticalBlock(block.blockId) ?.let { verticalBlock -> - courseDatesViewModel.logCourseComponentTapped(true, block) - if (courseDatesViewModel.isCourseExpandableSectionsEnabled) { - courseRouter.navigateToCourseContainer( + viewModel.logCourseComponentTapped(true, block) + if (viewModel.isCourseExpandableSectionsEnabled) { + viewModel.courseRouter.navigateToCourseContainer( fm = fragmentManager, - courseId = courseDatesViewModel.courseId, + courseId = viewModel.courseId, unitId = verticalBlock.id, componentId = "", mode = CourseViewMode.FULL ) } else { - courseDatesViewModel.getSequentialBlock(verticalBlock.id) + viewModel.getSequentialBlock(verticalBlock.id) ?.let { sequentialBlock -> - courseRouter.navigateToCourseSubsections( + viewModel.courseRouter.navigateToCourseSubsections( fm = fragmentManager, subSectionId = sequentialBlock.id, - courseId = courseDatesViewModel.courseId, + courseId = viewModel.courseId, unitId = verticalBlock.id, mode = CourseViewMode.FULL ) } } } ?: { - courseDatesViewModel.logCourseComponentTapped(false, block) + viewModel.logCourseComponentTapped(false, block) ActionDialogFragment.newInstance( title = context.getString(CoreR.string.core_leaving_the_app), message = context.getString( @@ -157,35 +153,35 @@ fun CourseDatesScreen( }, onPLSBannerViewed = { if (isFragmentResumed) { - courseDatesViewModel.logPlsBannerViewed() + viewModel.logPlsBannerViewed() } }, onSyncDates = { - courseDatesViewModel.logPlsShiftButtonClicked() - courseDatesViewModel.resetCourseDatesBanner { - courseDatesViewModel.logPlsShiftDates(it) + viewModel.logPlsShiftButtonClicked() + viewModel.resetCourseDatesBanner { + viewModel.logPlsShiftDates(it) if (it) { updateCourseStructure() } } }, - onCalendarSyncSwitch = { isChecked -> - courseDatesViewModel.handleCalendarSyncState(isChecked) - }, + onCalendarSyncStateClick = { + viewModel.calendarRouter.navigateToCalendarSettings(fragmentManager) + } ) } @Composable private fun CourseDatesUI( windowSize: WindowSize, - uiState: DatesUIState, + uiState: CourseDatesUIState, uiMessage: UIMessage?, isSelfPaced: Boolean, - calendarSyncUIState: CalendarSyncUIState, + useRelativeDates: Boolean, onItemClick: (CourseDateBlock) -> Unit, onPLSBannerViewed: () -> Unit, onSyncDates: () -> Unit, - onCalendarSyncSwitch: (Boolean) -> Unit = {}, + onCalendarSyncStateClick: () -> Unit, ) { val scaffoldState = rememberScaffoldState() @@ -214,6 +210,17 @@ private fun CourseDatesUI( HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) + val isPLSBannerAvailable = (uiState as? CourseDatesUIState.CourseDates) + ?.courseDatesResult + ?.courseBanner + ?.isBannerAvailableForUserType(isSelfPaced) + + LaunchedEffect(key1 = isPLSBannerAvailable) { + if (isPLSBannerAvailable == true) { + onPLSBannerViewed() + } + } + Box( modifier = Modifier .fillMaxSize() @@ -229,7 +236,7 @@ private fun CourseDatesUI( .fillMaxWidth() ) { when (uiState) { - is DatesUIState.Dates -> { + is CourseDatesUIState.CourseDates -> { LazyColumn( modifier = Modifier .fillMaxSize() @@ -239,19 +246,8 @@ private fun CourseDatesUI( val courseBanner = uiState.courseDatesResult.courseBanner val datesSection = uiState.courseDatesResult.datesSection - if (calendarSyncUIState.isCalendarSyncEnabled) { - item { - CalendarSyncCard( - modifier = Modifier.padding(top = 24.dp), - checked = calendarSyncUIState.isSynced, - onCalendarSync = onCalendarSyncSwitch - ) - } - } - if (courseBanner.isBannerAvailableForUserType(isSelfPaced)) { item { - onPLSBannerViewed() if (windowSize.isTablet) { CourseDatesBannerTablet( modifier = Modifier.padding(top = 16.dp), @@ -268,6 +264,46 @@ private fun CourseDatesUI( } } + // Handle calendar sync state + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp) + .background( + MaterialTheme.appColors.cardViewBackground, + MaterialTheme.shapes.medium + ) + .border( + 0.75.dp, + MaterialTheme.appColors.cardViewBorder, + MaterialTheme.shapes.medium + ) + .clickable { + onCalendarSyncStateClick() + } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp, start = 16.dp, end = 8.dp, bottom = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = uiState.calendarSyncState.icon, + tint = uiState.calendarSyncState.tint, + contentDescription = null + ) + Text( + text = stringResource(uiState.calendarSyncState.longTitle), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textDark + ) + } + } + } + // Handle DatesSection.COMPLETED separately datesSection[DatesSection.COMPLETED]?.isNotEmptyThenLet { section -> item { @@ -275,6 +311,7 @@ private fun CourseDatesUI( sectionKey = DatesSection.COMPLETED, sectionDates = section, onItemClick = onItemClick, + useRelativeDates = useRelativeDates ) } } @@ -289,6 +326,7 @@ private fun CourseDatesUI( sectionKey = sectionKey, sectionDates = section, onItemClick = onItemClick, + useRelativeDates = useRelativeDates ) } } @@ -296,22 +334,13 @@ private fun CourseDatesUI( } } - 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 - ) - } + CourseDatesUIState.Error -> { + NoContentScreen(noContentScreenType = NoContentScreenType.COURSE_DATES) } - DatesUIState.Loading -> {} + CourseDatesUIState.Loading -> { + CircularProgress() + } } } } @@ -319,71 +348,10 @@ private fun CourseDatesUI( } } -@Composable -fun CalendarSyncCard( - modifier: Modifier = Modifier, - checked: Boolean, - onCalendarSync: (Boolean) -> Unit, -) { - val cardModifier = modifier - .background( - MaterialTheme.appColors.cardViewBackground, - MaterialTheme.appShapes.material.medium - ) - .border( - 1.dp, - MaterialTheme.appColors.cardViewBorder, - MaterialTheme.appShapes.material.medium - ) - .padding(16.dp) - - Column(modifier = cardModifier) { - Row( - modifier = Modifier - .fillMaxWidth() - .height(40.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - Icon( - painter = painterResource(id = R.drawable.course_ic_calenday_sync), - contentDescription = null, - modifier = Modifier.size(24.dp) - ) - Text( - modifier = Modifier - .padding(start = 8.dp, end = 8.dp) - .weight(1f), - text = stringResource(id = R.string.course_header_sync_to_calendar), - style = MaterialTheme.appTypography.titleMedium, - color = MaterialTheme.appColors.textDark - ) - Switch( - checked = checked, - onCheckedChange = onCalendarSync, - modifier = Modifier.size(48.dp), - colors = SwitchDefaults.colors( - checkedThumbColor = MaterialTheme.appColors.primary, - checkedTrackColor = MaterialTheme.appColors.primary - ) - ) - } - - Text( - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp) - .height(40.dp), - text = stringResource(id = R.string.course_body_sync_to_calendar), - style = MaterialTheme.appTypography.bodyMedium, - color = MaterialTheme.appColors.textDark, - ) - } -} - @Composable fun ExpandableView( sectionKey: DatesSection = DatesSection.NONE, + useRelativeDates: Boolean, sectionDates: List, onItemClick: (CourseDateBlock) -> Unit, ) { @@ -467,6 +435,7 @@ fun ExpandableView( sectionKey = sectionKey, sectionDates = sectionDates, onItemClick = onItemClick, + useRelativeDates = useRelativeDates ) } } @@ -475,6 +444,7 @@ fun ExpandableView( @Composable private fun CourseDateBlockSection( sectionKey: DatesSection = DatesSection.NONE, + useRelativeDates: Boolean, sectionDates: List, onItemClick: (CourseDateBlock) -> Unit, ) { @@ -497,7 +467,7 @@ private fun CourseDateBlockSection( if (sectionKey != DatesSection.COMPLETED) { DateBullet(section = sectionKey) } - DateBlock(dateBlocks = sectionDates, onItemClick = onItemClick) + DateBlock(dateBlocks = sectionDates, onItemClick = onItemClick, useRelativeDates = useRelativeDates) } } } @@ -529,6 +499,7 @@ private fun DateBullet( @Composable private fun DateBlock( dateBlocks: List, + useRelativeDates: Boolean, onItemClick: (CourseDateBlock) -> Unit, ) { Column( @@ -543,7 +514,7 @@ private fun DateBlock( if (index != 0) { canShowDate = (lastAssignmentDate != dateBlock.date) } - CourseDateItem(dateBlock, canShowDate, index != 0, onItemClick) + CourseDateItem(dateBlock, canShowDate, index != 0, useRelativeDates, onItemClick) lastAssignmentDate = dateBlock.date } } @@ -554,8 +525,10 @@ private fun CourseDateItem( dateBlock: CourseDateBlock, canShowDate: Boolean, isMiddleChild: Boolean, + useRelativeDates: Boolean, onItemClick: (CourseDateBlock) -> Unit, ) { + val context = LocalContext.current Column( modifier = Modifier .wrapContentHeight() @@ -565,11 +538,7 @@ private fun CourseDateItem( Spacer(modifier = Modifier.height(20.dp)) } if (canShowDate) { - val timeTitle = if (dateBlock.isTimeDifferenceLessThan24Hours()) { - TimeUtils.getFormattedTime(dateBlock.date) - } else { - TimeUtils.getCourseFormattedDate(LocalContext.current, dateBlock.date) - } + val timeTitle = formatToString(context, dateBlock.date, useRelativeDates) Text( text = timeTitle, style = MaterialTheme.appTypography.labelMedium, @@ -634,6 +603,26 @@ private fun CourseDateItem( } } + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun EmptyCourseDatesScreenPreview() { + OpenEdXTheme { + CourseDatesUI( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + uiState = CourseDatesUIState.Error, + uiMessage = null, + isSelfPaced = true, + useRelativeDates = true, + onItemClick = {}, + onPLSBannerViewed = {}, + onSyncDates = {}, + onCalendarSyncStateClick = {}, + ) + } +} + @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable @@ -641,14 +630,17 @@ private fun CourseDatesScreenPreview() { OpenEdXTheme { CourseDatesUI( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - uiState = DatesUIState.Dates(CourseDatesResult(mockedResponse, mockedCourseBannerInfo)), + uiState = CourseDatesUIState.CourseDates( + CourseDatesResult(mockedResponse, mockedCourseBannerInfo), + CalendarSyncState.SYNCED + ), uiMessage = null, isSelfPaced = true, - calendarSyncUIState = mockCalendarSyncUIState, + useRelativeDates = true, onItemClick = {}, onPLSBannerViewed = {}, onSyncDates = {}, - onCalendarSyncSwitch = {}, + onCalendarSyncStateClick = {}, ) } } @@ -660,14 +652,17 @@ private fun CourseDatesScreenTabletPreview() { OpenEdXTheme { CourseDatesUI( windowSize = WindowSize(WindowType.Medium, WindowType.Medium), - uiState = DatesUIState.Dates(CourseDatesResult(mockedResponse, mockedCourseBannerInfo)), + uiState = CourseDatesUIState.CourseDates( + CourseDatesResult(mockedResponse, mockedCourseBannerInfo), + CalendarSyncState.SYNCED + ), uiMessage = null, isSelfPaced = true, - calendarSyncUIState = mockCalendarSyncUIState, + useRelativeDates = true, onItemClick = {}, onPLSBannerViewed = {}, onSyncDates = {}, - onCalendarSyncSwitch = {}, + onCalendarSyncStateClick = {}, ) } } @@ -703,7 +698,7 @@ private val mockedResponse: LinkedHashMap> = CourseDateBlock( title = "Homework 1: ABCD", description = "After this date, course content will be archived", - date = TimeUtils.iso8601ToDate("2023-10-20T15:08:07Z")!!, + date = Date(), dateType = DateType.ASSIGNMENT_DUE_DATE, ) ) @@ -753,9 +748,3 @@ private val mockedResponse: LinkedHashMap> = ) ) ) - -val mockCalendarSyncUIState = CalendarSyncUIState( - isCalendarSyncEnabled = true, - isSynced = true, - checkForOutOfSync = AtomicReference() -) diff --git a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesUIState.kt b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesUIState.kt new file mode 100644 index 000000000..17f6e3b46 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesUIState.kt @@ -0,0 +1,14 @@ +package org.openedx.course.presentation.dates + +import org.openedx.core.domain.model.CourseDatesResult +import org.openedx.core.presentation.settings.calendarsync.CalendarSyncState + +sealed interface CourseDatesUIState { + data class CourseDates( + val courseDatesResult: CourseDatesResult, + val calendarSyncState: CalendarSyncState, + ) : CourseDatesUIState + + data object Error : CourseDatesUIState + data object Loading : CourseDatesUIState +} diff --git a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt index 79f866ba7..3f716607f 100644 --- a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt @@ -1,7 +1,5 @@ package org.openedx.course.presentation.dates -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -11,114 +9,110 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import org.openedx.core.BaseViewModel +import org.openedx.core.CalendarRouter import org.openedx.core.R -import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.interactor.CalendarInteractor import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.CourseBannerType import org.openedx.core.domain.model.CourseDateBlock +import org.openedx.core.domain.model.CourseStructure import org.openedx.core.extension.getSequentialBlocks import org.openedx.core.extension.getVerticalBlocks -import org.openedx.core.extension.isInternetError -import org.openedx.core.presentation.course.CourseContainerTab -import org.openedx.core.system.ResourceManager -import org.openedx.core.system.notifier.CalendarSyncEvent.CheckCalendarSyncEvent +import org.openedx.core.presentation.settings.calendarsync.CalendarSyncDialogType +import org.openedx.core.presentation.settings.calendarsync.CalendarSyncState 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.CourseRefresh +import org.openedx.core.system.notifier.RefreshDates +import org.openedx.core.system.notifier.calendar.CalendarNotifier import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseAnalyticsEvent import org.openedx.course.presentation.CourseAnalyticsKey -import org.openedx.course.presentation.calendarsync.CalendarManager -import org.openedx.course.presentation.calendarsync.CalendarSyncDialogType -import org.openedx.course.presentation.calendarsync.CalendarSyncUIState +import org.openedx.course.presentation.CourseRouter +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import org.openedx.core.R as CoreR class CourseDatesViewModel( + val courseId: String, private val enrollmentMode: String, private val courseNotifier: CourseNotifier, private val interactor: CourseInteractor, - private val calendarManager: CalendarManager, private val resourceManager: ResourceManager, - private val corePreferences: CorePreferences, private val courseAnalytics: CourseAnalytics, private val config: Config, + private val calendarInteractor: CalendarInteractor, + private val calendarNotifier: CalendarNotifier, + private val corePreferences: CorePreferences, + val courseRouter: CourseRouter, + val calendarRouter: CalendarRouter ) : BaseViewModel() { - var courseId = "" - var courseName = "" var isSelfPaced = true + var useRelativeDates = corePreferences.isRelativeDatesEnabled - private val _uiState = MutableLiveData(DatesUIState.Loading) - val uiState: LiveData - get() = _uiState + private val _uiState = MutableStateFlow(CourseDatesUIState.Loading) + val uiState: StateFlow + get() = _uiState.asStateFlow() private val _uiMessage = MutableSharedFlow() val uiMessage: SharedFlow get() = _uiMessage.asSharedFlow() - private val _calendarSyncUIState = MutableStateFlow( - CalendarSyncUIState( - isCalendarSyncEnabled = isCalendarSyncEnabled(), - calendarTitle = calendarManager.getCourseCalendarTitle(courseName), - isSynced = false, - ) - ) - val calendarSyncUIState: StateFlow = - _calendarSyncUIState.asStateFlow() - private var courseBannerType: CourseBannerType = CourseBannerType.BLANK + private var courseStructure: CourseStructure? = null - val isCourseExpandableSectionsEnabled get() = config.isCourseNestedListEnabled() + val isCourseExpandableSectionsEnabled get() = config.getCourseUIConfig().isCourseDropdownNavigationEnabled init { viewModelScope.launch { 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 + is RefreshDates -> { loadingCourseDatesInternal() - updateAndFetchCalendarSyncState() } } } } + viewModelScope.launch { + calendarNotifier.notifier.collect { + (_uiState.value as? DatesUIState.Dates)?.let { currentUiState -> + val courseDates = currentUiState.courseDatesResult.datesSection.values.flatten() + _uiState.update { + (it as CourseDatesUIState.CourseDates).copy(calendarSyncState = getCalendarState(courseDates)) + } + } + } + } + + loadingCourseDatesInternal() } private fun loadingCourseDatesInternal() { viewModelScope.launch { try { + courseStructure = interactor.getCourseStructure(courseId = courseId) + isSelfPaced = courseStructure?.isSelfPaced ?: false val datesResponse = interactor.getCourseDates(courseId = courseId) if (datesResponse.datesSection.isEmpty()) { - _uiState.value = DatesUIState.Empty + _uiState.value = CourseDatesUIState.Error } else { - _uiState.value = DatesUIState.Dates(datesResponse) + val courseDates = datesResponse.datesSection.values.flatten() + val calendarState = getCalendarState(courseDates) + _uiState.value = CourseDatesUIState.CourseDates(datesResponse, calendarState) courseBannerType = datesResponse.courseBanner.bannerType checkIfCalendarOutOfDate() } } catch (e: Exception) { + _uiState.value = CourseDatesUIState.Error if (e.isInternetError()) { _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(CoreR.string.core_error_no_connection))) - } else { - _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(CoreR.string.core_error_unknown_error))) } } finally { courseNotifier.send(CourseLoading(false)) @@ -146,8 +140,8 @@ class CourseDatesViewModel( fun getVerticalBlock(blockId: String): Block? { return try { - val courseStructure = interactor.getCourseStructureFromCache() - courseStructure.blockData.getVerticalBlocks().find { it.descendants.contains(blockId) } + courseStructure?.blockData?.getVerticalBlocks() + ?.find { it.descendants.contains(blockId) } } catch (e: Exception) { null } @@ -155,51 +149,16 @@ class CourseDatesViewModel( fun getSequentialBlock(blockId: String): Block? { return try { - val courseStructure = interactor.getCourseStructureFromCache() - courseStructure.blockData.getSequentialBlocks() - .find { it.descendants.contains(blockId) } + courseStructure?.blockData?.getSequentialBlocks() + ?.find { it.descendants.contains(blockId) } } catch (e: Exception) { null } } - fun handleCalendarSyncState(isChecked: Boolean) { - logCalendarSyncToggle(isChecked) - setCalendarSyncDialogType( - when { - isChecked && calendarManager.hasPermissions() -> CalendarSyncDialogType.SYNC_DIALOG - isChecked -> CalendarSyncDialogType.PERMISSION_DIALOG - else -> CalendarSyncDialogType.UN_SYNC_DIALOG - } - ) - } - - private fun updateAndFetchCalendarSyncState(): Boolean { - val isCalendarSynced = calendarManager.isCalendarExists( - calendarTitle = _calendarSyncUIState.value.calendarTitle - ) - _calendarSyncUIState.update { it.copy(isSynced = isCalendarSynced) } - return isCalendarSynced - } - - private fun setCalendarSyncDialogType(dialog: CalendarSyncDialogType) { - val value = _uiState.value - if (value is DatesUIState.Dates) { - viewModelScope.launch { - courseNotifier.send( - CreateCalendarSyncEvent( - courseDates = value.courseDatesResult.datesSection.values.flatten(), - dialogType = dialog.name, - checkOutOfSync = false, - ) - ) - } - } - } - private fun checkIfCalendarOutOfDate() { val value = _uiState.value - if (value is DatesUIState.Dates) { + if (value is CourseDatesUIState.CourseDates) { viewModelScope.launch { courseNotifier.send( CreateCalendarSyncEvent( @@ -212,10 +171,27 @@ class CourseDatesViewModel( } } - private fun isCalendarSyncEnabled(): Boolean { - val calendarSync = corePreferences.appConfig.courseDatesCalendarSync - return calendarSync.isEnabled && ((calendarSync.isSelfPacedEnabled && isSelfPaced) || - (calendarSync.isInstructorPacedEnabled && !isSelfPaced)) + private suspend fun getCalendarState(courseDates: List): CalendarSyncState { + val courseCalendarState = calendarInteractor.getCourseCalendarStateByIdFromCache(courseId) + return when { + courseCalendarState?.isCourseSyncEnabled != true -> CalendarSyncState.OFFLINE + !isCourseCalendarUpToDate(courseDates) -> CalendarSyncState.SYNC_FAILED + else -> CalendarSyncState.SYNCED + } + } + + private suspend fun isCourseCalendarUpToDate(courseDateBlocks: List): Boolean { + val oldChecksum = getCourseCalendarStateChecksum() + val newChecksum = getCourseChecksum(courseDateBlocks) + return newChecksum == oldChecksum + } + + private fun getCourseChecksum(courseDateBlocks: List): Int { + return courseDateBlocks.sumOf { it.hashCode() } + } + + private suspend fun getCourseCalendarStateChecksum(): Int? { + return calendarInteractor.getCourseCalendarStateByIdFromCache(courseId)?.checksum } fun logPlsBannerViewed() { @@ -241,18 +217,6 @@ class CourseDatesViewModel( logDatesEvent(CourseAnalyticsEvent.DATES_COURSE_COMPONENT_CLICKED, params) } - private fun logCalendarSyncToggle(isChecked: Boolean) { - logDatesEvent( - CourseAnalyticsEvent.DATES_CALENDAR_SYNC_TOGGLE, - buildMap { - put( - CourseAnalyticsKey.ACTION.key, - if (isChecked) CourseAnalyticsKey.ON.key else CourseAnalyticsKey.OFF.key - ) - } - ) - } - private fun logDatesEvent( event: CourseAnalyticsEvent, param: Map = emptyMap(), diff --git a/course/src/main/java/org/openedx/course/presentation/dates/DashboardUIState.kt b/course/src/main/java/org/openedx/course/presentation/dates/DashboardUIState.kt index 8ff75239f..6dbb71fb2 100644 --- a/course/src/main/java/org/openedx/course/presentation/dates/DashboardUIState.kt +++ b/course/src/main/java/org/openedx/course/presentation/dates/DashboardUIState.kt @@ -1,12 +1,13 @@ package org.openedx.course.presentation.dates import org.openedx.core.domain.model.CourseDatesResult +import org.openedx.core.presentation.settings.calendarsync.CalendarSyncState sealed class DatesUIState { data class Dates( val courseDatesResult: CourseDatesResult, + val calendarSyncState: CalendarSyncState, ) : DatesUIState() - - object Empty : DatesUIState() - object Loading : DatesUIState() + data object Error : DatesUIState() + data object Loading : DatesUIState() } diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt new file mode 100644 index 000000000..c591966f4 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt @@ -0,0 +1,264 @@ +package org.openedx.course.presentation.download + +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CloudDownload +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.os.bundleOf +import androidx.fragment.app.DialogFragment +import org.openedx.core.presentation.dialog.DefaultDialogBox +import org.openedx.core.ui.AutoSizeText +import org.openedx.core.ui.IconText +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.OpenEdXOutlinedButton +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appTypography +import org.openedx.course.R +import org.openedx.course.domain.model.DownloadDialogResource +import org.openedx.foundation.extension.parcelable +import org.openedx.foundation.extension.toFileSize +import org.openedx.foundation.system.PreviewFragmentManager +import androidx.compose.ui.graphics.Color as ComposeColor +import org.openedx.core.R as coreR + +class DownloadConfirmDialogFragment : DialogFragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val dialogType = + requireArguments().parcelable(ARG_DIALOG_TYPE) ?: return@OpenEdXTheme + val uiState = requireArguments().parcelable(ARG_UI_STATE) ?: return@OpenEdXTheme + val sizeSumString = uiState.sizeSum.toFileSize(1, false) + val dialogData = when (dialogType) { + DownloadConfirmDialogType.CONFIRM -> DownloadDialogResource( + title = stringResource(id = coreR.string.course_confirm_download), + description = stringResource( + id = R.string.course_download_confirm_dialog_description, + sizeSumString + ), + ) + + DownloadConfirmDialogType.DOWNLOAD_ON_CELLULAR -> DownloadDialogResource( + title = stringResource(id = R.string.course_download_on_cellural), + description = stringResource( + id = R.string.course_download_on_cellural_dialog_description, + sizeSumString + ), + icon = painterResource(id = coreR.drawable.core_ic_warning), + ) + + DownloadConfirmDialogType.REMOVE -> DownloadDialogResource( + title = stringResource(id = R.string.course_download_remove_offline_content), + description = stringResource( + id = R.string.course_download_remove_dialog_description, + sizeSumString + ) + ) + } + + DownloadConfirmDialogView( + downloadDialogResource = dialogData, + uiState = uiState, + dialogType = dialogType, + onConfirmClick = { + uiState.saveDownloadModels() + dismiss() + }, + onRemoveClick = { + uiState.removeDownloadModels() + dismiss() + }, + onCancelClick = { + dismiss() + } + ) + } + } + } + + companion object { + const val DIALOG_TAG = "DownloadConfirmDialogFragment" + const val ARG_DIALOG_TYPE = "dialogType" + const val ARG_UI_STATE = "uiState" + + fun newInstance( + dialogType: DownloadConfirmDialogType, + uiState: DownloadDialogUIState + ): DownloadConfirmDialogFragment { + val dialog = DownloadConfirmDialogFragment() + dialog.arguments = bundleOf( + ARG_DIALOG_TYPE to dialogType, + ARG_UI_STATE to uiState + ) + return dialog + } + } +} + +@Composable +private fun DownloadConfirmDialogView( + modifier: Modifier = Modifier, + uiState: DownloadDialogUIState, + downloadDialogResource: DownloadDialogResource, + dialogType: DownloadConfirmDialogType, + onRemoveClick: () -> Unit, + onConfirmClick: () -> Unit, + onCancelClick: () -> Unit +) { + val scrollState = rememberScrollState() + DefaultDialogBox( + modifier = modifier, + onDismissClick = onCancelClick + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(scrollState) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + downloadDialogResource.icon?.let { icon -> + Image( + painter = icon, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + } + AutoSizeText( + text = downloadDialogResource.title, + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textDark, + minSize = MaterialTheme.appTypography.titleLarge.fontSize.value - 1 + ) + } + Column { + uiState.downloadDialogItems.forEach { + DownloadDialogItem(downloadDialogItem = it) + } + } + Text( + text = downloadDialogResource.description, + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.textDark + ) + + val buttonText: String + val buttonIcon: ImageVector + val buttonColor: ComposeColor + val onClick: () -> Unit + when (dialogType) { + DownloadConfirmDialogType.REMOVE -> { + buttonText = stringResource(id = R.string.course_remove) + buttonIcon = Icons.Rounded.Delete + buttonColor = MaterialTheme.appColors.error + onClick = onRemoveClick + } + + else -> { + buttonText = stringResource(id = R.string.course_download) + buttonIcon = Icons.Outlined.CloudDownload + buttonColor = MaterialTheme.appColors.secondaryButtonBackground + onClick = onConfirmClick + } + } + OpenEdXButton( + text = buttonText, + backgroundColor = buttonColor, + onClick = onClick, + content = { + IconText( + text = buttonText, + icon = buttonIcon, + color = MaterialTheme.appColors.primaryButtonText, + textStyle = MaterialTheme.appTypography.labelLarge + ) + } + ) + OpenEdXOutlinedButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = coreR.string.core_cancel), + backgroundColor = MaterialTheme.appColors.background, + borderColor = MaterialTheme.appColors.primaryButtonBackground, + textColor = MaterialTheme.appColors.primaryButtonBackground, + onClick = { + onCancelClick() + } + ) + } + } +} + +@Preview +@Composable +private fun DownloadConfirmDialogViewPreview() { + OpenEdXTheme { + DownloadConfirmDialogView( + downloadDialogResource = DownloadDialogResource( + title = "Title", + description = "Description Description Description Description Description Description Description " + ), + uiState = DownloadDialogUIState( + downloadDialogItems = listOf( + DownloadDialogItem( + title = "Subsection title 1", + size = 20000 + ), + DownloadDialogItem( + title = "Subsection title 2", + size = 10000000 + ) + ), + sizeSum = 1000000, + isAllBlocksDownloaded = false, + isDownloadFailed = false, + saveDownloadModels = {}, + removeDownloadModels = {}, + fragmentManager = PreviewFragmentManager + ), + dialogType = DownloadConfirmDialogType.CONFIRM, + onConfirmClick = {}, + onRemoveClick = {}, + onCancelClick = {} + ) + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogType.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogType.kt new file mode 100644 index 000000000..9c0833ff3 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogType.kt @@ -0,0 +1,9 @@ +package org.openedx.course.presentation.download + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +enum class DownloadConfirmDialogType : Parcelable { + DOWNLOAD_ON_CELLULAR, CONFIRM, REMOVE +} diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogItem.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogItem.kt new file mode 100644 index 000000000..9f3cfc4d4 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogItem.kt @@ -0,0 +1,13 @@ +package org.openedx.course.presentation.download + +import android.os.Parcelable +import androidx.compose.ui.graphics.vector.ImageVector +import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.RawValue + +@Parcelize +data class DownloadDialogItem( + val title: String, + val size: Long, + val icon: @RawValue ImageVector? = null +) : Parcelable diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt new file mode 100644 index 000000000..5a85ba191 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt @@ -0,0 +1,220 @@ +package org.openedx.course.presentation.download + +import androidx.fragment.app.FragmentManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch +import org.openedx.core.BlockType +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.Block +import org.openedx.core.module.DownloadWorkerController +import org.openedx.core.module.db.DownloadModel +import org.openedx.core.system.StorageManager +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.course.domain.interactor.CourseInteractor + +class DownloadDialogManager( + private val networkConnection: NetworkConnection, + private val corePreferences: CorePreferences, + private val interactor: CourseInteractor, + private val workerController: DownloadWorkerController +) { + + companion object { + const val MAX_CELLULAR_SIZE = 104857600 // 100MB + const val DOWNLOAD_SIZE_FACTOR = 2 // Multiplier to match required disk size + } + + private val uiState = MutableSharedFlow() + private val coroutineScope = CoroutineScope(Dispatchers.IO) + + init { + coroutineScope.launch { + uiState.collect { state -> + val dialog = when { + state.isDownloadFailed -> DownloadErrorDialogFragment.newInstance( + dialogType = DownloadErrorDialogType.DOWNLOAD_FAILED, uiState = state + ) + + state.isAllBlocksDownloaded -> DownloadConfirmDialogFragment.newInstance( + dialogType = DownloadConfirmDialogType.REMOVE, uiState = state + ) + + !networkConnection.isOnline() -> DownloadErrorDialogFragment.newInstance( + dialogType = DownloadErrorDialogType.NO_CONNECTION, uiState = state + ) + + StorageManager.getFreeStorage() < state.sizeSum * DOWNLOAD_SIZE_FACTOR -> DownloadStorageErrorDialogFragment.newInstance( + uiState = state + ) + + corePreferences.videoSettings.wifiDownloadOnly && !networkConnection.isWifiConnected() -> DownloadErrorDialogFragment.newInstance( + dialogType = DownloadErrorDialogType.WIFI_REQUIRED, uiState = state + ) + + !corePreferences.videoSettings.wifiDownloadOnly && !networkConnection.isWifiConnected() -> DownloadConfirmDialogFragment.newInstance( + dialogType = DownloadConfirmDialogType.DOWNLOAD_ON_CELLULAR, uiState = state + ) + + state.sizeSum >= MAX_CELLULAR_SIZE -> DownloadConfirmDialogFragment.newInstance( + dialogType = DownloadConfirmDialogType.CONFIRM, uiState = state + ) + + else -> null + } + + dialog?.show(state.fragmentManager, dialog::class.java.simpleName) ?: state.saveDownloadModels() + } + } + } + + fun showPopup( + subSectionsBlocks: List, + courseId: String, + isBlocksDownloaded: Boolean, + onlyVideoBlocks: Boolean = false, + fragmentManager: FragmentManager, + removeDownloadModels: (blockId: String) -> Unit, + saveDownloadModels: (blockId: String) -> Unit, + ) { + createDownloadItems( + subSectionsBlocks = subSectionsBlocks, + courseId = courseId, + fragmentManager = fragmentManager, + isBlocksDownloaded = isBlocksDownloaded, + onlyVideoBlocks = onlyVideoBlocks, + removeDownloadModels = removeDownloadModels, + saveDownloadModels = saveDownloadModels + ) + } + + fun showRemoveDownloadModelPopup( + downloadDialogItem: DownloadDialogItem, + fragmentManager: FragmentManager, + removeDownloadModels: () -> Unit, + ) { + coroutineScope.launch { + uiState.emit( + DownloadDialogUIState( + downloadDialogItems = listOf(downloadDialogItem), + isAllBlocksDownloaded = true, + isDownloadFailed = false, + sizeSum = downloadDialogItem.size, + fragmentManager = fragmentManager, + removeDownloadModels = removeDownloadModels, + saveDownloadModels = {} + ) + ) + } + } + + fun showDownloadFailedPopup( + downloadModel: List, + fragmentManager: FragmentManager, + ) { + createDownloadItems( + downloadModels = downloadModel, + fragmentManager = fragmentManager, + ) + } + + private fun createDownloadItems( + downloadModels: List, + fragmentManager: FragmentManager, + ) { + coroutineScope.launch { + val courseIds = downloadModels.map { it.courseId }.distinct() + val blockIds = downloadModels.map { it.id } + val notDownloadedSubSections = mutableListOf() + val allDownloadDialogItems = mutableListOf() + + courseIds.forEach { courseId -> + val courseStructure = interactor.getCourseStructureFromCache(courseId) + val allSubSectionBlocks = courseStructure.blockData.filter { it.type == BlockType.SEQUENTIAL } + + allSubSectionBlocks.forEach { subSectionBlock -> + val verticalBlocks = courseStructure.blockData.filter { it.id in subSectionBlock.descendants } + val blocks = courseStructure.blockData.filter { + it.id in verticalBlocks.flatMap { it.descendants } && it.id in blockIds + } + val totalSize = blocks.sumOf { getFileSize(it) } + + if (blocks.isNotEmpty()) notDownloadedSubSections.add(subSectionBlock) + if (totalSize > 0) { + allDownloadDialogItems.add( + DownloadDialogItem( + title = subSectionBlock.displayName, + size = totalSize + ) + ) + } + } + } + + uiState.emit( + DownloadDialogUIState( + downloadDialogItems = allDownloadDialogItems, + isAllBlocksDownloaded = false, + isDownloadFailed = true, + sizeSum = allDownloadDialogItems.sumOf { it.size }, + fragmentManager = fragmentManager, + removeDownloadModels = {}, + saveDownloadModels = { + coroutineScope.launch { + workerController.saveModels(downloadModels) + } + } + ) + ) + } + } + + private fun createDownloadItems( + subSectionsBlocks: List, + courseId: String, + fragmentManager: FragmentManager, + isBlocksDownloaded: Boolean, + onlyVideoBlocks: Boolean, + removeDownloadModels: (blockId: String) -> Unit, + saveDownloadModels: (blockId: String) -> Unit, + ) { + coroutineScope.launch { + val courseStructure = interactor.getCourseStructure(courseId, false) + val downloadModelIds = interactor.getAllDownloadModels().map { it.id } + + val downloadDialogItems = subSectionsBlocks.mapNotNull { subSectionBlock -> + val verticalBlocks = courseStructure.blockData.filter { it.id in subSectionBlock.descendants } + val blocks = verticalBlocks.flatMap { verticalBlock -> + courseStructure.blockData.filter { + it.id in verticalBlock.descendants && + (isBlocksDownloaded == (it.id in downloadModelIds)) && + (!onlyVideoBlocks || it.type == BlockType.VIDEO) + } + } + val size = blocks.sumOf { getFileSize(it) } + if (size > 0) DownloadDialogItem(title = subSectionBlock.displayName, size = size) else null + } + + uiState.emit( + DownloadDialogUIState( + downloadDialogItems = downloadDialogItems, + isAllBlocksDownloaded = isBlocksDownloaded, + isDownloadFailed = false, + sizeSum = downloadDialogItems.sumOf { it.size }, + fragmentManager = fragmentManager, + removeDownloadModels = { subSectionsBlocks.forEach { removeDownloadModels(it.id) } }, + saveDownloadModels = { subSectionsBlocks.forEach { saveDownloadModels(it.id) } } + ) + ) + } + } + + private fun getFileSize(block: Block): Long { + return when { + block.type == BlockType.VIDEO -> block.downloadModel?.size ?: 0L + block.isxBlock -> block.offlineDownload?.fileSize ?: 0L + else -> 0L + } + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogUIState.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogUIState.kt new file mode 100644 index 000000000..b58e856bd --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogUIState.kt @@ -0,0 +1,17 @@ +package org.openedx.course.presentation.download + +import android.os.Parcelable +import androidx.fragment.app.FragmentManager +import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.RawValue + +@Parcelize +data class DownloadDialogUIState( + val downloadDialogItems: List = emptyList(), + val sizeSum: Long, + val isAllBlocksDownloaded: Boolean, + val isDownloadFailed: Boolean, + val fragmentManager: @RawValue FragmentManager, + val removeDownloadModels: () -> Unit, + val saveDownloadModels: () -> Unit +) : Parcelable diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogFragment.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogFragment.kt new file mode 100644 index 000000000..96cdf3d40 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogFragment.kt @@ -0,0 +1,222 @@ +package org.openedx.course.presentation.download + +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.os.bundleOf +import androidx.fragment.app.DialogFragment +import org.openedx.core.presentation.dialog.DefaultDialogBox +import org.openedx.core.ui.AutoSizeText +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.OpenEdXOutlinedButton +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appTypography +import org.openedx.course.R +import org.openedx.course.domain.model.DownloadDialogResource +import org.openedx.foundation.extension.parcelable +import org.openedx.foundation.system.PreviewFragmentManager +import org.openedx.core.R as coreR + +class DownloadErrorDialogFragment : DialogFragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val dialogType = + requireArguments().parcelable(ARG_DIALOG_TYPE) ?: return@OpenEdXTheme + val uiState = requireArguments().parcelable(ARG_UI_STATE) ?: return@OpenEdXTheme + val downloadDialogResource = when (dialogType) { + DownloadErrorDialogType.NO_CONNECTION -> DownloadDialogResource( + title = stringResource(id = coreR.string.core_no_internet_connection), + description = stringResource(id = R.string.course_download_no_internet_dialog_description), + icon = painterResource(id = R.drawable.course_ic_error), + ) + + DownloadErrorDialogType.WIFI_REQUIRED -> DownloadDialogResource( + title = stringResource(id = R.string.course_wifi_required), + description = stringResource(id = R.string.course_download_wifi_required_dialog_description), + icon = painterResource(id = R.drawable.course_ic_error), + ) + + DownloadErrorDialogType.DOWNLOAD_FAILED -> DownloadDialogResource( + title = stringResource(id = R.string.course_download_failed), + description = stringResource(id = R.string.course_download_failed_dialog_description), + icon = painterResource(id = R.drawable.course_ic_error), + ) + } + + DownloadErrorDialogView( + downloadDialogResource = downloadDialogResource, + uiState = uiState, + dialogType = dialogType, + onTryAgainClick = { + uiState.saveDownloadModels() + dismiss() + }, + onCancelClick = { + dismiss() + } + ) + } + } + } + + companion object { + const val DIALOG_TAG = "DownloadErrorDialogFragment" + const val ARG_DIALOG_TYPE = "dialogType" + const val ARG_UI_STATE = "uiState" + + fun newInstance( + dialogType: DownloadErrorDialogType, + uiState: DownloadDialogUIState + ): DownloadErrorDialogFragment { + val dialog = DownloadErrorDialogFragment() + dialog.arguments = bundleOf( + ARG_DIALOG_TYPE to dialogType, + ARG_UI_STATE to uiState + ) + return dialog + } + } +} + +@Composable +private fun DownloadErrorDialogView( + modifier: Modifier = Modifier, + uiState: DownloadDialogUIState, + downloadDialogResource: DownloadDialogResource, + dialogType: DownloadErrorDialogType, + onTryAgainClick: () -> Unit, + onCancelClick: () -> Unit, +) { + val scrollState = rememberScrollState() + val dismissButtonText = when (dialogType) { + DownloadErrorDialogType.DOWNLOAD_FAILED -> stringResource(id = coreR.string.core_cancel) + else -> stringResource(id = coreR.string.core_close) + } + DefaultDialogBox( + modifier = modifier, + onDismissClick = onCancelClick + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(scrollState) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + downloadDialogResource.icon?.let { icon -> + Image( + painter = icon, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + } + AutoSizeText( + text = downloadDialogResource.title, + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textDark, + minSize = MaterialTheme.appTypography.titleLarge.fontSize.value - 1 + ) + } + Column { + uiState.downloadDialogItems.forEach { + DownloadDialogItem(downloadDialogItem = it) + } + } + Text( + text = downloadDialogResource.description, + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.textDark + ) + if (dialogType == DownloadErrorDialogType.DOWNLOAD_FAILED) { + OpenEdXButton( + text = stringResource(id = coreR.string.core_error_try_again), + backgroundColor = MaterialTheme.appColors.secondaryButtonBackground, + onClick = onTryAgainClick, + ) + } + OpenEdXOutlinedButton( + modifier = Modifier.fillMaxWidth(), + text = dismissButtonText, + backgroundColor = MaterialTheme.appColors.background, + borderColor = MaterialTheme.appColors.primaryButtonBackground, + textColor = MaterialTheme.appColors.primaryButtonBackground, + onClick = { + onCancelClick() + } + ) + } + } +} + +@Preview +@Composable +private fun DownloadErrorDialogViewPreview() { + OpenEdXTheme { + DownloadErrorDialogView( + downloadDialogResource = DownloadDialogResource( + title = "Title", + description = "Description Description Description Description Description Description Description ", + icon = painterResource(id = R.drawable.course_ic_error) + ), + uiState = DownloadDialogUIState( + downloadDialogItems = listOf( + DownloadDialogItem( + title = "Subsection title 1", + size = 20000 + ), + DownloadDialogItem( + title = "Subsection title 2", + size = 10000000 + ) + ), + sizeSum = 100000, + isAllBlocksDownloaded = false, + isDownloadFailed = false, + fragmentManager = PreviewFragmentManager, + removeDownloadModels = {}, + saveDownloadModels = {} + ), + onCancelClick = {}, + onTryAgainClick = {}, + dialogType = DownloadErrorDialogType.DOWNLOAD_FAILED + ) + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogType.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogType.kt new file mode 100644 index 000000000..85f01cf1a --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogType.kt @@ -0,0 +1,9 @@ +package org.openedx.course.presentation.download + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +enum class DownloadErrorDialogType : Parcelable { + NO_CONNECTION, WIFI_REQUIRED, DOWNLOAD_FAILED +} diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadStorageErrorDialogFragment.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadStorageErrorDialogFragment.kt new file mode 100644 index 000000000..4c192209f --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadStorageErrorDialogFragment.kt @@ -0,0 +1,283 @@ +package org.openedx.course.presentation.download + +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.os.bundleOf +import androidx.fragment.app.DialogFragment +import org.openedx.core.presentation.dialog.DefaultDialogBox +import org.openedx.core.system.StorageManager +import org.openedx.core.ui.AutoSizeText +import org.openedx.core.ui.OpenEdXOutlinedButton +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appTypography +import org.openedx.course.R +import org.openedx.course.domain.model.DownloadDialogResource +import org.openedx.course.presentation.download.DownloadDialogManager.Companion.DOWNLOAD_SIZE_FACTOR +import org.openedx.foundation.extension.parcelable +import org.openedx.foundation.extension.toFileSize +import org.openedx.foundation.system.PreviewFragmentManager +import org.openedx.core.R as coreR + +class DownloadStorageErrorDialogFragment : DialogFragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val uiState = requireArguments().parcelable(ARG_UI_STATE) ?: return@OpenEdXTheme + val downloadDialogResource = DownloadDialogResource( + title = stringResource(id = R.string.course_device_storage_full), + description = stringResource(id = R.string.course_download_device_storage_full_dialog_description), + icon = painterResource(id = R.drawable.course_ic_error), + ) + + DownloadStorageErrorDialogView( + uiState = uiState, + downloadDialogResource = downloadDialogResource, + onCancelClick = { + dismiss() + } + ) + } + } + } + + companion object { + const val DIALOG_TAG = "DownloadStorageErrorDialogFragment" + const val ARG_UI_STATE = "uiState" + + fun newInstance( + uiState: DownloadDialogUIState + ): DownloadStorageErrorDialogFragment { + val dialog = DownloadStorageErrorDialogFragment() + dialog.arguments = bundleOf( + ARG_UI_STATE to uiState + ) + return dialog + } + } +} + +@Composable +private fun DownloadStorageErrorDialogView( + modifier: Modifier = Modifier, + uiState: DownloadDialogUIState, + downloadDialogResource: DownloadDialogResource, + onCancelClick: () -> Unit, +) { + val scrollState = rememberScrollState() + DefaultDialogBox( + modifier = modifier, + onDismissClick = onCancelClick + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(scrollState) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + downloadDialogResource.icon?.let { icon -> + Image( + painter = icon, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + } + AutoSizeText( + text = downloadDialogResource.title, + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textDark, + minSize = MaterialTheme.appTypography.titleLarge.fontSize.value - 1 + ) + } + Column { + uiState.downloadDialogItems.forEach { + DownloadDialogItem(downloadDialogItem = it.copy(size = it.size * DOWNLOAD_SIZE_FACTOR)) + } + } + StorageBar( + freeSpace = StorageManager.getFreeStorage(), + totalSpace = StorageManager.getTotalStorage(), + requiredSpace = uiState.sizeSum * DOWNLOAD_SIZE_FACTOR + ) + Text( + text = downloadDialogResource.description, + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.textDark + ) + OpenEdXOutlinedButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = coreR.string.core_cancel), + backgroundColor = MaterialTheme.appColors.background, + borderColor = MaterialTheme.appColors.primaryButtonBackground, + textColor = MaterialTheme.appColors.primaryButtonBackground, + onClick = { + onCancelClick() + } + ) + } + } +} + +@Composable +private fun StorageBar( + freeSpace: Long, + totalSpace: Long, + requiredSpace: Long +) { + val cornerRadius = 2.dp + val boxPadding = 1.dp + val usedSpace = totalSpace - freeSpace + val minSize = 0.1f + val freePercentage = freeSpace / requiredSpace.toFloat() + minSize + val reqPercentage = (requiredSpace - freeSpace) / requiredSpace.toFloat() + minSize + + val animReqPercentage = remember { Animatable(Float.MIN_VALUE) } + LaunchedEffect(Unit) { + animReqPercentage.animateTo( + targetValue = reqPercentage, + animationSpec = tween( + durationMillis = 1000, + easing = LinearOutSlowInEasing + ) + ) + } + + Column( + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(36.dp) + .background(MaterialTheme.appColors.background) + .clip(RoundedCornerShape(cornerRadius)) + .border( + 2.dp, + MaterialTheme.appColors.cardViewBorder, + RoundedCornerShape(cornerRadius * 2) + ) + .padding(2.dp) + .background(MaterialTheme.appColors.background), + ) { + Box( + modifier = Modifier + .weight(freePercentage) + .fillMaxHeight() + .padding(top = boxPadding, bottom = boxPadding, start = boxPadding, end = boxPadding / 2) + .clip(RoundedCornerShape(topStart = cornerRadius, bottomStart = cornerRadius)) + .background(MaterialTheme.appColors.cardViewBorder) + ) + Box( + modifier = Modifier + .weight(animReqPercentage.value) + .fillMaxHeight() + .padding(top = boxPadding, bottom = boxPadding, end = boxPadding, start = boxPadding / 2) + .clip(RoundedCornerShape(topEnd = cornerRadius, bottomEnd = cornerRadius)) + .background(MaterialTheme.appColors.error) + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + ) { + Text( + text = stringResource( + R.string.course_used_free_storage, + usedSpace.toFileSize(1, false), + freeSpace.toFileSize(1, false) + ), + style = MaterialTheme.appTypography.labelSmall, + color = MaterialTheme.appColors.textFieldHint, + modifier = Modifier.weight(1f) + ) + Text( + text = requiredSpace.toFileSize(1, false), + style = MaterialTheme.appTypography.labelSmall, + color = MaterialTheme.appColors.error, + ) + } + } +} + +@Preview +@Composable +private fun DownloadStorageErrorDialogViewPreview() { + OpenEdXTheme { + DownloadStorageErrorDialogView( + downloadDialogResource = DownloadDialogResource( + title = "Title", + description = "Description Description Description Description Description Description Description ", + icon = painterResource(id = R.drawable.course_ic_error) + ), + uiState = DownloadDialogUIState( + downloadDialogItems = listOf( + DownloadDialogItem( + title = "Subsection title 1", + size = 20000 + ), + DownloadDialogItem( + title = "Subsection title 2", + size = 10000000 + ) + ), + sizeSum = 100000, + isAllBlocksDownloaded = false, + isDownloadFailed = false, + fragmentManager = PreviewFragmentManager, + removeDownloadModels = {}, + saveDownloadModels = {} + ), + onCancelClick = {} + ) + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadView.kt b/course/src/main/java/org/openedx/course/presentation/download/DownloadView.kt new file mode 100644 index 000000000..fd70dd723 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/download/DownloadView.kt @@ -0,0 +1,59 @@ +package org.openedx.course.presentation.download + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import org.openedx.core.R +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appTypography +import org.openedx.foundation.extension.toFileSize + +@Composable +fun DownloadDialogItem( + modifier: Modifier = Modifier, + downloadDialogItem: DownloadDialogItem, +) { + val icon = if (downloadDialogItem.icon != null) { + rememberVectorPainter(downloadDialogItem.icon) + } else { + painterResource(id = R.drawable.ic_core_chapter_icon) + } + Row( + modifier = modifier.padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + modifier = Modifier + .size(24.dp) + .align(Alignment.Top), + painter = icon, + tint = MaterialTheme.appColors.textDark, + contentDescription = null, + ) + Text( + modifier = Modifier.weight(1f), + text = downloadDialogItem.title, + style = MaterialTheme.appTypography.titleSmall, + color = MaterialTheme.appColors.textDark, + overflow = TextOverflow.Ellipsis, + maxLines = 2 + ) + Text( + text = downloadDialogItem.size.toFileSize(1, false), + style = MaterialTheme.appTypography.bodySmall, + color = MaterialTheme.appColors.textFieldHint + ) + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsScreen.kt b/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsScreen.kt index 184031091..9720740a2 100644 --- a/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsScreen.kt @@ -34,14 +34,14 @@ import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography -import org.openedx.core.ui.windowSizeValue import org.openedx.course.presentation.ui.CardArrow +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.windowSizeValue import org.openedx.course.R as courseR @Composable diff --git a/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsUIState.kt b/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsUIState.kt new file mode 100644 index 000000000..860e4261f --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsUIState.kt @@ -0,0 +1,7 @@ +package org.openedx.course.presentation.handouts + +sealed class HandoutsUIState { + data object Loading : HandoutsUIState() + data class HTMLContent(val htmlContent: String) : HandoutsUIState() + data object Error : HandoutsUIState() +} diff --git a/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsViewModel.kt b/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsViewModel.kt index 66ba39293..c8d9a87f8 100644 --- a/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsViewModel.kt @@ -1,10 +1,10 @@ package org.openedx.course.presentation.handouts -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import org.openedx.core.BaseViewModel import org.openedx.core.config.Config import org.openedx.core.domain.model.AnnouncementModel import org.openedx.core.domain.model.HandoutsModel @@ -12,6 +12,7 @@ import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseAnalyticsEvent import org.openedx.course.presentation.CourseAnalyticsKey +import org.openedx.foundation.presentation.BaseViewModel class HandoutsViewModel( private val courseId: String, @@ -23,26 +24,40 @@ class HandoutsViewModel( val apiHostUrl get() = config.getApiHostURL() - private val _htmlContent = MutableLiveData() - val htmlContent: LiveData - get() = _htmlContent + private val _uiState = MutableStateFlow(HandoutsUIState.Loading) + val uiState: StateFlow + get() = _uiState.asStateFlow() init { - getEnrolledCourse() + getCourseHandouts() } - private fun getEnrolledCourse() { + private fun getCourseHandouts() { viewModelScope.launch { + var emptyState = false try { if (HandoutsType.valueOf(handoutsType) == HandoutsType.Handouts) { val handouts = interactor.getHandouts(courseId) - _htmlContent.value = handoutsToHtml(handouts) + if (handouts.handoutsHtml.isNotBlank()) { + _uiState.value = HandoutsUIState.HTMLContent(handoutsToHtml(handouts)) + } else { + emptyState = true + } } else { val announcements = interactor.getAnnouncements(courseId) - _htmlContent.value = announcementsToHtml(announcements) + if (announcements.isNotEmpty()) { + _uiState.value = + HandoutsUIState.HTMLContent(announcementsToHtml(announcements)) + } else { + emptyState = true + } } } catch (e: Exception) { //ignore e.printStackTrace() + emptyState = true + } + if (emptyState) { + _uiState.value = HandoutsUIState.Error } } } @@ -98,8 +113,8 @@ class HandoutsViewModel( } fun logEvent(event: CourseAnalyticsEvent) { - courseAnalytics.logEvent( - event = event.eventName, + courseAnalytics.logScreenEvent( + screenName = event.eventName, params = buildMap { put(CourseAnalyticsKey.NAME.key, event.biValue) put(CourseAnalyticsKey.COURSE_ID.key, courseId) diff --git a/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsWebViewFragment.kt b/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsWebViewFragment.kt index 7c9d3615e..24240954a 100644 --- a/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsWebViewFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsWebViewFragment.kt @@ -4,25 +4,51 @@ import android.content.res.Configuration import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf +import org.openedx.core.NoContentScreenType +import org.openedx.core.ui.CircularProgress +import org.openedx.core.ui.NoContentScreen +import org.openedx.core.ui.Toolbar import org.openedx.core.ui.WebContentScreen -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType -import org.openedx.core.ui.rememberWindowSize +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.course.R import org.openedx.course.presentation.CourseAnalyticsEvent +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue class HandoutsWebViewFragment : Fragment() { @@ -39,48 +65,50 @@ class HandoutsWebViewFragment : Fragment() { savedInstanceState: Bundle?, ) = ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + + val title = if (HandoutsType.valueOf(viewModel.handoutsType) == HandoutsType.Handouts) { + viewModel.logEvent(CourseAnalyticsEvent.HANDOUTS) + getString(R.string.course_handouts) + } else { + viewModel.logEvent(CourseAnalyticsEvent.ANNOUNCEMENTS) + getString(R.string.course_announcements) + } + setContent { OpenEdXTheme { - val windowSize = rememberWindowSize() - - val htmlBody by viewModel.htmlContent.observeAsState("") val colorBackgroundValue = MaterialTheme.appColors.background.value val colorTextValue = MaterialTheme.appColors.textPrimary.value - - WebContentScreen( - windowSize = windowSize, + val uiState by viewModel.uiState.collectAsState() + HandoutsScreens( + handoutType = HandoutsType.valueOf(viewModel.handoutsType), + uiState = uiState, + title = title, apiHostUrl = viewModel.apiHostUrl, - title = requireArguments().getString(ARG_TITLE, ""), - htmlBody = viewModel.injectDarkMode( - htmlBody, - colorBackgroundValue, - colorTextValue - ), + onInjectDarkMode = { + viewModel.injectDarkMode( + (uiState as HandoutsUIState.HTMLContent).htmlContent, + colorBackgroundValue, + colorTextValue + ) + }, onBackClick = { requireActivity().supportFragmentManager.popBackStack() - }) + } + ) } } - if (HandoutsType.valueOf(viewModel.handoutsType) == HandoutsType.Handouts) { - viewModel.logEvent(CourseAnalyticsEvent.HANDOUTS) - } else { - viewModel.logEvent(CourseAnalyticsEvent.ANNOUNCEMENTS) - } } companion object { - private val ARG_TITLE = "argTitle" - private val ARG_TYPE = "argType" - private val ARG_COURSE_ID = "argCourse" + private const val ARG_TYPE = "argType" + private const val ARG_COURSE_ID = "argCourse" fun newInstance( - title: String, type: String, courseId: String, ): HandoutsWebViewFragment { val fragment = HandoutsWebViewFragment() fragment.arguments = bundleOf( - ARG_TITLE to title, ARG_TYPE to type, ARG_COURSE_ID to courseId ) @@ -89,24 +117,163 @@ class HandoutsWebViewFragment : Fragment() { } } +@Composable +fun HandoutsScreens( + handoutType: HandoutsType, + uiState: HandoutsUIState, + title: String, + apiHostUrl: String, + onInjectDarkMode: () -> String, + onBackClick: () -> Unit +) { + val windowSize = rememberWindowSize() + when (uiState) { + is HandoutsUIState.Loading -> { + CircularProgress() + } + + is HandoutsUIState.HTMLContent -> { + WebContentScreen( + windowSize = windowSize, + apiHostUrl = apiHostUrl, + title = title, + htmlBody = onInjectDarkMode(), + onBackClick = onBackClick + ) + } + + HandoutsUIState.Error -> { + HandoutsEmptyScreen( + windowSize = windowSize, + handoutType = handoutType, + title = title, + onBackClick = onBackClick + ) + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun HandoutsEmptyScreen( + windowSize: WindowSize, + handoutType: HandoutsType, + title: String, + onBackClick: () -> Unit +) { + val handoutScreenType = + if (handoutType == HandoutsType.Handouts) NoContentScreenType.COURSE_HANDOUTS + else NoContentScreenType.COURSE_ANNOUNCEMENTS + + val scaffoldState = rememberScaffoldState() + Scaffold( + modifier = Modifier + .fillMaxSize() + .padding(bottom = 24.dp) + .semantics { + testTagsAsResourceId = true + }, + scaffoldState = scaffoldState, + backgroundColor = MaterialTheme.appColors.background + ) { + + val screenWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), + compact = Modifier.fillMaxWidth() + ) + ) + } + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(it) + .statusBarsInset() + .displayCutoutForLandscape(), + contentAlignment = Alignment.TopCenter + ) { + Column(screenWidth) { + Box( + Modifier + .fillMaxWidth() + .zIndex(1f), + contentAlignment = Alignment.CenterStart + ) { + Toolbar( + label = title, + canShowBackBtn = true, + onBackClick = onBackClick + ) + } + Surface( + Modifier.fillMaxSize(), + color = MaterialTheme.appColors.background + ) { + NoContentScreen(noContentScreenType = handoutScreenType) + } + } + } + } +} + @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -fun WebContentScreenPreview() { - WebContentScreen( - windowSize = WindowSize(WindowType.Compact, WindowType.Compact), +fun HandoutsScreensPreview() { + HandoutsScreens( + handoutType = HandoutsType.Handouts, + uiState = HandoutsUIState.HTMLContent(htmlContent = ""), + title = "Handouts", apiHostUrl = "http://localhost:8000", - title = "Handouts", onBackClick = { }, htmlBody = "" + onInjectDarkMode = { "" }, + onBackClick = { } ) } @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, device = Devices.NEXUS_9) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.NEXUS_9) @Composable -fun WebContentScreenTabletPreview() { - WebContentScreen( - windowSize = WindowSize(WindowType.Medium, WindowType.Medium), +fun HandoutsScreensTabletPreview() { + HandoutsScreens( + handoutType = HandoutsType.Handouts, + uiState = HandoutsUIState.HTMLContent(htmlContent = ""), + title = "Handouts", apiHostUrl = "http://localhost:8000", - title = "Handouts", onBackClick = { }, htmlBody = "" + onInjectDarkMode = { "" }, + onBackClick = { } ) } + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun EmptyHandoutsScreensPreview() { + OpenEdXTheme(darkTheme = true) { + HandoutsScreens( + handoutType = HandoutsType.Handouts, + uiState = HandoutsUIState.Error, + title = "Handouts", + apiHostUrl = "http://localhost:8000", + onInjectDarkMode = { "" }, + onBackClick = { } + ) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun EmptyAnnouncementsScreensPreview() { + OpenEdXTheme(darkTheme = true) { + HandoutsScreens( + handoutType = HandoutsType.Announcements, + uiState = HandoutsUIState.Error, + title = "Handouts", + apiHostUrl = "http://localhost:8000", + onInjectDarkMode = { "" }, + onBackClick = { } + ) + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt new file mode 100644 index 000000000..9a4374aec --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt @@ -0,0 +1,489 @@ +package org.openedx.course.presentation.offline + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.InsertDriveFile +import androidx.compose.material.icons.filled.CloudDone +import androidx.compose.material.icons.outlined.CloudDownload +import androidx.compose.material.icons.outlined.SmartDisplay +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentManager +import org.openedx.core.module.db.DownloadModel +import org.openedx.core.module.db.DownloadedState +import org.openedx.core.module.db.FileType +import org.openedx.core.ui.IconText +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.OpenEdXOutlinedButton +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appTypography +import org.openedx.course.R +import org.openedx.foundation.extension.toFileSize +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue +import org.openedx.core.R as coreR + +@Composable +fun CourseOfflineScreen( + windowSize: WindowSize, + viewModel: CourseOfflineViewModel, + fragmentManager: FragmentManager, +) { + val uiState by viewModel.uiState.collectAsState() + + CourseOfflineUI( + windowSize = windowSize, + uiState = uiState, + hasInternetConnection = viewModel.hasInternetConnection, + onDownloadAllClick = { + viewModel.downloadAllBlocks(fragmentManager) + }, + onCancelDownloadClick = { + viewModel.removeDownloadModel() + }, + onDeleteClick = { downloadModel -> + viewModel.removeDownloadModel( + downloadModel, + fragmentManager + ) + }, + onDeleteAllClick = { + viewModel.deleteAll(fragmentManager) + }, + ) +} + +@Composable +private fun CourseOfflineUI( + windowSize: WindowSize, + uiState: CourseOfflineUIState, + hasInternetConnection: Boolean, + onDownloadAllClick: () -> Unit, + onCancelDownloadClick: () -> Unit, + onDeleteClick: (downloadModel: DownloadModel) -> Unit, + onDeleteAllClick: () -> Unit +) { + val scaffoldState = rememberScaffoldState() + + Scaffold( + modifier = Modifier.fillMaxSize(), + scaffoldState = scaffoldState, + backgroundColor = MaterialTheme.appColors.background + ) { + val modifierScreenWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), + compact = Modifier.fillMaxWidth() + ) + ) + } + + val horizontalPadding by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.padding(horizontal = 6.dp), + compact = Modifier.padding(horizontal = 24.dp) + ) + ) + } + + Box( + modifier = Modifier + .fillMaxSize() + .padding(it) + .displayCutoutForLandscape(), contentAlignment = Alignment.TopCenter + ) { + Surface( + modifier = modifierScreenWidth, + color = MaterialTheme.appColors.background, + ) { + LazyColumn( + Modifier + .fillMaxWidth() + .padding(top = 20.dp, bottom = 24.dp) + .then(horizontalPadding) + ) { + item { + if (uiState.isHaveDownloadableBlocks) { + DownloadProgress( + uiState = uiState, + ) + } else { + NoDownloadableBlocksProgress() + } + if (uiState.progressBarValue != 1f && !uiState.isDownloading && hasInternetConnection) { + Spacer(modifier = Modifier.height(20.dp)) + OpenEdXButton( + text = stringResource(R.string.course_download_all), + backgroundColor = MaterialTheme.appColors.secondaryButtonBackground, + onClick = onDownloadAllClick, + enabled = uiState.isHaveDownloadableBlocks, + content = { + val textColor = if (uiState.isHaveDownloadableBlocks) { + MaterialTheme.appColors.primaryButtonText + } else { + MaterialTheme.appColors.textPrimaryVariant + } + IconText( + text = stringResource(R.string.course_download_all), + icon = Icons.Outlined.CloudDownload, + color = textColor, + textStyle = MaterialTheme.appTypography.labelLarge + ) + } + ) + } else if (uiState.isDownloading) { + Spacer(modifier = Modifier.height(20.dp)) + OpenEdXOutlinedButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.course_cancel_course_download), + backgroundColor = MaterialTheme.appColors.background, + borderColor = MaterialTheme.appColors.error, + textColor = MaterialTheme.appColors.error, + onClick = onCancelDownloadClick, + content = { + IconText( + text = stringResource(R.string.course_cancel_course_download), + icon = Icons.Rounded.Close, + color = MaterialTheme.appColors.error, + textStyle = MaterialTheme.appTypography.labelLarge + ) + } + ) + } + if (uiState.largestDownloads.isNotEmpty()) { + Spacer(modifier = Modifier.height(20.dp)) + LargestDownloads( + largestDownloads = uiState.largestDownloads, + isDownloading = uiState.isDownloading, + onDeleteClick = onDeleteClick, + onDeleteAllClick = onDeleteAllClick, + ) + } + } + } + } + } + } +} + +@Composable +private fun LargestDownloads( + largestDownloads: List, + isDownloading: Boolean, + onDeleteClick: (downloadModel: DownloadModel) -> Unit, + onDeleteAllClick: () -> Unit, +) { + var isEditingEnabled by rememberSaveable { + mutableStateOf(false) + } + val text = if (!isEditingEnabled) { + stringResource(coreR.string.core_edit) + } else { + stringResource(coreR.string.core_label_done) + } + + LaunchedEffect(isDownloading) { + if (isDownloading) { + isEditingEnabled = false + } + } + + Column { + Row { + Text( + modifier = Modifier.weight(1f), + text = stringResource(R.string.course_largest_downloads), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textDark + ) + if (!isDownloading) { + Text( + modifier = Modifier.clickable { + isEditingEnabled = !isEditingEnabled + }, + text = text, + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.textAccent, + ) + } + } + Spacer(modifier = Modifier.height(20.dp)) + largestDownloads.forEach { + DownloadItem( + downloadModel = it, + isEditingEnabled = isEditingEnabled, + onDeleteClick = onDeleteClick + ) + } + if (!isDownloading) { + OpenEdXOutlinedButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.course_remove_all_downloads), + backgroundColor = MaterialTheme.appColors.background, + borderColor = MaterialTheme.appColors.error, + textColor = MaterialTheme.appColors.error, + onClick = onDeleteAllClick, + content = { + IconText( + text = stringResource(R.string.course_remove_all_downloads), + icon = Icons.Rounded.Delete, + color = MaterialTheme.appColors.error, + textStyle = MaterialTheme.appTypography.labelLarge + ) + } + ) + } + } +} + +@Composable +private fun DownloadItem( + modifier: Modifier = Modifier, + downloadModel: DownloadModel, + isEditingEnabled: Boolean, + onDeleteClick: (downloadModel: DownloadModel) -> Unit +) { + val fileIcon = if (downloadModel.type == FileType.VIDEO) { + Icons.Outlined.SmartDisplay + } else { + Icons.AutoMirrored.Outlined.InsertDriveFile + } + val downloadIcon: ImageVector + val downloadIconTint: Color + val downloadIconClick: Modifier + if (isEditingEnabled) { + downloadIcon = Icons.Rounded.Delete + downloadIconTint = MaterialTheme.appColors.error + downloadIconClick = Modifier.clickable { + onDeleteClick(downloadModel) + } + } else { + downloadIcon = Icons.Default.CloudDone + downloadIconTint = MaterialTheme.appColors.successGreen + downloadIconClick = Modifier + } + + Column { + Row( + modifier = modifier + .fillMaxWidth() + .padding(start = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = fileIcon, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = downloadModel.title, + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textDark + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = downloadModel.size.toFileSize(1, false), + style = MaterialTheme.appTypography.labelSmall, + color = MaterialTheme.appColors.textDark + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Icon( + modifier = Modifier + .size(24.dp) + .then(downloadIconClick), + imageVector = downloadIcon, + tint = downloadIconTint, + contentDescription = null + ) + } + Spacer(modifier = Modifier.height(12.dp)) + Divider() + Spacer(modifier = Modifier.height(12.dp)) + } +} + +@Composable +private fun DownloadProgress( + modifier: Modifier = Modifier, + uiState: CourseOfflineUIState, +) { + Column( + modifier = modifier + ) { + Row( + modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = uiState.downloadedSize, + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.successGreen + ) + Text( + text = uiState.readyToDownloadSize, + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textDark + ) + } + Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier + .fillMaxWidth() + .height(40.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + IconText( + text = stringResource(R.string.course_downloaded), + icon = Icons.Default.CloudDone, + color = MaterialTheme.appColors.successGreen, + textStyle = MaterialTheme.appTypography.labelLarge + ) + if (!uiState.isDownloading) { + IconText( + text = stringResource(R.string.course_ready_to_download), + icon = Icons.Outlined.CloudDownload, + color = MaterialTheme.appColors.textDark, + textStyle = MaterialTheme.appTypography.labelLarge + ) + } else { + IconText( + text = stringResource(R.string.course_downloading), + icon = Icons.Outlined.CloudDownload, + color = MaterialTheme.appColors.textDark, + textStyle = MaterialTheme.appTypography.labelLarge + ) + } + } + if (uiState.progressBarValue != 0f) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(6.dp) + .clip(CircleShape), + progress = uiState.progressBarValue, + strokeCap = StrokeCap.Round, + color = MaterialTheme.appColors.successGreen, + backgroundColor = MaterialTheme.appColors.progressBarBackgroundColor + ) + } else { + Text( + text = stringResource(R.string.course_you_can_download_course_content_offline), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textDark + ) + } + } +} + +@Composable +private fun NoDownloadableBlocksProgress( + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + ) { + Text( + text = stringResource(R.string.course_0mb), + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textFieldHint + ) + Spacer(modifier = Modifier.height(4.dp)) + IconText( + text = stringResource(R.string.course_available_to_download), + icon = Icons.Outlined.CloudDownload, + color = MaterialTheme.appColors.textFieldHint, + textStyle = MaterialTheme.appTypography.labelLarge + ) + Spacer(modifier = Modifier.height(20.dp)) + Text( + text = stringResource(R.string.course_no_available_to_download_offline), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textDark + ) + } +} + +@Preview +@Composable +private fun CourseOfflineUIPreview() { + OpenEdXTheme { + CourseOfflineUI( + windowSize = rememberWindowSize(), + hasInternetConnection = true, + uiState = CourseOfflineUIState( + isHaveDownloadableBlocks = true, + readyToDownloadSize = "159MB", + downloadedSize = "0MB", + progressBarValue = 0f, + isDownloading = true, + largestDownloads = listOf( + DownloadModel( + "", + "", + "", + 0, + "", + "", + FileType.X_BLOCK, + DownloadedState.DOWNLOADED, + null + ) + ), + ), + onDownloadAllClick = {}, + onCancelDownloadClick = {}, + onDeleteClick = {}, + onDeleteAllClick = {} + ) + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineUIState.kt b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineUIState.kt new file mode 100644 index 000000000..8abde204f --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineUIState.kt @@ -0,0 +1,12 @@ +package org.openedx.course.presentation.offline + +import org.openedx.core.module.db.DownloadModel + +data class CourseOfflineUIState( + val isHaveDownloadableBlocks: Boolean, + val largestDownloads: List, + val isDownloading: Boolean, + val readyToDownloadSize: String, + val downloadedSize: String, + val progressBarValue: Float, +) diff --git a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt new file mode 100644 index 000000000..88c8a60c4 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt @@ -0,0 +1,221 @@ +package org.openedx.course.presentation.offline + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.InsertDriveFile +import androidx.compose.material.icons.outlined.SmartDisplay +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.openedx.core.BlockType +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.Block +import org.openedx.core.module.DownloadWorkerController +import org.openedx.core.module.db.DownloadDao +import org.openedx.core.module.db.DownloadModel +import org.openedx.core.module.db.FileType +import org.openedx.core.module.download.BaseDownloadViewModel +import org.openedx.core.module.download.DownloadHelper +import org.openedx.core.presentation.CoreAnalytics +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.course.domain.interactor.CourseInteractor +import org.openedx.course.presentation.download.DownloadDialogItem +import org.openedx.course.presentation.download.DownloadDialogManager +import org.openedx.foundation.extension.toFileSize +import org.openedx.foundation.utils.FileUtil + +class CourseOfflineViewModel( + val courseId: String, + val courseTitle: String, + val courseInteractor: CourseInteractor, + private val preferencesManager: CorePreferences, + private val downloadDialogManager: DownloadDialogManager, + private val fileUtil: FileUtil, + private val networkConnection: NetworkConnection, + coreAnalytics: CoreAnalytics, + downloadDao: DownloadDao, + workerController: DownloadWorkerController, + downloadHelper: DownloadHelper, +) : BaseDownloadViewModel( + courseId, + downloadDao, + preferencesManager, + workerController, + coreAnalytics, + downloadHelper, +) { + private val _uiState = MutableStateFlow( + CourseOfflineUIState( + isHaveDownloadableBlocks = false, + largestDownloads = emptyList(), + isDownloading = false, + readyToDownloadSize = "", + downloadedSize = "", + progressBarValue = 0f, + ) + ) + val uiState: StateFlow + get() = _uiState.asStateFlow() + + val hasInternetConnection: Boolean + get() = networkConnection.isOnline() + + init { + viewModelScope.launch { + downloadModelsStatusFlow.collect { + val isDownloading = it.any { it.value.isWaitingOrDownloading } + _uiState.update { it.copy(isDownloading = isDownloading) } + } + } + + viewModelScope.launch { + async { initDownloadFragment() }.await() + getOfflineData() + } + } + + fun downloadAllBlocks(fragmentManager: FragmentManager) { + viewModelScope.launch { + val courseStructure = courseInteractor.getCourseStructureFromCache(courseId) + val downloadModels = courseInteractor.getAllDownloadModels() + val subSectionsBlocks = allBlocks.values.filter { it.type == BlockType.SEQUENTIAL } + val notDownloadedSubSectionBlocks = subSectionsBlocks.mapNotNull { subSection -> + val verticalBlocks = allBlocks.values.filter { it.id in subSection.descendants } + val notDownloadedBlocks = courseStructure.blockData.filter { block -> + block.id in verticalBlocks.flatMap { it.descendants } && + block.isDownloadable && + downloadModels.none { it.id == block.id } + } + if (notDownloadedBlocks.isNotEmpty()) subSection else null + } + + downloadDialogManager.showPopup( + subSectionsBlocks = notDownloadedSubSectionBlocks, + courseId = courseId, + isBlocksDownloaded = false, + fragmentManager = fragmentManager, + removeDownloadModels = ::removeDownloadModels, + saveDownloadModels = { blockId -> + saveDownloadModels(fileUtil.getExternalAppDir().path, blockId) + } + ) + } + } + + fun removeDownloadModel(downloadModel: DownloadModel, fragmentManager: FragmentManager) { + val icon = when (downloadModel.type) { + FileType.VIDEO -> Icons.Outlined.SmartDisplay + else -> Icons.AutoMirrored.Outlined.InsertDriveFile + } + val downloadDialogItem = DownloadDialogItem( + title = downloadModel.title, + size = downloadModel.size, + icon = icon + ) + downloadDialogManager.showRemoveDownloadModelPopup( + downloadDialogItem = downloadDialogItem, + fragmentManager = fragmentManager, + removeDownloadModels = { + super.removeBlockDownloadModel(downloadModel.id) + } + ) + } + + fun deleteAll(fragmentManager: FragmentManager) { + viewModelScope.launch { + val downloadModels = courseInteractor.getAllDownloadModels().filter { it.courseId == courseId } + val totalSize = downloadModels.sumOf { it.size } + val downloadDialogItem = DownloadDialogItem( + title = courseTitle, + size = totalSize, + icon = Icons.AutoMirrored.Outlined.InsertDriveFile + ) + downloadDialogManager.showRemoveDownloadModelPopup( + downloadDialogItem = downloadDialogItem, + fragmentManager = fragmentManager, + removeDownloadModels = { + downloadModels.forEach { super.removeBlockDownloadModel(it.id) } + } + ) + } + } + + fun removeDownloadModel() { + viewModelScope.launch { + courseInteractor.getAllDownloadModels() + .filter { it.courseId == courseId && it.downloadedState.isWaitingOrDownloading } + .forEach { removeBlockDownloadModel(it.id) } + } + } + + private suspend fun initDownloadFragment() { + val courseStructure = courseInteractor.getCourseStructureFromCache(courseId) + setBlocks(courseStructure.blockData) + allBlocks.values + .filter { it.type == BlockType.SEQUENTIAL } + .forEach { addDownloadableChildrenForSequentialBlock(it) } + } + + private fun getOfflineData() { + viewModelScope.launch { + val courseStructure = courseInteractor.getCourseStructureFromCache(courseId) + val totalDownloadableSize = getFilesSize(courseStructure.blockData) + + if (totalDownloadableSize == 0L) return@launch + + courseInteractor.getDownloadModels().collect { downloadModels -> + val completedDownloads = + downloadModels.filter { it.downloadedState.isDownloaded && it.courseId == courseId } + val completedDownloadIds = completedDownloads.map { it.id } + val downloadedBlocks = courseStructure.blockData.filter { it.id in completedDownloadIds } + + updateUIState( + totalDownloadableSize, + completedDownloads, + downloadedBlocks + ) + } + } + } + + private fun updateUIState( + totalDownloadableSize: Long, + completedDownloads: List, + downloadedBlocks: List + ) { + val downloadedSize = getFilesSize(downloadedBlocks) + val realDownloadedSize = completedDownloads.sumOf { it.size } + val largestDownloads = completedDownloads + .sortedByDescending { it.size } + .take(5) + + _uiState.update { + it.copy( + isHaveDownloadableBlocks = true, + largestDownloads = largestDownloads, + readyToDownloadSize = (totalDownloadableSize - downloadedSize).toFileSize(1, false), + downloadedSize = realDownloadedSize.toFileSize(1, false), + progressBarValue = downloadedSize.toFloat() / totalDownloadableSize.toFloat() + ) + } + } + + private fun getFilesSize(blocks: List): Long { + return blocks.filter { it.isDownloadable }.sumOf { + when (it.downloadableType) { + FileType.VIDEO -> { + it.studentViewData?.encodedVideos + ?.getPreferredVideoInfoForDownloading(preferencesManager.videoSettings.videoDownloadQuality) + ?.fileSize ?: 0 + } + + FileType.X_BLOCK -> it.offlineDownload?.fileSize ?: 0 + else -> 0 + } + } + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt index 7e950cba8..f1b9119ff 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt @@ -2,7 +2,6 @@ package org.openedx.course.presentation.outline import android.content.res.Configuration.UI_MODE_NIGHT_NO import android.content.res.Configuration.UI_MODE_NIGHT_YES -import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -17,24 +16,27 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.Divider +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.Icon +import androidx.compose.material.LinearProgressIndicator import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.AndroidUriHandler import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Devices @@ -43,139 +45,116 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentManager import org.openedx.core.BlockType -import org.openedx.core.UIMessage +import org.openedx.core.NoContentScreenType +import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts import org.openedx.core.domain.model.CourseDatesBannerInfo import org.openedx.core.domain.model.CourseStructure import org.openedx.core.domain.model.CoursewareAccess -import org.openedx.core.extension.takeIfNotEmpty +import org.openedx.core.domain.model.OfflineDownload +import org.openedx.core.domain.model.Progress import org.openedx.core.presentation.course.CourseViewMode +import org.openedx.core.ui.CircularProgress import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.NoContentScreen import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.TextIcon -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography -import org.openedx.core.ui.windowSizeValue import org.openedx.course.R -import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.ui.CourseDatesBanner import org.openedx.course.presentation.ui.CourseDatesBannerTablet -import org.openedx.course.presentation.ui.CourseExpandableChapterCard -import org.openedx.course.presentation.ui.CourseSectionCard -import org.openedx.course.presentation.ui.CourseSubSectionItem -import java.io.File +import org.openedx.course.presentation.ui.CourseMessage +import org.openedx.course.presentation.ui.CourseSection +import org.openedx.foundation.extension.takeIfNotEmpty +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.windowSizeValue import java.util.Date import org.openedx.core.R as CoreR @Composable fun CourseOutlineScreen( windowSize: WindowSize, - courseOutlineViewModel: CourseOutlineViewModel, - courseRouter: CourseRouter, + viewModel: CourseOutlineViewModel, fragmentManager: FragmentManager, - onResetDatesClick: () -> Unit + onResetDatesClick: () -> Unit, ) { - val uiState by courseOutlineViewModel.uiState.collectAsState() - val uiMessage by courseOutlineViewModel.uiMessage.collectAsState(null) + val uiState by viewModel.uiState.collectAsState() + val uiMessage by viewModel.uiMessage.collectAsState(null) + val resumeBlockId by viewModel.resumeBlockId.collectAsState("") val context = LocalContext.current + LaunchedEffect(resumeBlockId) { + if (resumeBlockId.isNotEmpty()) { + viewModel.openBlock(fragmentManager, resumeBlockId) + } + } + CourseOutlineUI( windowSize = windowSize, uiState = uiState, - isCourseNestedListEnabled = courseOutlineViewModel.isCourseNestedListEnabled, 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( + if (viewModel.switchCourseSections(block.id)) { + viewModel.sequentialClickedEvent( block.blockId, block.displayName ) } }, onSubSectionClick = { subSectionBlock -> - courseOutlineViewModel.courseSubSectionUnit[subSectionBlock.id]?.let { unit -> - courseOutlineViewModel.logUnitDetailViewedEvent( - unit.blockId, - unit.displayName + if (viewModel.isCourseDropdownNavigationEnabled) { + viewModel.courseSubSectionUnit[subSectionBlock.id]?.let { unit -> + viewModel.logUnitDetailViewedEvent( + unit.blockId, + unit.displayName + ) + viewModel.courseRouter.navigateToCourseContainer( + fragmentManager, + courseId = viewModel.courseId, + unitId = unit.id, + mode = CourseViewMode.FULL + ) + } + } else { + viewModel.sequentialClickedEvent( + subSectionBlock.blockId, + subSectionBlock.displayName ) - courseRouter.navigateToCourseContainer( - fragmentManager, - courseId = courseOutlineViewModel.courseId, - unitId = unit.id, + viewModel.courseRouter.navigateToCourseSubsections( + fm = fragmentManager, + courseId = viewModel.courseId, + subSectionId = subSectionBlock.id, mode = CourseViewMode.FULL ) } }, onResumeClick = { componentId -> - courseOutlineViewModel.resumeSectionBlock?.let { subSection -> - courseOutlineViewModel.resumeCourseTappedEvent(subSection.id) - courseOutlineViewModel.resumeVerticalBlock?.let { unit -> - if (courseOutlineViewModel.isCourseExpandableSectionsEnabled) { - courseRouter.navigateToCourseContainer( - fm = fragmentManager, - courseId = courseOutlineViewModel.courseId, - unitId = unit.id, - componentId = componentId, - mode = CourseViewMode.FULL - ) - } else { - courseRouter.navigateToCourseSubsections( - fragmentManager, - courseId = courseOutlineViewModel.courseId, - subSectionId = subSection.id, - mode = CourseViewMode.FULL, - unitId = unit.id, - componentId = componentId - ) - } - } - } + viewModel.openBlock( + fragmentManager, + componentId + ) }, - onDownloadClick = { - if (courseOutlineViewModel.isBlockDownloading(it.id)) { - courseRouter.navigateToDownloadQueue( - 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 - ) - } + onDownloadClick = { blocksIds -> + viewModel.downloadBlocks( + blocksIds = blocksIds, + fragmentManager = fragmentManager, + ) }, onResetDatesClick = { - courseOutlineViewModel.resetCourseDatesBanner( + viewModel.resetCourseDatesBanner( onResetDates = { onResetDatesClick() } ) }, onCertificateClick = { - courseOutlineViewModel.viewCertificateTappedEvent() + viewModel.viewCertificateTappedEvent() it.takeIfNotEmpty() ?.let { url -> AndroidUriHandler(context).openUri(url) } } @@ -186,13 +165,11 @@ fun CourseOutlineScreen( private fun CourseOutlineUI( windowSize: WindowSize, uiState: CourseOutlineUIState, - isCourseNestedListEnabled: Boolean, uiMessage: UIMessage?, - onItemClick: (Block) -> Unit, onExpandClick: (Block) -> Unit, onSubSectionClick: (Block) -> Unit, onResumeClick: (String) -> Unit, - onDownloadClick: (Block) -> Unit, + onDownloadClick: (blockIds: List) -> Unit, onResetDatesClick: () -> Unit, onCertificateClick: (String) -> Unit, ) { @@ -248,125 +225,130 @@ private fun CourseOutlineUI( Box { when (uiState) { is CourseOutlineUIState.CourseData -> { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = listBottomPadding - ) { - if (uiState.datesBannerInfo.isBannerAvailableForDashboard()) { - item { - Box( - modifier = Modifier - .padding(all = 8.dp) - ) { - if (windowSize.isTablet) { - CourseDatesBannerTablet( - banner = uiState.datesBannerInfo, - resetDates = onResetDatesClick, - ) - } else { - CourseDatesBanner( - banner = uiState.datesBannerInfo, - resetDates = onResetDatesClick, - ) + if (uiState.courseStructure.blockData.isEmpty()) { + NoContentScreen(noContentScreenType = NoContentScreenType.COURSE_OUTLINE) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = listBottomPadding + ) { + if (uiState.datesBannerInfo.isBannerAvailableForDashboard()) { + item { + Box( + modifier = Modifier + .padding(all = 8.dp) + ) { + if (windowSize.isTablet) { + CourseDatesBannerTablet( + banner = uiState.datesBannerInfo, + resetDates = onResetDatesClick, + ) + } else { + CourseDatesBanner( + banner = uiState.datesBannerInfo, + resetDates = onResetDatesClick, + ) + } } } } - } - if (uiState.resumeComponent != null) { - item { - Box(listPadding) { - if (windowSize.isTablet) { - ResumeCourseTablet( - modifier = Modifier.padding(vertical = 16.dp), - block = uiState.resumeComponent, - onResumeClick = onResumeClick - ) - } else { - ResumeCourse( - modifier = Modifier.padding(vertical = 16.dp), - block = uiState.resumeComponent, - onResumeClick = onResumeClick - ) - } + + val certificate = uiState.courseStructure.certificate + if (certificate?.isCertificateEarned() == true) { + item { + CourseMessage( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp) + .then(listPadding), + icon = painterResource(R.drawable.ic_course_certificate), + message = stringResource( + R.string.course_you_earned_certificate, + uiState.courseStructure.name + ), + action = stringResource(R.string.course_view_certificate), + onActionClick = { + onCertificateClick( + certificate.certificateURL ?: "" + ) + } + ) } } - } - if (isCourseNestedListEnabled) { - uiState.courseStructure.blockData.forEach { section -> - val courseSubSections = - uiState.courseSubSections[section.id] - val courseSectionsState = - uiState.courseSectionsState[section.id] + val progress = uiState.courseStructure.progress + if (progress != null && progress.totalAssignmentsCount > 0) { item { - Column { - CourseExpandableChapterCard( - modifier = listPadding, - block = section, - onItemClick = onExpandClick, - arrowDegrees = if (courseSectionsState == true) -90f else 90f - ) - Divider() - } + CourseProgress( + modifier = Modifier + .fillMaxWidth() + .padding( + top = 16.dp, + start = 24.dp, + end = 24.dp + ), + progress = progress + ) } + } - courseSubSections?.forEach { subSectionBlock -> - item { - Column { - AnimatedVisibility( - visible = courseSectionsState == true - ) { - Column { - val downloadsCount = - uiState.subSectionsDownloadsCount[subSectionBlock.id] - ?: 0 - - CourseSubSectionItem( - modifier = listPadding, - block = subSectionBlock, - downloadedState = uiState.downloadedState[subSectionBlock.id], - downloadsCount = downloadsCount, - onClick = onSubSectionClick, - onDownloadClick = onDownloadClick - ) - Divider() - } - } + if (uiState.resumeComponent != null) { + item { + Box(listPadding) { + if (windowSize.isTablet) { + ResumeCourseTablet( + modifier = Modifier.padding(vertical = 16.dp), + block = uiState.resumeComponent, + displayName = uiState.resumeUnitTitle, + onResumeClick = onResumeClick + ) + } else { + ResumeCourse( + modifier = Modifier.padding(vertical = 16.dp), + block = uiState.resumeComponent, + displayName = uiState.resumeUnitTitle, + onResumeClick = onResumeClick + ) } } } } - return@LazyColumn - } - items(uiState.courseStructure.blockData) { block -> - Column(listPadding) { - if (block.type == BlockType.CHAPTER) { - Text( - modifier = Modifier.padding( - top = 36.dp, - bottom = 8.dp - ), - text = block.displayName, - style = MaterialTheme.appTypography.titleMedium, - color = MaterialTheme.appColors.textPrimaryVariant - ) - } else { - CourseSectionCard( - block = block, - downloadedState = uiState.downloadedState[block.id], - onItemClick = onItemClick, + item { + Spacer(modifier = Modifier.height(12.dp)) + } + uiState.courseStructure.blockData.forEach { section -> + val courseSubSections = + uiState.courseSubSections[section.id] + val courseSectionsState = + uiState.courseSectionsState[section.id] + + item { + CourseSection( + modifier = listPadding.padding(vertical = 4.dp), + block = section, + onItemClick = onExpandClick, + useRelativeDates = uiState.useRelativeDates, + courseSectionsState = courseSectionsState, + courseSubSections = courseSubSections, + downloadedStateMap = uiState.downloadedState, + onSubSectionClick = onSubSectionClick, onDownloadClick = onDownloadClick ) - Divider() } } } } } - CourseOutlineUIState.Loading -> {} + CourseOutlineUIState.Error -> { + NoContentScreen(noContentScreenType = NoContentScreenType.COURSE_OUTLINE) + } + + CourseOutlineUIState.Loading -> { + CircularProgress() + } } } } @@ -378,6 +360,7 @@ private fun CourseOutlineUI( private fun ResumeCourse( modifier: Modifier = Modifier, block: Block, + displayName: String, onResumeClick: (String) -> Unit, ) { Column( @@ -400,7 +383,7 @@ private fun ResumeCourse( tint = MaterialTheme.appColors.textPrimary ) Text( - text = block.displayName, + text = displayName, color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.titleMedium, maxLines = 1, @@ -417,7 +400,7 @@ private fun ResumeCourse( TextIcon( text = stringResource(id = R.string.course_resume), painter = painterResource(id = CoreR.drawable.core_ic_forward), - color = MaterialTheme.appColors.buttonText, + color = MaterialTheme.appColors.primaryButtonText, textStyle = MaterialTheme.appTypography.labelLarge ) } @@ -430,6 +413,7 @@ private fun ResumeCourse( private fun ResumeCourseTablet( modifier: Modifier = Modifier, block: Block, + displayName: String, onResumeClick: (String) -> Unit, ) { Row( @@ -458,7 +442,7 @@ private fun ResumeCourseTablet( tint = MaterialTheme.appColors.textPrimary ) Text( - text = block.displayName, + text = displayName, color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.titleMedium, overflow = TextOverflow.Ellipsis, @@ -476,7 +460,7 @@ private fun ResumeCourseTablet( TextIcon( text = stringResource(id = R.string.course_resume), painter = painterResource(id = CoreR.drawable.core_ic_forward), - color = MaterialTheme.appColors.buttonText, + color = MaterialTheme.appColors.primaryButtonText, textStyle = MaterialTheme.appTypography.labelLarge ) } @@ -484,6 +468,37 @@ private fun ResumeCourseTablet( } } +@Composable +private fun CourseProgress( + modifier: Modifier = Modifier, + progress: Progress, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(10.dp) + .clip(CircleShape), + progress = progress.value, + color = MaterialTheme.appColors.progressBarColor, + backgroundColor = MaterialTheme.appColors.progressBarBackgroundColor + ) + Text( + text = pluralStringResource( + R.plurals.course_assignments_complete, + progress.assignmentsCompleted, + progress.assignmentsCompleted, + progress.totalAssignmentsCount + ), + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.labelSmall + ) + } +} + fun getUnitBlockIcon(block: Block): Int { return when (block.type) { BlockType.VIDEO -> R.drawable.ic_course_video @@ -504,6 +519,7 @@ private fun CourseOutlineScreenPreview() { mockCourseStructure, mapOf(), mockChapterBlock, + "Resumed Unit", mapOf(), mapOf(), mapOf(), @@ -513,11 +529,10 @@ private fun CourseOutlineScreenPreview() { verifiedUpgradeLink = "", contentTypeGatingEnabled = false, hasEnded = false - ) + ), + true ), - isCourseNestedListEnabled = true, uiMessage = null, - onItemClick = {}, onExpandClick = {}, onSubSectionClick = {}, onResumeClick = {}, @@ -539,6 +554,7 @@ private fun CourseOutlineScreenTabletPreview() { mockCourseStructure, mapOf(), mockChapterBlock, + "Resumed Unit", mapOf(), mapOf(), mapOf(), @@ -548,11 +564,10 @@ private fun CourseOutlineScreenTabletPreview() { verifiedUpgradeLink = "", contentTypeGatingEnabled = false, hasEnded = false - ) + ), + true ), - isCourseNestedListEnabled = true, uiMessage = null, - onItemClick = {}, onExpandClick = {}, onSubSectionClick = {}, onResumeClick = {}, @@ -568,10 +583,15 @@ private fun CourseOutlineScreenTabletPreview() { @Composable private fun ResumeCoursePreview() { OpenEdXTheme { - ResumeCourse(block = mockChapterBlock) {} + ResumeCourse(block = mockChapterBlock, displayName = "Resumed Unit") {} } } +private val mockAssignmentProgress = AssignmentProgress( + assignmentType = "Home", + numPointsEarned = 1f, + numPointsPossible = 3f +) private val mockChapterBlock = Block( id = "id", blockId = "blockId", @@ -587,7 +607,10 @@ private val mockChapterBlock = Block( descendants = emptyList(), descendantsType = BlockType.CHAPTER, completion = 0.0, - containsGatedContent = false + containsGatedContent = false, + assignmentProgress = mockAssignmentProgress, + due = Date(), + offlineDownload = null ) private val mockSequentialBlock = Block( id = "id", @@ -604,7 +627,10 @@ private val mockSequentialBlock = Block( descendants = emptyList(), descendantsType = BlockType.CHAPTER, completion = 0.0, - containsGatedContent = false + containsGatedContent = false, + assignmentProgress = mockAssignmentProgress, + due = Date(), + offlineDownload = OfflineDownload("fileUrl", "", 1), ) private val mockCourseStructure = CourseStructure( @@ -628,5 +654,6 @@ private val mockCourseStructure = CourseStructure( ), media = null, certificate = null, - isSelfPaced = false + isSelfPaced = false, + progress = Progress(1, 3), ) diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineUIState.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineUIState.kt index 0307b1f8e..55cf52137 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineUIState.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineUIState.kt @@ -10,11 +10,14 @@ sealed class CourseOutlineUIState { val courseStructure: CourseStructure, val downloadedState: Map, val resumeComponent: Block?, + val resumeUnitTitle: String, val courseSubSections: Map>, val courseSectionsState: Map, val subSectionsDownloadsCount: Map, val datesBannerInfo: CourseDatesBannerInfo, + val useRelativeDates: Boolean, ) : CourseOutlineUIState() + data object Error : CourseOutlineUIState() data object Loading : CourseOutlineUIState() } diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt index 569498ab6..9e997ed5f 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt @@ -1,5 +1,6 @@ package org.openedx.course.presentation.outline +import androidx.fragment.app.FragmentManager import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -10,7 +11,6 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.openedx.core.BlockType import org.openedx.core.R -import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Block @@ -18,27 +18,34 @@ import org.openedx.core.domain.model.CourseComponentStatus import org.openedx.core.domain.model.CourseDateBlock import org.openedx.core.domain.model.CourseDatesBannerInfo import org.openedx.core.domain.model.CourseDatesResult +import org.openedx.core.domain.model.CourseStructure import org.openedx.core.extension.getSequentialBlocks import org.openedx.core.extension.getVerticalBlocks -import org.openedx.core.extension.isInternetError import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.download.BaseDownloadViewModel +import org.openedx.core.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics -import org.openedx.core.system.ResourceManager +import org.openedx.core.presentation.course.CourseViewMode +import org.openedx.core.presentation.settings.calendarsync.CalendarSyncDialogType 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.CourseOpenBlock import org.openedx.core.system.notifier.CourseStructureUpdated import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseAnalyticsEvent import org.openedx.course.presentation.CourseAnalyticsKey -import org.openedx.course.presentation.calendarsync.CalendarSyncDialogType +import org.openedx.course.presentation.CourseRouter import org.openedx.course.R as courseR +import org.openedx.course.presentation.download.DownloadDialogManager +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager +import org.openedx.foundation.utils.FileUtil class CourseOutlineViewModel( val courseId: String, @@ -50,17 +57,22 @@ class CourseOutlineViewModel( private val networkConnection: NetworkConnection, private val preferencesManager: CorePreferences, private val analytics: CourseAnalytics, + private val downloadDialogManager: DownloadDialogManager, + private val fileUtil: FileUtil, + val courseRouter: CourseRouter, coreAnalytics: CoreAnalytics, downloadDao: DownloadDao, workerController: DownloadWorkerController, + downloadHelper: DownloadHelper, ) : BaseDownloadViewModel( courseId, downloadDao, preferencesManager, workerController, - coreAnalytics + coreAnalytics, + downloadHelper ) { - val isCourseNestedListEnabled get() = config.isCourseNestedListEnabled() + val isCourseDropdownNavigationEnabled get() = config.getCourseUIConfig().isCourseDropdownNavigationEnabled private val _uiState = MutableStateFlow(CourseOutlineUIState.Loading) val uiState: StateFlow @@ -70,28 +82,33 @@ class CourseOutlineViewModel( val uiMessage: SharedFlow get() = _uiMessage.asSharedFlow() - var resumeSectionBlock: Block? = null - private set - var resumeVerticalBlock: Block? = null - private set + private val _resumeBlockId = MutableSharedFlow() + val resumeBlockId: SharedFlow + get() = _resumeBlockId.asSharedFlow() - val isCourseExpandableSectionsEnabled get() = config.isCourseNestedListEnabled() + private var resumeSectionBlock: Block? = null + private var resumeVerticalBlock: Block? = null + + private val isCourseExpandableSectionsEnabled get() = config.getCourseUIConfig().isCourseDropdownNavigationEnabled private val courseSubSections = mutableMapOf>() private val subSectionsDownloadsCount = mutableMapOf() val courseSubSectionUnit = mutableMapOf() + private var isOfflineBlocksUpToDate = false + init { viewModelScope.launch { courseNotifier.notifier.collect { event -> - when(event) { + when (event) { is CourseStructureUpdated -> { if (event.courseId == courseId) { - updateCourseData() + getCourseData() } } - is CourseDataReady -> { - getCourseData() + + is CourseOpenBlock -> { + _resumeBlockId.emit(event.blockId) } } } @@ -105,14 +122,18 @@ class CourseOutlineViewModel( courseStructure = state.courseStructure, downloadedState = it.toMap(), resumeComponent = state.resumeComponent, + resumeUnitTitle = resumeVerticalBlock?.displayName ?: "", courseSubSections = courseSubSections, courseSectionsState = state.courseSectionsState, subSectionsDownloadsCount = subSectionsDownloadsCount, datesBannerInfo = state.datesBannerInfo, + useRelativeDates = preferencesManager.isRelativeDatesEnabled ) } } } + + getCourseData() } override fun saveDownloadModels(folder: String, id: String) { @@ -129,14 +150,7 @@ class CourseOutlineViewModel( } } - fun updateCourseData() { - getCourseDataInternal() - } - fun getCourseData() { - viewModelScope.launch { - courseNotifier.send(CourseLoading(true)) - } getCourseDataInternal() } @@ -150,10 +164,12 @@ class CourseOutlineViewModel( courseStructure = state.courseStructure, downloadedState = state.downloadedState, resumeComponent = state.resumeComponent, + resumeUnitTitle = resumeVerticalBlock?.displayName ?: "", courseSubSections = courseSubSections, courseSectionsState = courseSectionsState, subSectionsDownloadsCount = subSectionsDownloadsCount, datesBannerInfo = state.datesBannerInfo, + useRelativeDates = preferencesManager.isRelativeDatesEnabled ) courseSectionsState[blockId] ?: false @@ -166,7 +182,7 @@ class CourseOutlineViewModel( private fun getCourseDataInternal() { viewModelScope.launch { try { - var courseStructure = interactor.getCourseStructureFromCache() + var courseStructure = interactor.getCourseStructure(courseId) val blocks = courseStructure.blockData val courseStatus = if (networkConnection.isOnline()) { @@ -192,6 +208,7 @@ class CourseOutlineViewModel( val datesBannerInfo = courseDatesResult.courseBanner checkIfCalendarOutOfDate(courseDatesResult.datesSection.values.flatten()) + updateOutdatedOfflineXBlocks(courseStructure) setBlocks(blocks) courseSubSections.clear() @@ -206,13 +223,15 @@ class CourseOutlineViewModel( courseStructure = courseStructure, downloadedState = getDownloadModelsStatus(), resumeComponent = getResumeBlock(blocks, courseStatus.lastVisitedBlockId), + resumeUnitTitle = resumeVerticalBlock?.displayName ?: "", courseSubSections = courseSubSections, courseSectionsState = courseSectionsState, subSectionsDownloadsCount = subSectionsDownloadsCount, datesBannerInfo = datesBannerInfo, + useRelativeDates = preferencesManager.isRelativeDatesEnabled ) - courseNotifier.send(CourseLoading(false)) } catch (e: Exception) { + _uiState.value = CourseOutlineUIState.Error if (e.isInternetError()) { _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection))) } else { @@ -230,17 +249,12 @@ class CourseOutlineViewModel( resultBlocks.add(block) block.descendants.forEach { descendant -> blocks.find { it.id == descendant }?.let { sequentialBlock -> - if (isCourseNestedListEnabled) { - courseSubSections.getOrPut(block.id) { mutableListOf() } - .add(sequentialBlock) - courseSubSectionUnit[sequentialBlock.id] = - sequentialBlock.getFirstDescendantBlock(blocks) - subSectionsDownloadsCount[sequentialBlock.id] = - sequentialBlock.getDownloadsCount(blocks) - - } else { - resultBlocks.add(sequentialBlock) - } + courseSubSections.getOrPut(block.id) { mutableListOf() } + .add(sequentialBlock) + courseSubSectionUnit[sequentialBlock.id] = + sequentialBlock.getFirstDescendantBlock(blocks) + subSectionsDownloadsCount[sequentialBlock.id] = + sequentialBlock.getDownloadsCount(blocks) addDownloadableChildrenForSequentialBlock(sequentialBlock) } } @@ -265,7 +279,7 @@ class CourseOutlineViewModel( viewModelScope.launch { try { interactor.resetCourseDates(courseId = courseId) - updateCourseData() + getCourseData() courseNotifier.send(CourseDatesShifted) onResetDates(true) } catch (e: Exception) { @@ -279,6 +293,41 @@ class CourseOutlineViewModel( } } + fun openBlock(fragmentManager: FragmentManager, blockId: String) { + viewModelScope.launch { + val courseStructure = interactor.getCourseStructure(courseId, false) + val blocks = courseStructure.blockData + getResumeBlock(blocks, blockId) + resumeBlock(fragmentManager, blockId) + } + } + + private fun resumeBlock(fragmentManager: FragmentManager, blockId: String) { + resumeSectionBlock?.let { subSection -> + resumeCourseTappedEvent(subSection.id) + resumeVerticalBlock?.let { unit -> + if (isCourseExpandableSectionsEnabled) { + courseRouter.navigateToCourseContainer( + fm = fragmentManager, + courseId = courseId, + unitId = unit.id, + componentId = blockId, + mode = CourseViewMode.FULL + ) + } else { + courseRouter.navigateToCourseSubsections( + fragmentManager, + courseId = courseId, + subSectionId = subSection.id, + mode = CourseViewMode.FULL, + unitId = unit.id, + componentId = blockId + ) + } + } + } + } + fun viewCertificateTappedEvent() { analytics.logEvent( CourseAnalyticsEvent.VIEW_CERTIFICATE.eventName, @@ -289,7 +338,7 @@ class CourseOutlineViewModel( ) } - fun resumeCourseTappedEvent(blockId: String) { + private fun resumeCourseTappedEvent(blockId: String) { val currentState = uiState.value if (currentState is CourseOutlineUIState.CourseData) { analytics.logEvent( @@ -346,4 +395,88 @@ class CourseOutlineViewModel( ) } } + + fun downloadBlocks(blocksIds: List, fragmentManager: FragmentManager) { + viewModelScope.launch { + val courseData = _uiState.value as? CourseOutlineUIState.CourseData ?: return@launch + + val subSectionsBlocks = courseData.courseSubSections.values.flatten().filter { it.id in blocksIds } + + val blocks = subSectionsBlocks.flatMap { subSectionsBlock -> + val verticalBlocks = allBlocks.values.filter { it.id in subSectionsBlock.descendants } + allBlocks.values.filter { it.id in verticalBlocks.flatMap { it.descendants } } + } + + val downloadableBlocks = blocks.filter { it.isDownloadable } + val downloadingBlocks = blocksIds.filter { isBlockDownloading(it) } + val isAllBlocksDownloaded = downloadableBlocks.all { isBlockDownloaded(it.id) } + + val notDownloadedSubSectionBlocks = subSectionsBlocks.mapNotNull { subSectionsBlock -> + val verticalBlocks = allBlocks.values.filter { it.id in subSectionsBlock.descendants } + val notDownloadedBlocks = allBlocks.values.filter { + it.id in verticalBlocks.flatMap { it.descendants } && it.isDownloadable && !isBlockDownloaded(it.id) + } + if (notDownloadedBlocks.isNotEmpty()) { + subSectionsBlock + } else { + null + } + } + + val requiredSubSections = notDownloadedSubSectionBlocks.ifEmpty { + subSectionsBlocks + } + + if (downloadingBlocks.isNotEmpty()) { + val downloadableChildren = downloadingBlocks.flatMap { getDownloadableChildren(it).orEmpty() } + if (config.getCourseUIConfig().isCourseDownloadQueueEnabled) { + courseRouter.navigateToDownloadQueue(fragmentManager, downloadableChildren) + } else { + downloadableChildren.forEach { + if (!isBlockDownloaded(it)) { + removeBlockDownloadModel(it) + } + } + } + } else { + downloadDialogManager.showPopup( + subSectionsBlocks = requiredSubSections, + courseId = courseId, + isBlocksDownloaded = isAllBlocksDownloaded, + fragmentManager = fragmentManager, + removeDownloadModels = ::removeDownloadModels, + saveDownloadModels = { blockId -> + saveDownloadModels(fileUtil.getExternalAppDir().path, blockId) + } + ) + } + } + } + + private fun updateOutdatedOfflineXBlocks(courseStructure: CourseStructure) { + viewModelScope.launch { + if (!isOfflineBlocksUpToDate) { + val xBlocks = courseStructure.blockData.filter { it.isxBlock } + if (xBlocks.isNotEmpty()) { + val xBlockIds = xBlocks.map { it.id }.toSet() + val savedDownloadModelsMap = interactor.getAllDownloadModels() + .filter { it.id in xBlockIds } + .associateBy { it.id } + + val outdatedBlockIds = xBlocks + .filter { block -> + val savedBlock = savedDownloadModelsMap[block.id] + savedBlock != null && block.offlineDownload?.lastModified != savedBlock.lastModified + } + .map { it.id } + + outdatedBlockIds.forEach { blockId -> + interactor.removeDownloadModel(blockId) + } + saveDownloadModels(fileUtil.getExternalAppDir().path, outdatedBlockIds) + } + isOfflineBlocksUpToDate = true + } + } + } } diff --git a/course/src/main/java/org/openedx/course/presentation/section/CourseSectionFragment.kt b/course/src/main/java/org/openedx/course/presentation/section/CourseSectionFragment.kt index 297545117..7a08bd9b0 100644 --- a/course/src/main/java/org/openedx/course/presentation/section/CourseSectionFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/section/CourseSectionFragment.kt @@ -5,18 +5,39 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material.* -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close -import androidx.compose.runtime.* +import androidx.compose.material.CircularProgressIndicator +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.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.res.painterResource @@ -34,13 +55,14 @@ import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.core.BlockType -import org.openedx.core.UIMessage +import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts -import org.openedx.core.extension.serializable -import org.openedx.core.module.db.DownloadedState import org.openedx.core.presentation.course.CourseViewMode -import org.openedx.core.ui.* +import org.openedx.core.ui.BackBtn +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.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 @@ -48,7 +70,14 @@ import org.openedx.core.ui.theme.appTypography import org.openedx.course.R import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.ui.CardArrow -import java.io.File +import org.openedx.foundation.extension.serializable +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue +import java.util.Date +import org.openedx.core.R as CoreR class CourseSectionFragment : Fragment() { @@ -95,19 +124,6 @@ class CourseSectionFragment : Fragment() { ) } }, - onDownloadClick = { - if (viewModel.isBlockDownloading(it.id) || viewModel.isBlockDownloaded(it.id)) { - viewModel.removeDownloadModels(it.id) - } else { - viewModel.saveDownloadModels( - requireContext().externalCacheDir.toString() + - File.separator + - requireContext() - .getString(org.openedx.core.R.string.app_name) - .replace(Regex("\\s"), "_"), it.id - ) - } - } ) LaunchedEffect(rememberSaveable { true }) { @@ -160,7 +176,6 @@ private fun CourseSectionScreen( uiMessage: UIMessage?, onBackClick: () -> Unit, onItemClick: (Block) -> Unit, - onDownloadClick: (Block) -> Unit ) { val scaffoldState = rememberScaffoldState() val title = when (uiState) { @@ -249,11 +264,9 @@ private fun CourseSectionScreen( items(uiState.blocks) { block -> CourseSubsectionItem( block = block, - downloadedState = uiState.downloadedState[block.id], onClick = { onItemClick(it) }, - onDownloadClick = onDownloadClick ) Divider() } @@ -270,13 +283,11 @@ private fun CourseSectionScreen( @Composable private fun CourseSubsectionItem( block: Block, - downloadedState: DownloadedState?, onClick: (Block) -> Unit, - onDownloadClick: (Block) -> Unit ) { val completedIconPainter = if (block.isCompleted()) painterResource(R.drawable.course_ic_task_alt) else painterResource( - R.drawable.ic_course_chapter_icon + CoreR.drawable.ic_core_chapter_icon ) val completedIconColor = if (block.isCompleted()) MaterialTheme.appColors.primary else MaterialTheme.appColors.onSurface @@ -286,8 +297,6 @@ private fun CourseSubsectionItem( stringResource(id = R.string.course_accessibility_section_uncompleted) } - val iconModifier = Modifier.size(24.dp) - Column(Modifier.clickable { onClick(block) }) { Row( Modifier @@ -320,47 +329,6 @@ private fun CourseSubsectionItem( horizontalArrangement = Arrangement.spacedBy(24.dp), verticalAlignment = Alignment.CenterVertically ) { - if (downloadedState == DownloadedState.DOWNLOADED || downloadedState == DownloadedState.NOT_DOWNLOADED) { - val downloadIconPainter = if (downloadedState == DownloadedState.DOWNLOADED) { - painterResource(id = R.drawable.course_ic_remove_download) - } else { - painterResource(id = R.drawable.course_ic_start_download) - } - val downloadIconDescription = - if (downloadedState == DownloadedState.DOWNLOADED) { - stringResource(id = R.string.course_accessibility_remove_course_section) - } else { - stringResource(id = R.string.course_accessibility_download_course_section) - } - IconButton(modifier = iconModifier, - onClick = { onDownloadClick(block) }) { - Icon( - painter = downloadIconPainter, - contentDescription = downloadIconDescription, - tint = MaterialTheme.appColors.textPrimary - ) - } - } else if (downloadedState != null) { - Box(contentAlignment = Alignment.Center) { - if (downloadedState == DownloadedState.DOWNLOADING || downloadedState == DownloadedState.WAITING) { - CircularProgressIndicator( - modifier = Modifier.size(34.dp), - backgroundColor = Color.LightGray, - strokeWidth = 2.dp, - color = MaterialTheme.appColors.primary - ) - } - IconButton( - modifier = iconModifier.padding(top = 2.dp), - onClick = { onDownloadClick(block) }) { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = stringResource(id = R.string.course_accessibility_stop_downloading_course_section), - tint = MaterialTheme.appColors.error - ) - } - } - } CardArrow( degrees = 0f ) @@ -369,16 +337,6 @@ private fun CourseSubsectionItem( } } -private fun getUnitBlockIcon(block: Block): Int { - return when (block.descendantsType) { - BlockType.VIDEO -> R.drawable.ic_course_video - BlockType.PROBLEM -> R.drawable.ic_course_pen - BlockType.DISCUSSION -> R.drawable.ic_course_discussion - else -> R.drawable.ic_course_block - } -} - - @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable @@ -393,14 +351,12 @@ private fun CourseSectionScreenPreview() { mockBlock, mockBlock ), - mapOf(), "", "Course default" ), uiMessage = null, onBackClick = {}, onItemClick = {}, - onDownloadClick = {} ) } } @@ -419,14 +375,12 @@ private fun CourseSectionScreenTabletPreview() { mockBlock, mockBlock ), - mapOf(), "", "Course default", ), uiMessage = null, onBackClick = {}, onItemClick = {}, - onDownloadClick = {} ) } } @@ -446,5 +400,8 @@ private val mockBlock = Block( descendants = emptyList(), descendantsType = BlockType.HTML, completion = 0.0, - containsGatedContent = false + containsGatedContent = false, + assignmentProgress = AssignmentProgress("", 1f, 2f), + due = Date(), + offlineDownload = null ) diff --git a/course/src/main/java/org/openedx/course/presentation/section/CourseSectionUIState.kt b/course/src/main/java/org/openedx/course/presentation/section/CourseSectionUIState.kt index a8a16681a..1606de1e7 100644 --- a/course/src/main/java/org/openedx/course/presentation/section/CourseSectionUIState.kt +++ b/course/src/main/java/org/openedx/course/presentation/section/CourseSectionUIState.kt @@ -1,12 +1,10 @@ package org.openedx.course.presentation.section import org.openedx.core.domain.model.Block -import org.openedx.core.module.db.DownloadedState sealed class CourseSectionUIState { data class Blocks( val blocks: List, - val downloadedState: Map, val sectionName: String, val courseName: String ) : CourseSectionUIState() diff --git a/course/src/main/java/org/openedx/course/presentation/section/CourseSectionViewModel.kt b/course/src/main/java/org/openedx/course/presentation/section/CourseSectionViewModel.kt index 97f241650..d760620af 100644 --- a/course/src/main/java/org/openedx/course/presentation/section/CourseSectionViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/section/CourseSectionViewModel.kt @@ -7,43 +7,27 @@ import androidx.lifecycle.viewModelScope 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.data.storage.CorePreferences import org.openedx.core.domain.model.Block -import org.openedx.core.extension.isInternetError -import org.openedx.core.module.DownloadWorkerController -import org.openedx.core.module.db.DownloadDao -import org.openedx.core.module.download.BaseDownloadViewModel -import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.presentation.course.CourseViewMode -import org.openedx.core.system.ResourceManager -import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseSectionChanged import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseAnalyticsEvent import org.openedx.course.presentation.CourseAnalyticsKey +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.SingleEventLiveData +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager class CourseSectionViewModel( val courseId: String, private val interactor: CourseInteractor, private val resourceManager: ResourceManager, - private val networkConnection: NetworkConnection, - private val preferencesManager: CorePreferences, private val notifier: CourseNotifier, private val analytics: CourseAnalytics, - coreAnalytics: CoreAnalytics, - workerController: DownloadWorkerController, - downloadDao: DownloadDao, -) : BaseDownloadViewModel( - courseId, - downloadDao, - preferencesManager, - workerController, - coreAnalytics -) { +) : BaseViewModel() { private val _uiState = MutableLiveData(CourseSectionUIState.Loading) val uiState: LiveData @@ -57,24 +41,6 @@ class CourseSectionViewModel( override fun onCreate(owner: LifecycleOwner) { super.onCreate(owner) - viewModelScope.launch { - downloadModelsStatusFlow.collect { downloadModels -> - when (val state = uiState.value) { - is CourseSectionUIState.Blocks -> { - val list = (uiState.value as CourseSectionUIState.Blocks).blocks - _uiState.value = CourseSectionUIState.Blocks( - sectionName = state.sectionName, - courseName = state.courseName, - blocks = ArrayList(list), - downloadedState = downloadModels.toMap() - ) - } - - else -> {} - } - } - } - viewModelScope.launch { notifier.notifier.collect { event -> if (event is CourseSectionChanged) { @@ -89,18 +55,15 @@ class CourseSectionViewModel( viewModelScope.launch { try { val courseStructure = when (mode) { - CourseViewMode.FULL -> interactor.getCourseStructureFromCache() - CourseViewMode.VIDEOS -> interactor.getCourseStructureForVideos() + CourseViewMode.FULL -> interactor.getCourseStructure(courseId) + CourseViewMode.VIDEOS -> interactor.getCourseStructureForVideos(courseId) } val blocks = courseStructure.blockData - setBlocks(blocks) val newList = getDescendantBlocks(blocks, blockId) val sequentialBlock = getSequentialBlock(blocks, blockId) - initDownloadModelsStatus() _uiState.value = CourseSectionUIState.Blocks( blocks = ArrayList(newList), - downloadedState = getDownloadModelsStatus(), courseName = courseStructure.name, sectionName = sequentialBlock.displayName ) @@ -116,19 +79,6 @@ class CourseSectionViewModel( } } - override fun saveDownloadModels(folder: String, id: String) { - if (preferencesManager.videoSettings.wifiDownloadOnly) { - if (networkConnection.isWifiConnected()) { - super.saveDownloadModels(folder, id) - } else { - _uiMessage.value = - UIMessage.ToastMessage(resourceManager.getString(org.openedx.course.R.string.course_can_download_only_with_wifi)) - } - } else { - super.saveDownloadModels(folder, id) - } - } - private fun getDescendantBlocks(blocks: List, id: String): List { val resultList = mutableListOf() if (blocks.isEmpty()) return emptyList() @@ -140,7 +90,6 @@ class CourseSectionViewModel( if (blockDescendant != null) { if (blockDescendant.type == BlockType.VERTICAL) { resultList.add(blockDescendant) - addDownloadableChildrenForVerticalBlock(blockDescendant) } } else continue } diff --git a/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt b/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt index f9f028c0f..6927c0106 100644 --- a/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt +++ b/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt @@ -1,8 +1,10 @@ package org.openedx.course.presentation.ui import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image @@ -15,7 +17,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding @@ -45,14 +46,16 @@ import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Home -import androidx.compose.material.icons.filled.TaskAlt +import androidx.compose.material.icons.filled.CloudDone +import androidx.compose.material.icons.outlined.CloudDownload import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -60,34 +63,27 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex -import coil.compose.AsyncImage -import coil.request.ImageRequest import org.jsoup.Jsoup import org.openedx.core.BlockType +import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts -import org.openedx.core.domain.model.Certificate import org.openedx.core.domain.model.CourseDatesBannerInfo -import org.openedx.core.domain.model.CourseSharingUtmParameters -import org.openedx.core.domain.model.CoursewareAccess -import org.openedx.core.domain.model.EnrolledCourse -import org.openedx.core.domain.model.EnrolledCourseData -import org.openedx.core.extension.isLinkValid -import org.openedx.core.extension.nonZero -import org.openedx.core.extension.toFileSize import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.db.FileType @@ -97,108 +93,27 @@ import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.OpenEdXOutlinedButton import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.noRippleClickable -import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography +import org.openedx.core.utils.TimeUtils import org.openedx.course.R import org.openedx.course.presentation.dates.mockedCourseBannerInfo import org.openedx.course.presentation.outline.getUnitBlockIcon +import org.openedx.foundation.extension.nonZero +import org.openedx.foundation.extension.toFileSize import subtitleFile.Caption import subtitleFile.TimedTextObject import java.util.Date import org.openedx.core.R as coreR -@Composable -fun CourseImageHeader( - modifier: Modifier, - apiHostUrl: String, - courseImage: String?, - courseCertificate: Certificate?, - onCertificateClick: (String) -> Unit = {}, - courseName: String, -) { - val configuration = LocalConfiguration.current - val windowSize = rememberWindowSize() - val contentScale = - if (!windowSize.isTablet && configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) { - ContentScale.Fit - } else { - ContentScale.Crop - } - val imageUrl = if (courseImage?.isLinkValid() == true) { - courseImage - } else { - apiHostUrl.dropLast(1) + courseImage - } - Box(modifier = modifier, contentAlignment = Alignment.Center) { - AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(imageUrl) - .error(coreR.drawable.core_no_image_course) - .placeholder(coreR.drawable.core_no_image_course) - .build(), - contentDescription = stringResource( - id = coreR.string.core_accessibility_header_image_for, - courseName - ), - contentScale = contentScale, - modifier = Modifier - .fillMaxSize() - .clip(MaterialTheme.appShapes.cardShape) - ) - if (courseCertificate?.isCertificateEarned() == true) { - Column( - Modifier - .fillMaxSize() - .clip(MaterialTheme.appShapes.cardShape) - .background(MaterialTheme.appColors.certificateForeground), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Icon( - modifier = Modifier.testTag("ic_congratulations"), - painter = painterResource(id = R.drawable.ic_course_completed_mark), - contentDescription = stringResource(id = R.string.course_congratulations), - tint = Color.White - ) - Spacer(Modifier.height(6.dp)) - Text( - modifier = Modifier.testTag("txt_congratulations"), - text = stringResource(id = R.string.course_congratulations), - style = MaterialTheme.appTypography.headlineMedium, - color = Color.White - ) - Spacer(Modifier.height(4.dp)) - Text( - modifier = Modifier.testTag("txt_course_passed"), - text = stringResource(id = R.string.course_passed), - style = MaterialTheme.appTypography.bodyMedium, - color = Color.White - ) - Spacer(Modifier.height(20.dp)) - OpenEdXOutlinedButton( - modifier = Modifier, - borderColor = Color.White, - textColor = MaterialTheme.appColors.buttonText, - text = stringResource(id = R.string.course_view_certificate), - onClick = { - courseCertificate.certificateURL?.let { - onCertificateClick(it) - } - }) - } - } - } -} - @Composable fun CourseSectionCard( block: Block, downloadedState: DownloadedState?, onItemClick: (Block) -> Unit, - onDownloadClick: (Block) -> Unit + onDownloadClick: (Block) -> Unit, ) { val iconModifier = Modifier.size(24.dp) @@ -216,7 +131,7 @@ fun CourseSectionCard( ) { val completedIconPainter = if (block.isCompleted()) painterResource(R.drawable.course_ic_task_alt) else painterResource( - R.drawable.ic_course_chapter_icon + coreR.drawable.ic_core_chapter_icon ) val completedIconColor = if (block.isCompleted()) MaterialTheme.appColors.primary else MaterialTheme.appColors.onSurface @@ -246,10 +161,10 @@ fun CourseSectionCard( verticalAlignment = Alignment.CenterVertically ) { if (downloadedState == DownloadedState.DOWNLOADED || downloadedState == DownloadedState.NOT_DOWNLOADED) { - val downloadIconPainter = if (downloadedState == DownloadedState.DOWNLOADED) { - painterResource(id = R.drawable.course_ic_remove_download) + val downloadIcon = if (downloadedState == DownloadedState.DOWNLOADED) { + Icons.Default.CloudDone } else { - painterResource(id = R.drawable.course_ic_start_download) + Icons.Outlined.CloudDownload } val downloadIconDescription = if (downloadedState == DownloadedState.DOWNLOADED) { @@ -260,7 +175,7 @@ fun CourseSectionCard( IconButton(modifier = iconModifier, onClick = { onDownloadClick(block) }) { Icon( - painter = downloadIconPainter, + imageVector = downloadIcon, contentDescription = downloadIconDescription, tint = MaterialTheme.appColors.textPrimary ) @@ -299,7 +214,7 @@ fun OfflineQueueCard( downloadModel: DownloadModel, progressValue: Long, progressSize: Long, - onDownloadClick: (DownloadModel) -> Unit + onDownloadClick: (DownloadModel) -> Unit, ) { val iconModifier = Modifier.size(24.dp) @@ -323,15 +238,14 @@ fun OfflineQueueCard( maxLines = 1 ) Text( - text = downloadModel.size.toLong().toFileSize(), + text = downloadModel.size.toFileSize(), style = MaterialTheme.appTypography.titleSmall, color = MaterialTheme.appColors.textSecondary, overflow = TextOverflow.Ellipsis, maxLines = 1 ) - val progress = progressValue.toFloat() / progressSize - + val progress = if (progressSize == 0L) 0f else progressValue.toFloat() / progressSize LinearProgressIndicator( modifier = Modifier .fillMaxWidth() @@ -367,58 +281,16 @@ fun OfflineQueueCard( @Composable fun CardArrow( - degrees: Float + degrees: Float, ) { Icon( imageVector = Icons.Filled.ChevronRight, - tint = MaterialTheme.appColors.primary, + tint = MaterialTheme.appColors.textDark, contentDescription = "Expandable Arrow", modifier = Modifier.rotate(degrees), ) } -@Composable -fun SequentialItem( - block: Block, - onClick: (Block) -> Unit -) { - val icon = if (block.isCompleted()) Icons.Filled.TaskAlt else Icons.Filled.Home - val iconColor = - if (block.isCompleted()) MaterialTheme.appColors.primary else MaterialTheme.appColors.onSurface - Row( - Modifier - .fillMaxWidth() - .padding( - horizontal = 20.dp, - vertical = 12.dp - ) - .clickable { onClick(block) }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Row(Modifier.weight(1f)) { - Icon( - imageVector = icon, - contentDescription = null, - tint = iconColor - ) - Spacer(modifier = Modifier.width(16.dp)) - Text( - block.displayName, - style = MaterialTheme.appTypography.titleMedium, - color = MaterialTheme.appColors.textPrimary, - overflow = TextOverflow.Ellipsis, - maxLines = 1 - ) - } - Icon( - imageVector = Icons.Filled.ChevronRight, - tint = MaterialTheme.appColors.onSurface, - contentDescription = "Expandable Arrow" - ) - } -} - @Composable fun VideoTitle( text: String, @@ -441,7 +313,7 @@ fun NavigationUnitsButtons( hasNextBlock: Boolean, isVerticalNavigation: Boolean, onPrevClick: () -> Unit, - onNextClick: () -> Unit + onNextClick: () -> Unit, ) { val nextButtonIcon = if (hasNextBlock) { painterResource(id = coreR.drawable.core_ic_down) @@ -476,7 +348,7 @@ fun NavigationUnitsButtons( colors = ButtonDefaults.outlinedButtonColors( backgroundColor = MaterialTheme.appColors.background ), - border = BorderStroke(1.dp, MaterialTheme.appColors.primary), + border = BorderStroke(1.dp, MaterialTheme.appColors.primaryButtonBorder), elevation = null, shape = MaterialTheme.appShapes.navigationButtonShape, onClick = onPrevClick, @@ -505,7 +377,7 @@ fun NavigationUnitsButtons( modifier = Modifier .height(42.dp), colors = ButtonDefaults.buttonColors( - backgroundColor = MaterialTheme.appColors.buttonBackground + backgroundColor = MaterialTheme.appColors.primaryButtonBackground ), elevation = null, shape = MaterialTheme.appShapes.navigationButtonShape, @@ -517,7 +389,7 @@ fun NavigationUnitsButtons( ) { Text( text = nextButtonText, - color = MaterialTheme.appColors.buttonText, + color = MaterialTheme.appColors.primaryButtonText, style = MaterialTheme.appTypography.labelLarge ) Spacer(Modifier.width(8.dp)) @@ -525,7 +397,7 @@ fun NavigationUnitsButtons( modifier = Modifier.rotate(if (isVerticalNavigation || !hasNextBlock) 0f else -90f), painter = nextButtonIcon, contentDescription = null, - tint = MaterialTheme.appColors.buttonText + tint = MaterialTheme.appColors.primaryButtonText ) } } @@ -540,7 +412,7 @@ fun HorizontalPageIndicator( completedAndSelectedColor: Color = Color.Green, completedColor: Color = Color.Green, selectedColor: Color = Color.White, - defaultColor: Color = Color.Gray + defaultColor: Color = Color.Gray, ) { Row( horizontalArrangement = Arrangement.spacedBy(1.dp), @@ -605,7 +477,7 @@ fun Indicator( defaultColor: Color, defaultRadius: Dp, selectedSize: Dp, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { val size by animateDpAsState( targetValue = if (isSelected) selectedSize else defaultRadius, @@ -634,7 +506,7 @@ fun VideoSubtitles( showSubtitleLanguage: Boolean, currentIndex: Int, onTranscriptClick: (Caption) -> Unit, - onSettingsClick: () -> Unit + onSettingsClick: () -> Unit, ) { timedTextObject?.let { val autoScrollDelay = 3000L @@ -653,7 +525,11 @@ fun VideoSubtitles( val scaffoldState = rememberScaffoldState() val subtitles = timedTextObject.captions.values.toList() Scaffold(scaffoldState = scaffoldState) { - Column(Modifier.padding(it)) { + Column( + modifier = Modifier + .padding(it) + .background(color = MaterialTheme.appColors.background) + ) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -687,6 +563,11 @@ fun VideoSubtitles( } else { MaterialTheme.appColors.textFieldBorder } + val fontWeight = if (currentIndex == index) { + FontWeight.SemiBold + } else { + FontWeight.Normal + } Text( modifier = Modifier .fillMaxWidth() @@ -695,7 +576,8 @@ fun VideoSubtitles( }, text = Jsoup.parse(item.content).text(), color = textColor, - style = MaterialTheme.appTypography.bodyMedium + style = MaterialTheme.appTypography.bodyMedium, + fontWeight = fontWeight, ) Spacer(Modifier.height(16.dp)) } @@ -706,81 +588,184 @@ fun VideoSubtitles( } @Composable -fun CourseExpandableChapterCard( - modifier: Modifier, +fun CourseSection( + modifier: Modifier = Modifier, block: Block, + useRelativeDates: Boolean, onItemClick: (Block) -> Unit, - arrowDegrees: Float = 0f + courseSectionsState: Boolean?, + courseSubSections: List?, + downloadedStateMap: Map, + onSubSectionClick: (Block) -> Unit, + onDownloadClick: (blocksIds: List) -> Unit, ) { - Column(modifier = Modifier - .clickable { onItemClick(block) } - .background(if (block.isCompleted()) MaterialTheme.appColors.surface else Color.Transparent) - ) { - Row( - modifier - .fillMaxWidth() - .height(60.dp) - .padding( - vertical = 8.dp - ), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - if (block.isCompleted()) { - val completedIconPainter = painterResource(R.drawable.course_ic_task_alt) - val completedIconColor = MaterialTheme.appColors.primary - val completedIconDescription = - stringResource(id = R.string.course_accessibility_section_completed) + val arrowRotation by animateFloatAsState( + targetValue = if (courseSectionsState == true) -90f else 90f, label = "" + ) + val subSectionIds = courseSubSections?.map { it.id }.orEmpty() + val filteredStatuses = downloadedStateMap.filterKeys { it in subSectionIds }.values + val downloadedState = when { + filteredStatuses.isEmpty() -> null + filteredStatuses.all { it.isDownloaded } -> DownloadedState.DOWNLOADED + filteredStatuses.any { it.isWaitingOrDownloading } -> DownloadedState.DOWNLOADING + else -> DownloadedState.NOT_DOWNLOADED + } - Icon( - painter = completedIconPainter, - contentDescription = completedIconDescription, - tint = completedIconColor + Column(modifier = modifier + .clip(MaterialTheme.appShapes.cardShape) + .noRippleClickable { onItemClick(block) } + .background(MaterialTheme.appColors.cardViewBackground) + .border( + 1.dp, + MaterialTheme.appColors.cardViewBorder, + MaterialTheme.appShapes.cardShape + ) + ) { + CourseExpandableChapterCard( + block = block, + arrowDegrees = arrowRotation, + downloadedState = downloadedState, + onDownloadClick = { + onDownloadClick(block.descendants) + } + ) + courseSubSections?.forEach { subSectionBlock -> + AnimatedVisibility( + visible = courseSectionsState == true + ) { + CourseSubSectionItem( + block = subSectionBlock, + onClick = onSubSectionClick, + useRelativeDates = useRelativeDates ) - Spacer(modifier = Modifier.width(16.dp)) } - Text( - modifier = Modifier.weight(1f), - text = block.displayName, - style = MaterialTheme.appTypography.titleSmall, - color = MaterialTheme.appColors.textPrimary, - maxLines = 1, - overflow = TextOverflow.Ellipsis + } + } +} + +@Composable +fun CourseExpandableChapterCard( + modifier: Modifier = Modifier, + block: Block, + arrowDegrees: Float = 0f, + downloadedState: DownloadedState?, + onDownloadClick: () -> Unit, +) { + val iconModifier = Modifier.size(24.dp) + Row( + modifier + .fillMaxWidth() + .height(48.dp) + .padding(vertical = 8.dp) + .padding(start = 16.dp, end = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + CardArrow(degrees = arrowDegrees) + if (block.isCompleted()) { + val completedIconPainter = painterResource(R.drawable.course_ic_task_alt) + val completedIconColor = MaterialTheme.appColors.successGreen + val completedIconDescription = stringResource(id = R.string.course_accessibility_section_completed) + + Icon( + painter = completedIconPainter, + contentDescription = completedIconDescription, + tint = completedIconColor ) - Spacer(modifier = Modifier.width(16.dp)) - CardArrow(degrees = arrowDegrees) + } + Text( + modifier = Modifier.weight(1f), + text = block.displayName, + style = MaterialTheme.appTypography.titleSmall, + color = MaterialTheme.appColors.textPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Row( + modifier = Modifier.fillMaxHeight(), + verticalAlignment = Alignment.CenterVertically + ) { + if (downloadedState == DownloadedState.DOWNLOADED || downloadedState == DownloadedState.NOT_DOWNLOADED) { + val downloadIcon = + if (downloadedState == DownloadedState.DOWNLOADED) { + Icons.Default.CloudDone + } else { + Icons.Outlined.CloudDownload + } + val downloadIconDescription = + if (downloadedState == DownloadedState.DOWNLOADED) { + stringResource(id = R.string.course_accessibility_remove_course_section) + } else { + stringResource(id = R.string.course_accessibility_download_course_section) + } + val downloadIconTint = + if (downloadedState == DownloadedState.DOWNLOADED) { + MaterialTheme.appColors.successGreen + } else { + MaterialTheme.appColors.textAccent + } + IconButton(modifier = iconModifier, + onClick = { onDownloadClick() }) { + Icon( + imageVector = downloadIcon, + contentDescription = downloadIconDescription, + tint = downloadIconTint + ) + } + } else if (downloadedState != null) { + Box(contentAlignment = Alignment.Center) { + if (downloadedState == DownloadedState.DOWNLOADING) { + CircularProgressIndicator( + modifier = Modifier.size(28.dp), + backgroundColor = Color.LightGray, + strokeWidth = 2.dp, + color = MaterialTheme.appColors.primary + ) + } else if (downloadedState == DownloadedState.WAITING) { + Icon( + painter = painterResource(id = R.drawable.course_download_waiting), + contentDescription = stringResource(id = R.string.course_accessibility_stop_downloading_course_section), + tint = MaterialTheme.appColors.error + ) + } + IconButton( + modifier = iconModifier.padding(2.dp), + onClick = { onDownloadClick() }) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(id = R.string.course_accessibility_stop_downloading_course_section), + tint = MaterialTheme.appColors.error + ) + } + } + } } } } @Composable fun CourseSubSectionItem( - modifier: Modifier, + modifier: Modifier = Modifier, block: Block, - downloadedState: DownloadedState?, - downloadsCount: Int, + useRelativeDates: Boolean, onClick: (Block) -> Unit, - onDownloadClick: (Block) -> Unit ) { + val context = LocalContext.current val icon = - if (block.isCompleted()) painterResource(R.drawable.course_ic_task_alt) else painterResource( - R.drawable.ic_course_chapter_icon - ) + if (block.isCompleted()) painterResource(R.drawable.course_ic_task_alt) else painterResource(coreR.drawable.ic_core_chapter_icon) val iconColor = - if (block.isCompleted()) MaterialTheme.appColors.primary else MaterialTheme.appColors.onSurface - - val iconModifier = Modifier.size(24.dp) - - Column(Modifier - .clickable { onClick(block) } - .background(if (block.isCompleted()) MaterialTheme.appColors.surface else Color.Transparent) + if (block.isCompleted()) MaterialTheme.appColors.successGreen else MaterialTheme.appColors.onSurface + val due by rememberSaveable { + mutableStateOf(block.due?.let { TimeUtils.formatToString(context, it, useRelativeDates) } ?: "") + } + val isAssignmentEnable = !block.isCompleted() && block.assignmentProgress != null && due.isNotEmpty() + Column( + modifier = modifier + .fillMaxWidth() + .clickable { onClick(block) } + .padding(horizontal = 16.dp, vertical = 12.dp) ) { Row( - modifier - .fillMaxWidth() - .height(60.dp) - .padding(vertical = 16.dp) - .padding(start = 20.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { @@ -789,7 +774,7 @@ fun CourseSubSectionItem( contentDescription = null, tint = iconColor ) - Spacer(modifier = Modifier.width(16.dp)) + Spacer(modifier = Modifier.width(4.dp)) Text( modifier = Modifier.weight(1f), text = block.displayName, @@ -799,91 +784,29 @@ fun CourseSubSectionItem( maxLines = 1 ) Spacer(modifier = Modifier.width(16.dp)) - Row( - modifier = Modifier.fillMaxHeight(), - horizontalArrangement = Arrangement.spacedBy(if (downloadsCount > 0) 8.dp else 24.dp), - verticalAlignment = Alignment.CenterVertically - ) { - if (downloadedState == DownloadedState.DOWNLOADED || downloadedState == DownloadedState.NOT_DOWNLOADED) { - val downloadIconPainter = if (downloadedState == DownloadedState.DOWNLOADED) { - painterResource(id = R.drawable.course_ic_remove_download) - } else { - painterResource(id = R.drawable.course_ic_start_download) - } - val downloadIconDescription = - if (downloadedState == DownloadedState.DOWNLOADED) { - stringResource(id = R.string.course_accessibility_remove_course_section) - } else { - stringResource(id = R.string.course_accessibility_download_course_section) - } - IconButton(modifier = iconModifier, - onClick = { onDownloadClick(block) }) { - Icon( - painter = downloadIconPainter, - contentDescription = downloadIconDescription, - tint = MaterialTheme.appColors.textPrimary - ) - } - } else if (downloadedState != null) { - Box(contentAlignment = Alignment.Center) { - if (downloadedState == DownloadedState.DOWNLOADING || downloadedState == DownloadedState.WAITING) { - CircularProgressIndicator( - modifier = Modifier.size(28.dp), - backgroundColor = Color.LightGray, - strokeWidth = 2.dp, - color = MaterialTheme.appColors.primary - ) - } - IconButton( - modifier = iconModifier.padding(2.dp), - onClick = { onDownloadClick(block) }) { - Text( - modifier = Modifier - .padding(bottom = 4.dp), - text = "i", - style = MaterialTheme.appTypography.titleMedium, - color = MaterialTheme.appColors.primary - ) - } - } - } - if (downloadsCount > 0) { - Text( - text = downloadsCount.toString(), - style = MaterialTheme.appTypography.titleSmall, - color = MaterialTheme.appColors.textPrimary - ) - } + if (isAssignmentEnable) { + Icon( + imageVector = Icons.Filled.ChevronRight, + tint = MaterialTheme.appColors.onSurface, + contentDescription = null + ) } } - } -} -@Composable -fun CourseToolbar( - title: String, - onBackClick: () -> Unit -) { - OpenEdXTheme { - Box( - modifier = Modifier - .fillMaxWidth() - .displayCutoutForLandscape() - .zIndex(1f) - .statusBarsPadding(), - contentAlignment = Alignment.CenterStart - ) { - BackBtn { onBackClick() } + if (isAssignmentEnable) { + val assignmentString = + stringResource( + R.string.course_subsection_assignment_info, + block.assignmentProgress?.assignmentType ?: "", + stringResource(id = coreR.string.core_date_format_assignment_due, due), + block.assignmentProgress?.numPointsEarned?.toInt() ?: 0, + block.assignmentProgress?.numPointsPossible?.toInt() ?: 0 + ) + Spacer(modifier = Modifier.height(8.dp)) Text( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 56.dp), - text = title, - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.titleMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center + text = assignmentString, + style = MaterialTheme.appTypography.bodySmall, + color = MaterialTheme.appColors.textPrimary ) } } @@ -892,7 +815,7 @@ fun CourseToolbar( @Composable fun CourseUnitToolbar( title: String, - onBackClick: () -> Unit + onBackClick: () -> Unit, ) { OpenEdXTheme { Box( @@ -922,7 +845,7 @@ fun SubSectionUnitsTitle( unitName: String, unitsCount: Int, unitsListShowed: Boolean, - onUnitsClick: () -> Unit + onUnitsClick: () -> Unit, ) { val textStyle = MaterialTheme.appTypography.titleMedium val hasUnits = unitsCount > 0 @@ -968,7 +891,7 @@ fun SubSectionUnitsTitle( fun SubSectionUnitsList( unitBlocks: List, selectedUnitIndex: Int = 0, - onUnitClick: (index: Int, unit: Block) -> Unit + onUnitClick: (index: Int, unit: Block) -> Unit, ) { Card( modifier = Modifier @@ -996,7 +919,7 @@ fun SubSectionUnitsList( modifier = Modifier .size(16.dp) .alpha(if (unit.isCompleted()) 1f else 0f), - painter = painterResource(id = R.drawable.ic_course_check), + painter = painterResource(id = coreR.drawable.ic_core_check), contentDescription = "done" ) Text( @@ -1207,6 +1130,49 @@ fun DatesShiftedSnackBar( } } +@Composable +fun CourseMessage( + modifier: Modifier = Modifier, + icon: Painter, + message: String, + action: String? = null, + onActionClick: () -> Unit = {}, +) { + Column { + Row( + modifier + .semantics(mergeDescendants = true) {} + .noRippleClickable(onActionClick) + ) { + Icon( + painter = icon, + contentDescription = null, + modifier = Modifier.align(Alignment.CenterVertically), + tint = MaterialTheme.appColors.textPrimary + ) + Column(Modifier.padding(start = 12.dp)) { + Text( + text = message, + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.labelLarge + ) + if (action != null) { + Text( + text = action, + modifier = Modifier.padding(top = 4.dp), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.labelLarge.copy(textDecoration = TextDecoration.Underline) + ) + } + } + } + Divider( + color = MaterialTheme.appColors.divider + ) + } + +} + @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable @@ -1266,18 +1232,7 @@ private fun NavigationUnitsButtonsWithNextPreview() { @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -private fun SequentialItemPreview() { - OpenEdXTheme { - Surface(color = MaterialTheme.appColors.background) { - SequentialItem(block = mockChapterBlock, onClick = {}) - } - } -} - -@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun CourseChapterItemPreview() { +private fun CourseSectionCardPreview() { OpenEdXTheme { Surface(color = MaterialTheme.appColors.background) { CourseSectionCard( @@ -1290,26 +1245,6 @@ private fun CourseChapterItemPreview() { } } -@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun CourseHeaderPreview() { - OpenEdXTheme { - Surface(color = MaterialTheme.appColors.background) { - CourseImageHeader( - modifier = Modifier - .fillMaxWidth() - .height(200.dp) - .padding(6.dp), - apiHostUrl = "", - courseCertificate = Certificate(""), - courseImage = "", - courseName = "" - ) - } - } -} - @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable @@ -1344,6 +1279,7 @@ private fun OfflineQueueCardPreview() { Surface(color = MaterialTheme.appColors.background) { OfflineQueueCard( downloadModel = DownloadModel( + courseId = "", id = "", title = "Problems of society", size = 4000, @@ -1351,7 +1287,6 @@ private fun OfflineQueueCardPreview() { url = "", type = FileType.VIDEO, downloadedState = DownloadedState.DOWNLOADING, - progress = 0f ), progressValue = 10, progressSize = 30, @@ -1361,42 +1296,27 @@ private fun OfflineQueueCardPreview() { } } -private val mockCourse = EnrolledCourse( - auditAccessExpires = Date(), - created = "created", - certificate = Certificate(""), - mode = "mode", - isActive = true, - course = EnrolledCourseData( - id = "id", - name = "Course name", - number = "", - org = "Org", - start = Date(), - startDisplay = "", - startType = "", - end = Date(), - dynamicUpgradeDeadline = "", - subscriptionId = "", - coursewareAccess = CoursewareAccess( - true, - "", - "", - "", - "", - "" - ), - media = null, - courseImage = "", - courseAbout = "", - courseSharingUtmParameters = CourseSharingUtmParameters("", ""), - courseUpdates = "", - courseHandouts = "", - discussionUrl = "", - videoOutline = "", - isSelfPaced = false - ) -) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun CourseMessagePreview() { + OpenEdXTheme { + Surface(color = MaterialTheme.appColors.background) { + CourseMessage( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 12.dp), + icon = painterResource(R.drawable.ic_course_certificate), + message = stringResource( + R.string.course_you_earned_certificate, + "Demo Course" + ), + action = stringResource(R.string.course_view_certificate), + ) + } + } +} + private val mockChapterBlock = Block( id = "id", blockId = "blockId", @@ -1412,5 +1332,8 @@ private val mockChapterBlock = Block( descendants = emptyList(), descendantsType = BlockType.CHAPTER, completion = 0.0, - containsGatedContent = false + containsGatedContent = false, + assignmentProgress = AssignmentProgress("", 1f, 2f), + due = Date(), + offlineDownload = null ) diff --git a/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt b/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt index c69e26c0d..72e37ee5b 100644 --- a/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt +++ b/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt @@ -1,7 +1,6 @@ package org.openedx.course.presentation.ui import android.content.res.Configuration -import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween @@ -19,9 +18,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.material.AlertDialog import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Divider @@ -50,126 +46,113 @@ 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.koin.compose.koinInject import org.openedx.core.AppDataConstants import org.openedx.core.BlockType -import org.openedx.core.UIMessage +import org.openedx.core.NoContentScreenType +import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts import org.openedx.core.domain.model.CourseStructure import org.openedx.core.domain.model.CoursewareAccess +import org.openedx.core.domain.model.Progress import org.openedx.core.domain.model.VideoSettings -import org.openedx.core.extension.toFileSize import org.openedx.core.module.download.DownloadModelsSize import org.openedx.core.presentation.course.CourseViewMode -import org.openedx.core.presentation.settings.VideoQualityType +import org.openedx.core.presentation.settings.video.VideoQualityType +import org.openedx.core.ui.CircularProgress import org.openedx.core.ui.HandleUIMessage -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType +import org.openedx.core.ui.NoContentScreen import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography -import org.openedx.core.ui.windowSizeValue import org.openedx.course.R -import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.videos.CourseVideoViewModel import org.openedx.course.presentation.videos.CourseVideosUIState -import java.io.File +import org.openedx.foundation.extension.toFileSize +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.windowSizeValue +import org.openedx.foundation.utils.FileUtil import java.util.Date @Composable fun CourseVideosScreen( windowSize: WindowSize, - courseVideoViewModel: CourseVideoViewModel, - fragmentManager: FragmentManager, - courseRouter: CourseRouter + viewModel: CourseVideoViewModel, + fragmentManager: FragmentManager ) { - val uiState by courseVideoViewModel.uiState.collectAsState(CourseVideosUIState.Loading) - val uiMessage by courseVideoViewModel.uiMessage.collectAsState(null) - val videoSettings by courseVideoViewModel.videoSettings.collectAsState() + val uiState by viewModel.uiState.collectAsState(CourseVideosUIState.Loading) + val uiMessage by viewModel.uiMessage.collectAsState(null) + val videoSettings by viewModel.videoSettings.collectAsState() val context = LocalContext.current + val fileUtil: FileUtil = koinInject() CourseVideosUI( windowSize = windowSize, uiState = uiState, uiMessage = uiMessage, - courseTitle = courseVideoViewModel.courseTitle, - isCourseNestedListEnabled = courseVideoViewModel.isCourseNestedListEnabled, + courseTitle = viewModel.courseTitle, videoSettings = videoSettings, - onItemClick = { block -> - courseRouter.navigateToCourseSubsections( - fm = fragmentManager, - courseId = courseVideoViewModel.courseId, - subSectionId = block.id, - mode = CourseViewMode.VIDEOS - ) - }, onExpandClick = { block -> - courseVideoViewModel.switchCourseSections(block.id) + viewModel.switchCourseSections(block.id) }, onSubSectionClick = { subSectionBlock -> - courseVideoViewModel.courseSubSectionUnit[subSectionBlock.id]?.let { unit -> - courseVideoViewModel.sequentialClickedEvent( - unit.blockId, - unit.displayName + if (viewModel.isCourseDropdownNavigationEnabled) { + viewModel.courseSubSectionUnit[subSectionBlock.id]?.let { unit -> + viewModel.courseRouter.navigateToCourseContainer( + fragmentManager, + courseId = viewModel.courseId, + unitId = unit.id, + mode = CourseViewMode.VIDEOS + ) + } + } else { + viewModel.sequentialClickedEvent( + subSectionBlock.blockId, + subSectionBlock.displayName ) - courseRouter.navigateToCourseContainer( + viewModel.courseRouter.navigateToCourseSubsections( fm = fragmentManager, - courseId = courseVideoViewModel.courseId, - unitId = unit.id, + courseId = viewModel.courseId, + subSectionId = subSectionBlock.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 - ) - } + onDownloadClick = { blocksIds -> + viewModel.downloadBlocks( + blocksIds = blocksIds, + fragmentManager = fragmentManager, + ) }, onDownloadAllClick = { isAllBlocksDownloadedOrDownloading -> - courseVideoViewModel.logBulkDownloadToggleEvent(!isAllBlocksDownloadedOrDownloading) + viewModel.logBulkDownloadToggleEvent(!isAllBlocksDownloadedOrDownloading) if (isAllBlocksDownloadedOrDownloading) { - courseVideoViewModel.removeAllDownloadModels() + viewModel.removeAllDownloadModels() } else { - courseVideoViewModel.saveAllDownloadModels( - context.externalCacheDir.toString() + - File.separator + - context - .getString(org.openedx.core.R.string.app_name) - .replace(Regex("\\s"), "_") + viewModel.saveAllDownloadModels( + fileUtil.getExternalAppDir().path ) } }, onDownloadQueueClick = { - if (courseVideoViewModel.hasDownloadModelsInQueue()) { - courseRouter.navigateToDownloadQueue(fm = fragmentManager) + if (viewModel.hasDownloadModelsInQueue()) { + viewModel.courseRouter.navigateToDownloadQueue(fm = fragmentManager) } }, onVideoDownloadQualityClick = { - if (courseVideoViewModel.hasDownloadModelsInQueue()) { - courseVideoViewModel.onChangingVideoQualityWhileDownloading() + if (viewModel.hasDownloadModelsInQueue()) { + viewModel.onChangingVideoQualityWhileDownloading() } else { - courseRouter.navigateToVideoQuality( + viewModel.courseRouter.navigateToVideoQuality( fragmentManager, VideoQualityType.Download ) @@ -184,12 +167,10 @@ private fun CourseVideosUI( uiState: CourseVideosUIState, uiMessage: UIMessage?, courseTitle: String, - isCourseNestedListEnabled: Boolean, videoSettings: VideoSettings, - onItemClick: (Block) -> Unit, onExpandClick: (Block) -> Unit, onSubSectionClick: (Block) -> Unit, - onDownloadClick: (Block) -> Unit, + onDownloadClick: (blocksIds: List) -> Unit, onDownloadAllClick: (Boolean) -> Unit, onDownloadQueueClick: () -> Unit, onVideoDownloadQualityClick: () -> Unit @@ -262,20 +243,7 @@ private fun CourseVideosUI( ) { when (uiState) { is CourseVideosUIState.Empty -> { - Box( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()), - contentAlignment = Alignment.Center - ) { - Text( - text = stringResource(id = R.string.course_does_not_include_videos), - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.headlineSmall, - textAlign = TextAlign.Center, - modifier = Modifier.padding(horizontal = 40.dp) - ) - } + NoContentScreen(noContentScreenType = NoContentScreenType.COURSE_VIDEOS) } is CourseVideosUIState.CourseData -> { @@ -304,94 +272,35 @@ private fun CourseVideosUI( } } - if (isCourseNestedListEnabled) { - uiState.courseStructure.blockData.forEach { section -> - val courseSubSections = uiState.courseSubSections[section.id] - val courseSectionsState = uiState.courseSectionsState[section.id] - - item { - Column { - CourseExpandableChapterCard( - modifier = listPadding, - block = section, - onItemClick = onExpandClick, - arrowDegrees = if (courseSectionsState == true) -90f else 90f - ) - Divider() - } - } - - courseSubSections?.forEach { subSectionBlock -> - item { - Column { - AnimatedVisibility( - visible = courseSectionsState == true - ) { - Column { - val downloadsCount = - uiState.subSectionsDownloadsCount[subSectionBlock.id] - ?: 0 - - CourseSubSectionItem( - modifier = listPadding, - block = subSectionBlock, - downloadedState = uiState.downloadedState[subSectionBlock.id], - downloadsCount = downloadsCount, - onClick = onSubSectionClick, - onDownloadClick = { block -> - if (uiState.downloadedState[block.id]?.isDownloaded == true) { - deleteDownloadBlock = - block - - } else { - onDownloadClick(block) - } - } - ) - Divider() - } - } - } - } - } - } - return@LazyColumn + item { + Spacer(modifier = Modifier.height(12.dp)) } + uiState.courseStructure.blockData.forEach { section -> + val courseSubSections = + uiState.courseSubSections[section.id] + val courseSectionsState = + uiState.courseSectionsState[section.id] - items(uiState.courseStructure.blockData) { block -> - Column(listPadding) { - if (block.type == BlockType.CHAPTER) { - Text( - modifier = Modifier.padding( - top = 36.dp, - bottom = 8.dp - ), - text = block.displayName, - style = MaterialTheme.appTypography.titleMedium, - color = MaterialTheme.appColors.textPrimaryVariant - ) - } else { - CourseSectionCard( - block = block, - downloadedState = uiState.downloadedState[block.id], - onItemClick = onItemClick, - onDownloadClick = { block -> - if (uiState.downloadedState[block.id]?.isDownloaded == true) { - deleteDownloadBlock = block - - } else { - onDownloadClick(block) - } - } - ) - Divider() - } + item { + CourseSection( + modifier = listPadding.padding(vertical = 4.dp), + block = section, + onItemClick = onExpandClick, + courseSectionsState = courseSectionsState, + courseSubSections = courseSubSections, + downloadedStateMap = uiState.downloadedState, + useRelativeDates = uiState.useRelativeDates, + onSubSectionClick = onSubSectionClick, + onDownloadClick = onDownloadClick + ) } } } } - CourseVideosUIState.Loading -> {} + CourseVideosUIState.Loading -> { + CircularProgress() + } } } } @@ -507,7 +416,7 @@ private fun CourseVideosUI( TextButton( onClick = { deleteDownloadBlock?.let { block -> - onDownloadClick(block) + onDownloadClick(listOf(block.id)) } deleteDownloadBlock = null } @@ -630,6 +539,7 @@ private fun AllVideosDownloadItem( } }, colors = SwitchDefaults.colors( + uncheckedThumbColor = MaterialTheme.appColors.primary, checkedThumbColor = MaterialTheme.appColors.primary, checkedTrackColor = MaterialTheme.appColors.primary ) @@ -714,11 +624,10 @@ private fun CourseVideosScreenPreview() { remainingSize = 0, allCount = 1, allSize = 0 - ) + ), + useRelativeDates = true ), courseTitle = "", - isCourseNestedListEnabled = false, - onItemClick = { }, onExpandClick = { }, onSubSectionClick = { }, videoSettings = VideoSettings.default, @@ -738,12 +647,8 @@ private fun CourseVideosScreenEmptyPreview() { CourseVideosUI( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), uiMessage = null, - uiState = CourseVideosUIState.Empty( - "This course does not include any videos." - ), + uiState = CourseVideosUIState.Empty, courseTitle = "", - isCourseNestedListEnabled = false, - onItemClick = { }, onExpandClick = { }, onSubSectionClick = { }, videoSettings = VideoSettings.default, @@ -775,11 +680,9 @@ private fun CourseVideosScreenTabletPreview() { remainingSize = 0, allCount = 0, allSize = 0 - ) + ), useRelativeDates = true ), courseTitle = "", - isCourseNestedListEnabled = false, - onItemClick = { }, onExpandClick = { }, onSubSectionClick = { }, videoSettings = VideoSettings.default, @@ -791,6 +694,11 @@ private fun CourseVideosScreenTabletPreview() { } } +private val mockAssignmentProgress = AssignmentProgress( + assignmentType = "Home", + numPointsEarned = 1f, + numPointsPossible = 3f +) private val mockChapterBlock = Block( id = "id", @@ -807,7 +715,10 @@ private val mockChapterBlock = Block( descendants = emptyList(), descendantsType = BlockType.CHAPTER, completion = 0.0, - containsGatedContent = false + containsGatedContent = false, + assignmentProgress = mockAssignmentProgress, + due = Date(), + offlineDownload = null ) private val mockSequentialBlock = Block( @@ -825,7 +736,10 @@ private val mockSequentialBlock = Block( descendants = emptyList(), descendantsType = BlockType.SEQUENTIAL, completion = 0.0, - containsGatedContent = false + containsGatedContent = false, + assignmentProgress = mockAssignmentProgress, + due = Date(), + offlineDownload = null ) private val mockCourseStructure = CourseStructure( @@ -849,5 +763,6 @@ private val mockCourseStructure = CourseStructure( ), media = null, certificate = null, - isSelfPaced = false + isSelfPaced = false, + progress = Progress(1, 3), ) diff --git a/course/src/main/java/org/openedx/course/presentation/unit/NotSupportedUnitFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/NotAvailableUnitFragment.kt similarity index 50% rename from course/src/main/java/org/openedx/course/presentation/unit/NotSupportedUnitFragment.kt rename to course/src/main/java/org/openedx/course/presentation/unit/NotAvailableUnitFragment.kt index bdf5dcd8b..b983822b2 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/NotSupportedUnitFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/NotAvailableUnitFragment.kt @@ -3,10 +3,25 @@ package org.openedx.course.presentation.unit import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.* +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -22,16 +37,17 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.core.os.bundleOf import androidx.fragment.app.Fragment -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography -import org.openedx.core.ui.windowSizeValue +import org.openedx.foundation.extension.parcelable +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue import org.openedx.course.R as courseR -class NotSupportedUnitFragment : Fragment() { +class NotAvailableUnitFragment : Fragment() { private var blockId: String? = null @@ -49,9 +65,40 @@ class NotSupportedUnitFragment : Fragment() { setContent { OpenEdXTheme { val windowSize = rememberWindowSize() - NotSupportedUnitScreen( + val uriHandler = LocalUriHandler.current + val uri = requireArguments().getString(ARG_BLOCK_URL, "") + val title: String + val description: String + var buttonAction: (() -> Unit)? = null + when (requireArguments().parcelable(ARG_UNIT_TYPE)) { + NotAvailableUnitType.MOBILE_UNSUPPORTED -> { + title = stringResource(id = courseR.string.course_this_interactive_component) + description = stringResource(id = courseR.string.course_explore_other_parts_on_web) + buttonAction = { + uriHandler.openUri(uri) + } + } + + NotAvailableUnitType.OFFLINE_UNSUPPORTED -> { + title = stringResource(id = courseR.string.course_not_available_offline) + description = stringResource(id = courseR.string.course_explore_other_parts_when_reconnect) + } + + NotAvailableUnitType.NOT_DOWNLOADED -> { + title = stringResource(id = courseR.string.course_not_downloaded) + description = + stringResource(id = courseR.string.course_explore_other_parts_when_reconnect_or_download) + } + + else -> { + return@OpenEdXTheme + } + } + NotAvailableUnitScreen( windowSize = windowSize, - uri = requireArguments().getString(ARG_BLOCK_URL, "") + title = title, + description = description, + buttonAction = buttonAction ) } } @@ -60,14 +107,17 @@ class NotSupportedUnitFragment : Fragment() { companion object { private const val ARG_BLOCK_ID = "blockId" private const val ARG_BLOCK_URL = "blockUrl" + private const val ARG_UNIT_TYPE = "notAvailableUnitType" fun newInstance( blockId: String, - blockUrl: String - ): NotSupportedUnitFragment { - val fragment = NotSupportedUnitFragment() + blockUrl: String, + unitType: NotAvailableUnitType, + ): NotAvailableUnitFragment { + val fragment = NotAvailableUnitFragment() fragment.arguments = bundleOf( ARG_BLOCK_ID to blockId, - ARG_BLOCK_URL to blockUrl + ARG_BLOCK_URL to blockUrl, + ARG_UNIT_TYPE to unitType ) return fragment } @@ -76,12 +126,13 @@ class NotSupportedUnitFragment : Fragment() { } @Composable -private fun NotSupportedUnitScreen( +private fun NotAvailableUnitScreen( windowSize: WindowSize, - uri: String + title: String, + description: String, + buttonAction: (() -> Unit)? = null, ) { val scaffoldState = rememberScaffoldState() - val uriHandler = LocalUriHandler.current val scrollState = rememberScrollState() Scaffold( modifier = Modifier.fillMaxSize(), @@ -120,7 +171,7 @@ private fun NotSupportedUnitScreen( Spacer(Modifier.height(36.dp)) Text( modifier = Modifier.fillMaxWidth(), - text = stringResource(id = courseR.string.course_this_interactive_component), + text = title, style = MaterialTheme.appTypography.titleLarge, color = MaterialTheme.appColors.textPrimary, textAlign = TextAlign.Center @@ -128,29 +179,31 @@ private fun NotSupportedUnitScreen( Spacer(Modifier.height(12.dp)) Text( modifier = Modifier.fillMaxWidth(), - text = stringResource(id = courseR.string.course_explore_other_parts), + text = description, style = MaterialTheme.appTypography.bodyLarge, color = MaterialTheme.appColors.textPrimary, textAlign = TextAlign.Center ) Spacer(Modifier.height(40.dp)) - Button(modifier = Modifier - .width(216.dp) - .height(42.dp), - shape = MaterialTheme.appShapes.buttonShape, - colors = ButtonDefaults.buttonColors( - backgroundColor = MaterialTheme.appColors.buttonBackground - ), - onClick = { - uriHandler.openUri(uri) - }) { - Text( - text = stringResource(id = courseR.string.course_open_in_browser), - color = MaterialTheme.appColors.buttonText, - style = MaterialTheme.appTypography.labelLarge - ) + if (buttonAction != null) { + Button( + modifier = Modifier + .width(216.dp) + .height(42.dp), + shape = MaterialTheme.appShapes.buttonShape, + colors = ButtonDefaults.buttonColors( + backgroundColor = MaterialTheme.appColors.primaryButtonBackground + ), + onClick = buttonAction + ) { + Text( + text = stringResource(id = courseR.string.course_open_in_browser), + color = MaterialTheme.appColors.primaryButtonText, + style = MaterialTheme.appTypography.labelLarge + ) + } + Spacer(Modifier.height(20.dp)) } - Spacer(Modifier.height(20.dp)) } } } diff --git a/course/src/main/java/org/openedx/course/presentation/unit/NotAvailableUnitType.kt b/course/src/main/java/org/openedx/course/presentation/unit/NotAvailableUnitType.kt new file mode 100644 index 000000000..0b02b876e --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/unit/NotAvailableUnitType.kt @@ -0,0 +1,9 @@ +package org.openedx.course.presentation.unit + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +enum class NotAvailableUnitType : Parcelable { + MOBILE_UNSUPPORTED, OFFLINE_UNSUPPORTED, NOT_DOWNLOADED +} diff --git a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt index 6d37954ee..0610983e8 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt @@ -4,12 +4,14 @@ import androidx.fragment.app.Fragment import androidx.viewpager2.adapter.FragmentStateAdapter import org.openedx.core.FragmentViewType import org.openedx.core.domain.model.Block -import org.openedx.course.presentation.unit.NotSupportedUnitFragment +import org.openedx.course.presentation.unit.NotAvailableUnitFragment +import org.openedx.course.presentation.unit.NotAvailableUnitType import org.openedx.course.presentation.unit.html.HtmlUnitFragment import org.openedx.course.presentation.unit.video.VideoUnitFragment import org.openedx.course.presentation.unit.video.YoutubeVideoUnitFragment import org.openedx.discussion.presentation.threads.DiscussionThreadsFragment import org.openedx.discussion.presentation.topics.DiscussionTopicsViewModel +import java.io.File class CourseUnitContainerAdapter( fragment: Fragment, @@ -22,73 +24,88 @@ class CourseUnitContainerAdapter( override fun createFragment(position: Int): Fragment = unitBlockFragment(blocks[position]) private fun unitBlockFragment(block: Block): Fragment { + val downloadedModel = viewModel.getDownloadModelById(block.id) + val offlineUrl = downloadedModel?.let { it.path + File.separator + "index.html" } ?: "" + val noNetwork = !viewModel.hasNetworkConnection + return when { - (block.isVideoBlock && - (block.studentViewData?.encodedVideos?.hasVideoUrl == true || - block.studentViewData?.encodedVideos?.hasYoutubeUrl == true)) -> { - val encodedVideos = block.studentViewData?.encodedVideos!! - val transcripts = block.studentViewData!!.transcripts - with(encodedVideos) { - var isDownloaded = false - val videoUrl = if (viewModel.getDownloadModelById(block.id) != null) { - isDownloaded = true - viewModel.getDownloadModelById(block.id)!!.path - } else videoUrl - if (videoUrl.isNotEmpty()) { - VideoUnitFragment.newInstance( - block.id, - viewModel.courseId, - videoUrl, - transcripts?.toMap() ?: emptyMap(), - block.displayName, - isDownloaded - ) - } else { - YoutubeVideoUnitFragment.newInstance( - block.id, - viewModel.courseId, - encodedVideos.youtube?.url ?: "", - transcripts?.toMap() ?: emptyMap(), - block.displayName - ) - } - } + noNetwork && block.isDownloadable && offlineUrl.isEmpty() -> { + createNotAvailableUnitFragment(block, NotAvailableUnitType.NOT_DOWNLOADED) } - (block.isDiscussionBlock && block.studentViewData?.topicId.isNullOrEmpty().not()) -> { - DiscussionThreadsFragment.newInstance( - DiscussionTopicsViewModel.TOPIC, - viewModel.courseId, - block.studentViewData?.topicId ?: "", - block.displayName, - FragmentViewType.MAIN_CONTENT.name, - block.id - ) + noNetwork && !block.isDownloadable -> { + createNotAvailableUnitFragment(block, NotAvailableUnitType.OFFLINE_UNSUPPORTED) } - block.studentViewMultiDevice.not() -> { - NotSupportedUnitFragment.newInstance( - block.id, - block.lmsWebUrl - ) + block.isVideoBlock && block.studentViewData?.encodedVideos?.run { hasVideoUrl || hasYoutubeUrl } == true -> { + createVideoFragment(block) } - block.isHTMLBlock || - block.isProblemBlock || - block.isOpenAssessmentBlock || - block.isDragAndDropBlock || - block.isWordCloudBlock || - block.isLTIConsumerBlock || - block.isSurveyBlock -> { - HtmlUnitFragment.newInstance(block.id, block.studentViewUrl) + block.isDiscussionBlock && !block.studentViewData?.topicId.isNullOrEmpty() -> { + createDiscussionFragment(block) } - else -> { - NotSupportedUnitFragment.newInstance( + block.isHTMLBlock || block.isProblemBlock || block.isOpenAssessmentBlock || block.isDragAndDropBlock || + block.isWordCloudBlock || block.isLTIConsumerBlock || block.isSurveyBlock -> { + val lastModified = if (downloadedModel != null && noNetwork) { + downloadedModel.lastModified ?: "" + } else { + "" + } + HtmlUnitFragment.newInstance( block.id, - block.lmsWebUrl + block.studentViewUrl, + viewModel.courseId, + offlineUrl, + lastModified ) } + + else -> { + createNotAvailableUnitFragment(block, NotAvailableUnitType.MOBILE_UNSUPPORTED) + } + } + } + + private fun createNotAvailableUnitFragment(block: Block, type: NotAvailableUnitType): Fragment { + return NotAvailableUnitFragment.newInstance(block.id, block.lmsWebUrl, type) + } + + private fun createVideoFragment(block: Block): Fragment { + val encodedVideos = block.studentViewData!!.encodedVideos!! + val transcripts = block.studentViewData!!.transcripts ?: emptyMap() + val downloadedModel = viewModel.getDownloadModelById(block.id) + val isDownloaded = downloadedModel != null + val videoUrl = downloadedModel?.path ?: encodedVideos.videoUrl + + return if (videoUrl.isNotEmpty()) { + VideoUnitFragment.newInstance( + block.id, + viewModel.courseId, + videoUrl, + transcripts, + block.displayName, + isDownloaded + ) + } else { + YoutubeVideoUnitFragment.newInstance( + block.id, + viewModel.courseId, + encodedVideos.youtube?.url ?: "", + transcripts, + block.displayName + ) } } + + private fun createDiscussionFragment(block: Block): Fragment { + return DiscussionThreadsFragment.newInstance( + DiscussionTopicsViewModel.TOPIC, + viewModel.courseId, + block.studentViewData?.topicId ?: "", + block.displayName, + FragmentViewType.MAIN_CONTENT.name, + block.id + ) + } } diff --git a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt index fc7c9213f..d8870914a 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt @@ -31,7 +31,6 @@ import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.core.BlockType -import org.openedx.core.extension.serializable import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.presentation.global.InsetHolder import org.openedx.core.ui.theme.OpenEdXTheme @@ -47,6 +46,7 @@ import org.openedx.course.presentation.ui.NavigationUnitsButtons import org.openedx.course.presentation.ui.SubSectionUnitsList import org.openedx.course.presentation.ui.SubSectionUnitsTitle import org.openedx.course.presentation.ui.VerticalPageIndicator +import org.openedx.foundation.extension.serializable class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_container) { @@ -134,8 +134,7 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta super.onCreate(savedInstanceState) lifecycle.addObserver(viewModel) componentId = requireArguments().getString(ARG_COMPONENT_ID, "") - viewModel.loadBlocks(requireArguments().serializable(ARG_MODE)!!) - viewModel.setupCurrentIndex(componentId) + viewModel.loadBlocks(requireArguments().serializable(ARG_MODE)!!, componentId) viewModel.courseUnitContainerShowedEvent() } diff --git a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt index 323adb7cb..5a4cb0393 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt @@ -9,15 +9,13 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import org.openedx.core.BaseViewModel import org.openedx.core.BlockType import org.openedx.core.config.Config import org.openedx.core.domain.model.Block -import org.openedx.core.extension.clearAndAddAll -import org.openedx.core.extension.indexOfFirstFromIndex import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadedState import org.openedx.core.presentation.course.CourseViewMode +import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseSectionChanged import org.openedx.core.system.notifier.CourseStructureUpdated @@ -25,6 +23,9 @@ import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseAnalyticsEvent import org.openedx.course.presentation.CourseAnalyticsKey +import org.openedx.foundation.extension.clearAndAddAll +import org.openedx.foundation.extension.indexOfFirstFromIndex +import org.openedx.foundation.presentation.BaseViewModel class CourseUnitContainerViewModel( val courseId: String, @@ -33,13 +34,14 @@ class CourseUnitContainerViewModel( private val interactor: CourseInteractor, private val notifier: CourseNotifier, private val analytics: CourseAnalytics, + private val networkConnection: NetworkConnection, ) : BaseViewModel() { private val blocks = ArrayList() - val isCourseExpandableSectionsEnabled get() = config.isCourseNestedListEnabled() + val isCourseExpandableSectionsEnabled get() = config.getCourseUIConfig().isCourseDropdownNavigationEnabled - val isCourseUnitProgressEnabled get() = config.isCourseUnitProgressEnabled() + val isCourseUnitProgressEnabled get() = config.getCourseUIConfig().isCourseUnitProgressEnabled private var currentIndex = 0 private var currentVerticalIndex = 0 @@ -76,23 +78,31 @@ class CourseUnitContainerViewModel( var hasNextBlock = false private var currentMode: CourseViewMode? = null + private var currentComponentId = "" private var courseName = "" private val _descendantsBlocks = MutableStateFlow>(listOf()) val descendantsBlocks = _descendantsBlocks.asStateFlow() - fun loadBlocks(mode: CourseViewMode) { + val hasNetworkConnection: Boolean + get() = networkConnection.isOnline() + + fun loadBlocks(mode: CourseViewMode, componentId: String = "") { currentMode = mode - try { - val courseStructure = when (mode) { - CourseViewMode.FULL -> interactor.getCourseStructureFromCache() - CourseViewMode.VIDEOS -> interactor.getCourseStructureForVideos() + viewModelScope.launch { + try { + val courseStructure = when (mode) { + CourseViewMode.FULL -> interactor.getCourseStructure(courseId) + CourseViewMode.VIDEOS -> interactor.getCourseStructureForVideos(courseId) + } + val blocks = courseStructure.blockData + courseName = courseStructure.name + this@CourseUnitContainerViewModel.blocks.clearAndAddAll(blocks) + + setupCurrentIndex(componentId) + } catch (e: Exception) { + e.printStackTrace() } - val blocks = courseStructure.blockData - courseName = courseStructure.name - this.blocks.clearAndAddAll(blocks) - } catch (e: Exception) { - //ignore e.printStackTrace() } } @@ -104,7 +114,7 @@ class CourseUnitContainerViewModel( if (event is CourseStructureUpdated) { if (event.courseId != courseId) return@collect - currentMode?.let { loadBlocks(it) } + currentMode?.let { loadBlocks(it, currentComponentId) } val blockId = blocks[currentVerticalIndex].id _subSectionUnitBlocks.value = getSubSectionUnitBlocks(blocks, getSubSectionId(blockId)) @@ -113,10 +123,10 @@ class CourseUnitContainerViewModel( } } - fun setupCurrentIndex(componentId: String = "") { - if (currentSectionIndex != -1) { - return - } + private fun setupCurrentIndex(componentId: String = "") { + if (currentSectionIndex != -1) return + currentComponentId = componentId + blocks.forEachIndexed { index, block -> if (block.id == unitId) { currentVerticalIndex = index diff --git a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt index a74b9d5ee..7bf313ac8 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt @@ -9,13 +9,32 @@ import android.os.Bundle import android.util.Log import android.view.LayoutInflater import android.view.ViewGroup -import android.webkit.* +import android.webkit.JavascriptInterface +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.* -import androidx.compose.runtime.* +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -32,23 +51,40 @@ import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import kotlinx.coroutines.launch import org.koin.androidx.viewmodel.ext.android.viewModel -import org.openedx.core.extension.isEmailValid +import org.koin.core.parameter.parametersOf +import org.openedx.core.extension.equalsHost +import org.openedx.core.extension.loadUrl import org.openedx.core.system.AppCookieManager -import org.openedx.core.ui.* +import org.openedx.core.ui.FullScreenErrorView +import org.openedx.core.ui.roundBorderWithoutBottom import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.utils.EmailUtil +import org.openedx.foundation.extension.applyDarkModeIfEnabled +import org.openedx.foundation.extension.isEmailValid +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue class HtmlUnitFragment : Fragment() { - private val viewModel by viewModel() - private var blockId: String = "" + private val viewModel by viewModel { + parametersOf( + requireArguments().getString(ARG_BLOCK_ID, ""), + requireArguments().getString(ARG_COURSE_ID, "") + ) + } private var blockUrl: String = "" + private var offlineUrl: String = "" + private var lastModified: String = "" + private var fromDownloadedContent: Boolean = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - blockId = requireArguments().getString(ARG_BLOCK_ID, "") blockUrl = requireArguments().getString(ARG_BLOCK_URL, "") + offlineUrl = requireArguments().getString(ARG_OFFLINE_URL, "") + lastModified = requireArguments().getString(ARG_LAST_MODIFIED, "") + fromDownloadedContent = lastModified.isNotEmpty() } override fun onCreateView( @@ -61,15 +97,22 @@ class HtmlUnitFragment : Fragment() { OpenEdXTheme { val windowSize = rememberWindowSize() - var isLoading by remember { - mutableStateOf(true) - } - var hasInternetConnection by remember { mutableStateOf(viewModel.isOnline) } + val url by rememberSaveable { + mutableStateOf( + if (!hasInternetConnection && offlineUrl.isNotEmpty()) { + offlineUrl + } else { + blockUrl + } + ) + } + val injectJSList by viewModel.injectJSList.collectAsState() + val uiState by viewModel.uiState.collectAsState() val configuration = LocalConfiguration.current @@ -102,36 +145,47 @@ class HtmlUnitFragment : Fragment() { .then(border), contentAlignment = Alignment.TopCenter ) { - if (hasInternetConnection) { - HTMLContentView( - windowSize = windowSize, - url = blockUrl, - cookieManager = viewModel.cookieManager, - apiHostURL = viewModel.apiHostURL, - isLoading = isLoading, - injectJSList = injectJSList, - onCompletionSet = { - viewModel.notifyCompletionSet() - }, - onWebPageLoading = { - isLoading = true - }, - onWebPageLoaded = { - isLoading = false - if (isAdded) viewModel.setWebPageLoaded(requireContext().assets) - } - ) + if (uiState is HtmlUnitUIState.Initialization) return@Box + if ((uiState is HtmlUnitUIState.Error).not()) { + if (hasInternetConnection || fromDownloadedContent) { + HTMLContentView( + uiState = uiState, + windowSize = windowSize, + url = url, + cookieManager = viewModel.cookieManager, + apiHostURL = viewModel.apiHostURL, + isLoading = uiState is HtmlUnitUIState.Loading, + injectJSList = injectJSList, + onCompletionSet = { + viewModel.notifyCompletionSet() + }, + onWebPageLoading = { + viewModel.onWebPageLoading() + }, + onWebPageLoaded = { + if ((uiState is HtmlUnitUIState.Error).not()) { + viewModel.onWebPageLoaded() + } + if (isAdded) viewModel.setWebPageLoaded(requireContext().assets) + }, + onWebPageLoadError = { + if (!fromDownloadedContent) viewModel.onWebPageLoadError() + }, + saveXBlockProgress = { jsonProgress -> + viewModel.saveXBlockProgress(jsonProgress) + }, + ) + } else { + viewModel.onWebPageLoadError() + } } else { - ConnectionErrorView( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight() - .background(MaterialTheme.appColors.background) - ) { + val errorType = (uiState as HtmlUnitUIState.Error).errorType + FullScreenErrorView(errorType = errorType) { hasInternetConnection = viewModel.isOnline + viewModel.onWebPageLoading() } } - if (isLoading && hasInternetConnection) { + if (uiState is HtmlUnitUIState.Loading && hasInternetConnection) { Box( modifier = Modifier .fillMaxSize() @@ -150,15 +204,24 @@ class HtmlUnitFragment : Fragment() { companion object { private const val ARG_BLOCK_ID = "blockId" + private const val ARG_COURSE_ID = "courseId" private const val ARG_BLOCK_URL = "blockUrl" + private const val ARG_OFFLINE_URL = "offlineUrl" + private const val ARG_LAST_MODIFIED = "lastModified" fun newInstance( blockId: String, blockUrl: String, + courseId: String, + offlineUrl: String = "", + lastModified: String = "" ): HtmlUnitFragment { val fragment = HtmlUnitFragment() fragment.arguments = bundleOf( ARG_BLOCK_ID to blockId, - ARG_BLOCK_URL to blockUrl + ARG_BLOCK_URL to blockUrl, + ARG_OFFLINE_URL to offlineUrl, + ARG_LAST_MODIFIED to lastModified, + ARG_COURSE_ID to courseId ) return fragment } @@ -168,6 +231,7 @@ class HtmlUnitFragment : Fragment() { @Composable @SuppressLint("SetJavaScriptEnabled") private fun HTMLContentView( + uiState: HtmlUnitUIState, windowSize: WindowSize, url: String, cookieManager: AppCookieManager, @@ -177,6 +241,8 @@ private fun HTMLContentView( onCompletionSet: () -> Unit, onWebPageLoading: () -> Unit, onWebPageLoaded: () -> Unit, + onWebPageLoadError: () -> Unit, + saveXBlockProgress: (String) -> Unit, ) { val coroutineScope = rememberCoroutineScope() val context = LocalContext.current @@ -190,6 +256,8 @@ private fun HTMLContentView( ) } + val isDarkTheme = isSystemInDarkTheme() + AndroidView( modifier = Modifier .then(screenWidth) @@ -203,6 +271,17 @@ private fun HTMLContentView( onCompletionSet() } }, "callback") + addJavascriptInterface( + JSBridge( + postMessageCallback = { + coroutineScope.launch { + saveXBlockProgress(it) + setupOfflineProgress(it) + } + } + ), + "AndroidBridge" + ) webViewClient = object : WebViewClient() { override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { @@ -257,6 +336,17 @@ private fun HTMLContentView( } super.onReceivedHttpError(view, request, errorResponse) } + + override fun onReceivedError( + view: WebView, + request: WebResourceRequest, + error: WebResourceError + ) { + if (view.url.equalsHost(request.url.host)) { + onWebPageLoadError() + } + super.onReceivedError(view, request, error) + } } with(settings) { javaScriptEnabled = true @@ -265,16 +355,38 @@ private fun HTMLContentView( setSupportZoom(true) loadsImagesAutomatically = true domStorageEnabled = true + allowFileAccess = true + allowContentAccess = true + useWideViewPort = true + cacheMode = WebSettings.LOAD_NO_CACHE } isVerticalScrollBarEnabled = false isHorizontalScrollBarEnabled = false - loadUrl(url) + + loadUrl(url, coroutineScope, cookieManager) + applyDarkModeIfEnabled(isDarkTheme) } }, update = { webView -> if (!isLoading && injectJSList.isNotEmpty()) { injectJSList.forEach { webView.evaluateJavascript(it, null) } + val jsonProgress = (uiState as? HtmlUnitUIState.Loaded)?.jsonProgress + if (!jsonProgress.isNullOrEmpty()) { + webView.setupOfflineProgress(jsonProgress) + } } - }) + } + ) } +private fun WebView.setupOfflineProgress(jsonProgress: String) { + loadUrl("javascript:markProblemCompleted('$jsonProgress');") +} + +class JSBridge(val postMessageCallback: (String) -> Unit) { + @Suppress("unused") + @JavascriptInterface + fun postMessage(str: String) { + postMessageCallback(str) + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitUIState.kt b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitUIState.kt new file mode 100644 index 000000000..855a7a1e9 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitUIState.kt @@ -0,0 +1,10 @@ +package org.openedx.course.presentation.unit.html + +import org.openedx.core.presentation.global.ErrorType + +sealed class HtmlUnitUIState { + data object Initialization : HtmlUnitUIState() + data object Loading : HtmlUnitUIState() + data class Loaded(val jsonProgress: String? = null) : HtmlUnitUIState() + data class Error(val errorType: ErrorType) : HtmlUnitUIState() +} diff --git a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitViewModel.kt index c65fcb33e..ca79ce90b 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitViewModel.kt @@ -2,32 +2,60 @@ package org.openedx.course.presentation.unit.html import android.content.res.AssetManager import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import org.openedx.core.BaseViewModel import org.openedx.core.config.Config -import org.openedx.core.extension.readAsText +import org.openedx.core.presentation.global.ErrorType import org.openedx.core.system.AppCookieManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseCompletionSet import org.openedx.core.system.notifier.CourseNotifier +import org.openedx.course.domain.interactor.CourseInteractor +import org.openedx.course.worker.OfflineProgressSyncScheduler +import org.openedx.foundation.extension.readAsText +import org.openedx.foundation.presentation.BaseViewModel class HtmlUnitViewModel( + private val blockId: String, + private val courseId: String, private val config: Config, private val edxCookieManager: AppCookieManager, private val networkConnection: NetworkConnection, - private val notifier: CourseNotifier + private val notifier: CourseNotifier, + private val courseInteractor: CourseInteractor, + private val offlineProgressSyncScheduler: OfflineProgressSyncScheduler ) : BaseViewModel() { + private val _uiState = MutableStateFlow(HtmlUnitUIState.Initialization) + val uiState = _uiState.asStateFlow() + private val _injectJSList = MutableStateFlow>(listOf()) val injectJSList = _injectJSList.asStateFlow() val isOnline get() = networkConnection.isOnline() - val isCourseUnitProgressEnabled get() = config.isCourseUnitProgressEnabled() + val isCourseUnitProgressEnabled get() = config.getCourseUIConfig().isCourseUnitProgressEnabled val apiHostURL get() = config.getApiHostURL() val cookieManager get() = edxCookieManager + init { + tryToSyncProgress() + } + + fun onWebPageLoading() { + _uiState.value = HtmlUnitUIState.Loading + } + + fun onWebPageLoaded() { + _uiState.value = HtmlUnitUIState.Loaded() + } + + fun onWebPageLoadError() { + _uiState.value = + HtmlUnitUIState.Error(if (networkConnection.isOnline()) ErrorType.UNKNOWN_ERROR else ErrorType.CONNECTION_ERROR) + } + fun setWebPageLoaded(assets: AssetManager) { if (_injectJSList.value.isNotEmpty()) return @@ -39,6 +67,7 @@ class HtmlUnitViewModel( assets.readAsText("js_injection/survey_css.js")?.let { jsList.add(it) } _injectJSList.value = jsList + getXBlockProgress() } fun notifyCompletionSet() { @@ -46,4 +75,35 @@ class HtmlUnitViewModel( notifier.send(CourseCompletionSet()) } } + + fun saveXBlockProgress(jsonProgress: String) { + viewModelScope.launch { + courseInteractor.saveXBlockProgress(blockId, courseId, jsonProgress) + offlineProgressSyncScheduler.scheduleSync() + } + } + + private fun tryToSyncProgress() { + viewModelScope.launch { + try { + if (isOnline) { + courseInteractor.submitOfflineXBlockProgress(blockId, courseId) + } + } catch (e: Exception) { + e.printStackTrace() + } finally { + _uiState.value = HtmlUnitUIState.Loading + } + } + } + + private fun getXBlockProgress() { + viewModelScope.launch { + if (!isOnline) { + val xBlockProgress = courseInteractor.getXBlockProgress(blockId) + delay(500) + _uiState.value = HtmlUnitUIState.Loaded(jsonProgress = xBlockProgress?.jsonProgress?.toJson()) + } + } + } } diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/BaseVideoViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/BaseVideoViewModel.kt index 96d285223..7c67329e6 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/BaseVideoViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/BaseVideoViewModel.kt @@ -1,9 +1,9 @@ package org.openedx.course.presentation.unit.video -import org.openedx.core.BaseViewModel import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseAnalyticsEvent import org.openedx.course.presentation.CourseAnalyticsKey +import org.openedx.foundation.presentation.BaseViewModel open class BaseVideoViewModel( private val courseId: String, diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoFullScreenFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoFullScreenFragment.kt index 3caa4d7c6..7bbf0bd25 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoFullScreenFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoFullScreenFragment.kt @@ -27,12 +27,12 @@ import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.core.domain.model.VideoQuality -import org.openedx.core.extension.requestApplyInsetsWhenAttached import org.openedx.core.presentation.dialog.appreview.AppReviewManager import org.openedx.core.presentation.global.viewBinding import org.openedx.course.R import org.openedx.course.databinding.FragmentVideoFullScreenBinding import org.openedx.course.presentation.CourseAnalyticsKey +import org.openedx.foundation.extension.requestApplyInsetsWhenAttached class VideoFullScreenFragment : Fragment(R.layout.fragment_video_full_screen) { diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitFragment.kt index 2e078f4c6..0cc44dac3 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitFragment.kt @@ -6,14 +6,10 @@ import android.os.Handler import android.os.Looper import android.view.View import android.widget.FrameLayout -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.MaterialTheme import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.ui.Modifier import androidx.core.os.bundleOf import androidx.core.view.isVisible import androidx.fragment.app.Fragment @@ -27,17 +23,11 @@ import androidx.window.layout.WindowMetricsCalculator import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf -import org.openedx.core.extension.computeWindowSizeClasses -import org.openedx.core.extension.dpToPixel -import org.openedx.core.extension.objectToString -import org.openedx.core.extension.stringToObject import org.openedx.core.presentation.dialog.appreview.AppReviewManager import org.openedx.core.presentation.dialog.selectorbottomsheet.SelectBottomDialogFragment import org.openedx.core.presentation.global.viewBinding import org.openedx.core.ui.ConnectionErrorView -import org.openedx.core.ui.WindowSize import org.openedx.core.ui.theme.OpenEdXTheme -import org.openedx.core.ui.theme.appColors import org.openedx.core.utils.LocaleUtils import org.openedx.course.R import org.openedx.course.databinding.FragmentVideoUnitBinding @@ -46,6 +36,11 @@ import org.openedx.course.presentation.CourseAnalyticsKey import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.ui.VideoSubtitles import org.openedx.course.presentation.ui.VideoTitle +import org.openedx.foundation.extension.computeWindowSizeClasses +import org.openedx.foundation.extension.dpToPixel +import org.openedx.foundation.extension.objectToString +import org.openedx.foundation.extension.stringToObject +import org.openedx.foundation.presentation.WindowSize import kotlin.math.roundToInt class VideoUnitFragment : Fragment(R.layout.fragment_video_unit) { @@ -91,7 +86,6 @@ class VideoUnitFragment : Fragment(R.layout.fragment_video_unit) { viewModel.isDownloaded = getBoolean(ARG_DOWNLOADED) } viewModel.downloadSubtitles() - handler.removeCallbacks(videoTimeRunnable) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -105,11 +99,7 @@ class VideoUnitFragment : Fragment(R.layout.fragment_video_unit) { binding.connectionError.setContent { OpenEdXTheme { - ConnectionErrorView( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.appColors.background) - ) { + ConnectionErrorView { binding.connectionError.isVisible = !viewModel.hasInternetConnection && !viewModel.isDownloaded } @@ -202,9 +192,10 @@ class VideoUnitFragment : Fragment(R.layout.fragment_video_unit) { if (!viewModel.isPlayerSetUp) { setPlayerMedia(mediaItem) viewModel.getActivePlayer()?.prepare() - viewModel.getActivePlayer()?.playWhenReady = viewModel.isPlaying + viewModel.getActivePlayer()?.playWhenReady = viewModel.isPlaying && isResumed viewModel.isPlayerSetUp = true } + viewModel.getActivePlayer()?.seekTo(viewModel.getCurrentVideoTime()) viewModel.castPlayer?.setSessionAvailabilityListener( object : SessionAvailabilityListener { diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitViewModel.kt index e28e723f6..5779b96da 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitViewModel.kt @@ -100,8 +100,8 @@ open class VideoUnitViewModel( open fun markBlockCompleted(blockId: String, medium: String) { - logLoadedCompletedEvent(videoUrl, false, getCurrentVideoTime(), medium) if (!isBlockAlreadyCompleted) { + logLoadedCompletedEvent(videoUrl, false, getCurrentVideoTime(), medium) viewModelScope.launch { try { isBlockAlreadyCompleted = true diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoViewModel.kt index a4063393a..4ae600eb8 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoViewModel.kt @@ -40,8 +40,8 @@ class VideoViewModel( } fun markBlockCompleted(blockId: String, medium: String) { - logLoadedCompletedEvent(videoUrl, false, currentVideoTime, medium) if (!isBlockAlreadyCompleted) { + logLoadedCompletedEvent(videoUrl, false, currentVideoTime, medium) viewModelScope.launch { try { isBlockAlreadyCompleted = true diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoFullScreenFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoFullScreenFragment.kt index f62659c26..397c36baf 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoFullScreenFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoFullScreenFragment.kt @@ -16,12 +16,12 @@ import com.pierfrancescosoffritti.androidyoutubeplayer.core.ui.DefaultPlayerUiCo import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf -import org.openedx.core.extension.requestApplyInsetsWhenAttached import org.openedx.core.presentation.dialog.appreview.AppReviewManager import org.openedx.core.presentation.global.viewBinding import org.openedx.course.R import org.openedx.course.databinding.FragmentYoutubeVideoFullScreenBinding import org.openedx.course.presentation.CourseAnalyticsKey +import org.openedx.foundation.extension.requestApplyInsetsWhenAttached class YoutubeVideoFullScreenFragment : Fragment(R.layout.fragment_youtube_video_full_screen) { diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoUnitFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoUnitFragment.kt index 8ee99b970..58aaaf377 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoUnitFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoUnitFragment.kt @@ -4,14 +4,10 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.MaterialTheme import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.ui.Modifier import androidx.core.os.bundleOf import androidx.core.view.isVisible import androidx.fragment.app.Fragment @@ -24,15 +20,10 @@ import com.pierfrancescosoffritti.androidyoutubeplayer.core.ui.DefaultPlayerUiCo import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf -import org.openedx.core.extension.computeWindowSizeClasses -import org.openedx.core.extension.objectToString -import org.openedx.core.extension.stringToObject import org.openedx.core.presentation.dialog.appreview.AppReviewManager import org.openedx.core.presentation.dialog.selectorbottomsheet.SelectBottomDialogFragment import org.openedx.core.ui.ConnectionErrorView -import org.openedx.core.ui.WindowSize import org.openedx.core.ui.theme.OpenEdXTheme -import org.openedx.core.ui.theme.appColors import org.openedx.core.utils.LocaleUtils import org.openedx.course.R import org.openedx.course.databinding.FragmentYoutubeVideoUnitBinding @@ -40,6 +31,10 @@ import org.openedx.course.presentation.CourseAnalyticsKey import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.ui.VideoSubtitles import org.openedx.course.presentation.ui.VideoTitle +import org.openedx.foundation.extension.computeWindowSizeClasses +import org.openedx.foundation.extension.objectToString +import org.openedx.foundation.extension.stringToObject +import org.openedx.foundation.presentation.WindowSize class YoutubeVideoUnitFragment : Fragment(R.layout.fragment_youtube_video_unit) { @@ -84,6 +79,13 @@ class YoutubeVideoUnitFragment : Fragment(R.layout.fragment_youtube_video_unit) return binding.root } + override fun onResume() { + super.onResume() + if (viewModel.isPlaying) { + _youTubePlayer?.play() + } + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -95,11 +97,7 @@ class YoutubeVideoUnitFragment : Fragment(R.layout.fragment_youtube_video_unit) binding.connectionError.setContent { OpenEdXTheme { - ConnectionErrorView( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.appColors.background) - ) { + ConnectionErrorView { binding.connectionError.isVisible = !viewModel.hasInternetConnection } } @@ -202,7 +200,7 @@ class YoutubeVideoUnitFragment : Fragment(R.layout.fragment_youtube_video_unit) } viewModel.videoUrl.split("watch?v=").getOrNull(1)?.let { videoId -> - if (viewModel.isPlaying) { + if (viewModel.isPlaying && isResumed) { youTubePlayer.loadVideo( videoId, viewModel.getCurrentVideoTime().toFloat() / 1000 ) diff --git a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt index dc88105a8..3d197859f 100644 --- a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt @@ -1,5 +1,6 @@ package org.openedx.course.presentation.videos +import androidx.fragment.app.FragmentManager import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -9,7 +10,6 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.openedx.core.BlockType -import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Block @@ -17,10 +17,9 @@ import org.openedx.core.domain.model.VideoSettings import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.download.BaseDownloadViewModel +import org.openedx.core.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics -import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection -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 @@ -29,6 +28,11 @@ import org.openedx.core.system.notifier.VideoQualityChanged import org.openedx.course.R import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics +import org.openedx.course.presentation.CourseRouter +import org.openedx.course.presentation.download.DownloadDialogManager +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager +import org.openedx.foundation.utils.FileUtil class CourseVideoViewModel( val courseId: String, @@ -41,18 +45,23 @@ class CourseVideoViewModel( private val courseNotifier: CourseNotifier, private val videoNotifier: VideoNotifier, private val analytics: CourseAnalytics, + private val downloadDialogManager: DownloadDialogManager, + private val fileUtil: FileUtil, + val courseRouter: CourseRouter, coreAnalytics: CoreAnalytics, downloadDao: DownloadDao, - workerController: DownloadWorkerController + workerController: DownloadWorkerController, + downloadHelper: DownloadHelper, ) : BaseDownloadViewModel( courseId, downloadDao, preferencesManager, workerController, - coreAnalytics + coreAnalytics, + downloadHelper, ) { - val isCourseNestedListEnabled get() = config.isCourseNestedListEnabled() + val isCourseDropdownNavigationEnabled get() = config.getCourseUIConfig().isCourseDropdownNavigationEnabled private val _uiState = MutableStateFlow(CourseVideosUIState.Loading) val uiState: StateFlow @@ -75,13 +84,9 @@ class CourseVideoViewModel( when (event) { is CourseStructureUpdated -> { if (event.courseId == courseId) { - updateVideos() + getVideos() } } - - is CourseDataReady -> { - getVideos() - } } } } @@ -114,6 +119,8 @@ class CourseVideoViewModel( } _videoSettings.value = preferencesManager.videoSettings + + getVideos() } override fun saveDownloadModels(folder: String, id: String) { @@ -141,35 +148,38 @@ class CourseVideoViewModel( super.saveAllDownloadModels(folder) } - private fun updateVideos() { - getVideos() - } - fun getVideos() { viewModelScope.launch { - var courseStructure = interactor.getCourseStructureForVideos() - val blocks = courseStructure.blockData - if (blocks.isEmpty()) { - _uiState.value = CourseVideosUIState.Empty( - message = resourceManager.getString(R.string.course_does_not_include_videos) - ) - } else { - setBlocks(courseStructure.blockData) - courseSubSections.clear() - courseSubSectionUnit.clear() - courseStructure = courseStructure.copy(blockData = sortBlocks(blocks)) - initDownloadModelsStatus() - - val courseSectionsState = - (_uiState.value as? CourseVideosUIState.CourseData)?.courseSectionsState.orEmpty() - - _uiState.value = - CourseVideosUIState.CourseData( - courseStructure, getDownloadModelsStatus(), courseSubSections, - courseSectionsState, subSectionsDownloadsCount, getDownloadModelsSize() - ) + try { + var courseStructure = interactor.getCourseStructureForVideos(courseId) + val blocks = courseStructure.blockData + if (blocks.isEmpty()) { + _uiState.value = CourseVideosUIState.Empty + } else { + setBlocks(courseStructure.blockData) + courseSubSections.clear() + courseSubSectionUnit.clear() + courseStructure = courseStructure.copy(blockData = sortBlocks(blocks)) + initDownloadModelsStatus() + + val courseSectionsState = + (_uiState.value as? CourseVideosUIState.CourseData)?.courseSectionsState.orEmpty() + + _uiState.value = + CourseVideosUIState.CourseData( + courseStructure = courseStructure, + downloadedState = getDownloadModelsStatus(), + courseSubSections = courseSubSections, + courseSectionsState = courseSectionsState, + subSectionsDownloadsCount = subSectionsDownloadsCount, + downloadModelsSize = getDownloadModelsSize(), + useRelativeDates = preferencesManager.isRelativeDatesEnabled + ) + } + courseNotifier.send(CourseLoading(false)) + } catch (e: Exception) { + _uiState.value = CourseVideosUIState.Empty } - courseNotifier.send(CourseLoading(false)) } } @@ -204,15 +214,10 @@ class CourseVideoViewModel( resultBlocks.add(block) block.descendants.forEach { descendant -> blocks.find { it.id == descendant }?.let { - if (isCourseNestedListEnabled) { - courseSubSections.getOrPut(block.id) { mutableListOf() } - .add(it) - courseSubSectionUnit[it.id] = it.getFirstDescendantBlock(blocks) - subSectionsDownloadsCount[it.id] = it.getDownloadsCount(blocks) - - } else { - resultBlocks.add(it) - } + courseSubSections.getOrPut(block.id) { mutableListOf() } + .add(it) + courseSubSectionUnit[it.id] = it.getFirstDescendantBlock(blocks) + subSectionsDownloadsCount[it.id] = it.getDownloadsCount(blocks) addDownloadableChildrenForSequentialBlock(it) } } @@ -220,4 +225,58 @@ class CourseVideoViewModel( } return resultBlocks.toList() } + + fun downloadBlocks(blocksIds: List, fragmentManager: FragmentManager) { + viewModelScope.launch { + val courseData = _uiState.value as? CourseVideosUIState.CourseData ?: return@launch + + val subSectionsBlocks = courseData.courseSubSections.values.flatten().filter { it.id in blocksIds } + + val blocks = subSectionsBlocks.flatMap { subSectionsBlock -> + val verticalBlocks = allBlocks.values.filter { it.id in subSectionsBlock.descendants } + allBlocks.values.filter { it.id in verticalBlocks.flatMap { it.descendants } } + } + + val downloadableBlocks = blocks.filter { it.isDownloadable } + val downloadingBlocks = blocksIds.filter { isBlockDownloading(it) } + val isAllBlocksDownloaded = downloadableBlocks.all { isBlockDownloaded(it.id) } + + val notDownloadedSubSectionBlocks = subSectionsBlocks.mapNotNull { subSectionsBlock -> + val verticalBlocks = allBlocks.values.filter { it.id in subSectionsBlock.descendants } + val notDownloadedBlocks = allBlocks.values.filter { + it.id in verticalBlocks.flatMap { it.descendants } && it.isDownloadable && !isBlockDownloaded(it.id) + } + if (notDownloadedBlocks.isNotEmpty()) subSectionsBlock else null + } + + val requiredSubSections = notDownloadedSubSectionBlocks.ifEmpty { + subSectionsBlocks + } + + if (downloadingBlocks.isNotEmpty()) { + val downloadableChildren = downloadingBlocks.flatMap { getDownloadableChildren(it).orEmpty() } + if (config.getCourseUIConfig().isCourseDownloadQueueEnabled) { + courseRouter.navigateToDownloadQueue(fragmentManager, downloadableChildren) + } else { + downloadableChildren.forEach { + if (!isBlockDownloaded(it)) { + removeBlockDownloadModel(it) + } + } + } + } else { + downloadDialogManager.showPopup( + subSectionsBlocks = requiredSubSections, + courseId = courseId, + isBlocksDownloaded = isAllBlocksDownloaded, + onlyVideoBlocks = true, + fragmentManager = fragmentManager, + removeDownloadModels = ::removeDownloadModels, + saveDownloadModels = { blockId -> + saveDownloadModels(fileUtil.getExternalAppDir().path, blockId) + } + ) + } + } + } } diff --git a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideosUIState.kt b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideosUIState.kt index ce05913d6..245fb2380 100644 --- a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideosUIState.kt +++ b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideosUIState.kt @@ -12,9 +12,10 @@ sealed class CourseVideosUIState { val courseSubSections: Map>, val courseSectionsState: Map, val subSectionsDownloadsCount: Map, - val downloadModelsSize: DownloadModelsSize + val downloadModelsSize: DownloadModelsSize, + val useRelativeDates: Boolean ) : CourseVideosUIState() - data class Empty(val message: String) : CourseVideosUIState() - object Loading : CourseVideosUIState() + data object Empty : CourseVideosUIState() + data object Loading : CourseVideosUIState() } diff --git a/course/src/main/java/org/openedx/course/settings/download/DownloadQueueFragment.kt b/course/src/main/java/org/openedx/course/settings/download/DownloadQueueFragment.kt index 5e50ecf39..ceea27806 100644 --- a/course/src/main/java/org/openedx/course/settings/download/DownloadQueueFragment.kt +++ b/course/src/main/java/org/openedx/course/settings/download/DownloadQueueFragment.kt @@ -23,7 +23,6 @@ import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -41,24 +40,25 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import androidx.core.os.bundleOf import androidx.fragment.app.Fragment +import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.db.FileType import org.openedx.core.ui.BackBtn -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType import org.openedx.core.ui.displayCutoutForLandscape -import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography -import org.openedx.core.ui.windowSizeValue import org.openedx.course.R import org.openedx.course.presentation.ui.OfflineQueueCard +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue class DownloadQueueFragment : Fragment() { @@ -80,7 +80,7 @@ class DownloadQueueFragment : Fragment() { setContent { OpenEdXTheme { val windowSize = rememberWindowSize() - val uiState by viewModel.uiState.collectAsState(DownloadQueueUIState.Loading) + val uiState by viewModel.uiState.collectAsStateWithLifecycle(DownloadQueueUIState.Loading) DownloadQueueScreen( windowSize = windowSize, @@ -223,6 +223,7 @@ private fun DownloadQueueScreenPreview() { uiState = DownloadQueueUIState.Models( listOf( DownloadModel( + courseId = "", id = "", title = "1", size = 0, @@ -230,9 +231,9 @@ private fun DownloadQueueScreenPreview() { url = "", type = FileType.VIDEO, downloadedState = DownloadedState.DOWNLOADING, - progress = 0f ), DownloadModel( + courseId = "", id = "", title = "2", size = 0, @@ -240,7 +241,6 @@ private fun DownloadQueueScreenPreview() { url = "", type = FileType.VIDEO, downloadedState = DownloadedState.DOWNLOADING, - progress = 0f ) ), currentProgressId = "", diff --git a/course/src/main/java/org/openedx/course/settings/download/DownloadQueueViewModel.kt b/course/src/main/java/org/openedx/course/settings/download/DownloadQueueViewModel.kt index 3b9f3d1aa..1c74e3b80 100644 --- a/course/src/main/java/org/openedx/course/settings/download/DownloadQueueViewModel.kt +++ b/course/src/main/java/org/openedx/course/settings/download/DownloadQueueViewModel.kt @@ -8,6 +8,7 @@ import org.openedx.core.data.storage.CorePreferences import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.download.BaseDownloadViewModel +import org.openedx.core.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.system.notifier.DownloadNotifier import org.openedx.core.system.notifier.DownloadProgressChanged @@ -19,7 +20,15 @@ class DownloadQueueViewModel( private val workerController: DownloadWorkerController, private val downloadNotifier: DownloadNotifier, coreAnalytics: CoreAnalytics, -) : BaseDownloadViewModel("", downloadDao, preferencesManager, workerController, coreAnalytics) { + downloadHelper: DownloadHelper, +) : BaseDownloadViewModel( + "", + downloadDao, + preferencesManager, + workerController, + coreAnalytics, + downloadHelper +) { private val _uiState = MutableStateFlow(DownloadQueueUIState.Loading) val uiState = _uiState.asStateFlow() diff --git a/core/src/main/java/org/openedx/core/ImageProcessor.kt b/course/src/main/java/org/openedx/course/utils/ImageProcessor.kt similarity index 98% rename from core/src/main/java/org/openedx/core/ImageProcessor.kt rename to course/src/main/java/org/openedx/course/utils/ImageProcessor.kt index d3a6c4a4c..b83f4a5e5 100644 --- a/core/src/main/java/org/openedx/core/ImageProcessor.kt +++ b/course/src/main/java/org/openedx/course/utils/ImageProcessor.kt @@ -1,6 +1,6 @@ @file:Suppress("DEPRECATION") -package org.openedx.core +package org.openedx.course.utils import android.content.Context import android.graphics.Bitmap diff --git a/course/src/main/java/org/openedx/course/worker/OfflineProgressSyncScheduler.kt b/course/src/main/java/org/openedx/course/worker/OfflineProgressSyncScheduler.kt new file mode 100644 index 000000000..667772d33 --- /dev/null +++ b/course/src/main/java/org/openedx/course/worker/OfflineProgressSyncScheduler.kt @@ -0,0 +1,35 @@ +package org.openedx.course.worker + +import android.content.Context +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import java.util.concurrent.TimeUnit + +class OfflineProgressSyncScheduler(private val context: Context) { + + fun scheduleSync() { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.UNMETERED) + .build() + + val workRequest = OneTimeWorkRequestBuilder() + .addTag(OfflineProgressSyncWorker.WORKER_TAG) + .setConstraints(constraints) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + 1, + TimeUnit.HOURS + ) + .build() + + WorkManager.getInstance(context).enqueueUniqueWork( + OfflineProgressSyncWorker.WORKER_TAG, + ExistingWorkPolicy.REPLACE, + workRequest + ) + } +} diff --git a/course/src/main/java/org/openedx/course/worker/OfflineProgressSyncWorker.kt b/course/src/main/java/org/openedx/course/worker/OfflineProgressSyncWorker.kt new file mode 100644 index 000000000..d41e9909e --- /dev/null +++ b/course/src/main/java/org/openedx/course/worker/OfflineProgressSyncWorker.kt @@ -0,0 +1,82 @@ +package org.openedx.course.worker + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.content.pm.ServiceInfo +import android.os.Build +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.core.app.NotificationCompat +import androidx.work.CoroutineWorker +import androidx.work.ForegroundInfo +import androidx.work.WorkerParameters +import com.google.firebase.crashlytics.ktx.crashlytics +import com.google.firebase.ktx.Firebase +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.openedx.core.R +import org.openedx.course.domain.interactor.CourseInteractor + +class OfflineProgressSyncWorker( + private val context: Context, + workerParams: WorkerParameters +) : CoroutineWorker(context, workerParams), KoinComponent { + + private val courseInteractor: CourseInteractor by inject() + + private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + private val notificationBuilder = NotificationCompat.Builder(context, NOTIFICATION_CHANEL_ID) + + override suspend fun doWork(): Result { + return try { + setForeground(createForegroundInfo()) + tryToSyncProgress() + Result.success() + } catch (e: Exception) { + Log.e(WORKER_TAG, "$e") + Firebase.crashlytics.log("$e") + Result.failure() + } + } + + private fun createForegroundInfo(): ForegroundInfo { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + createChannel() + } + val serviceType = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC else 0 + + return ForegroundInfo( + NOTIFICATION_ID, + notificationBuilder + .setSmallIcon(R.drawable.core_ic_offline) + .setContentText(context.getString(R.string.core_title_syncing_calendar)) + .setContentTitle("") + .build(), + serviceType + ) + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun createChannel() { + val notificationChannel = + NotificationChannel( + NOTIFICATION_CHANEL_ID, + context.getString(R.string.core_offline_progress_sync), + NotificationManager.IMPORTANCE_LOW + ) + notificationManager.createNotificationChannel(notificationChannel) + } + + private suspend fun tryToSyncProgress() { + courseInteractor.submitAllOfflineXBlockProgress() + } + + companion object { + const val WORKER_TAG = "progress_sync_worker_tag" + const val NOTIFICATION_ID = 5678 + const val NOTIFICATION_CHANEL_ID = "progress_sync_channel" + } +} diff --git a/course/src/main/res/drawable/course_download_waiting.png b/course/src/main/res/drawable/course_download_waiting.png new file mode 100644 index 000000000..c4a04af69 Binary files /dev/null and b/course/src/main/res/drawable/course_download_waiting.png differ diff --git a/course/src/main/res/drawable/course_ic_calendar.xml b/course/src/main/res/drawable/course_ic_calendar.xml new file mode 100644 index 000000000..c8f12ef7a --- /dev/null +++ b/course/src/main/res/drawable/course_ic_calendar.xml @@ -0,0 +1,9 @@ + + + diff --git a/course/src/main/res/drawable/course_ic_circled_arrow_up.xml b/course/src/main/res/drawable/course_ic_circled_arrow_up.xml new file mode 100644 index 000000000..aab47473e --- /dev/null +++ b/course/src/main/res/drawable/course_ic_circled_arrow_up.xml @@ -0,0 +1,32 @@ + + + + + + diff --git a/course/src/main/res/drawable/course_ic_error.xml b/course/src/main/res/drawable/course_ic_error.xml new file mode 100644 index 000000000..4454ecf7c --- /dev/null +++ b/course/src/main/res/drawable/course_ic_error.xml @@ -0,0 +1,9 @@ + + + diff --git a/course/src/main/res/drawable/course_ic_remove_download.xml b/course/src/main/res/drawable/course_ic_remove_download.xml deleted file mode 100644 index 6fa45832e..000000000 --- a/course/src/main/res/drawable/course_ic_remove_download.xml +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - diff --git a/course/src/main/res/drawable/course_ic_start_download.xml b/course/src/main/res/drawable/course_ic_start_download.xml deleted file mode 100644 index e56223200..000000000 --- a/course/src/main/res/drawable/course_ic_start_download.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - diff --git a/course/src/main/res/drawable/ic_course_certificate.xml b/course/src/main/res/drawable/ic_course_certificate.xml new file mode 100644 index 000000000..53ca91779 --- /dev/null +++ b/course/src/main/res/drawable/ic_course_certificate.xml @@ -0,0 +1,9 @@ + + + diff --git a/course/src/main/res/drawable/ic_course_chapter_icon.xml b/course/src/main/res/drawable/ic_course_chapter_icon.xml deleted file mode 100644 index eaf899ce2..000000000 --- a/course/src/main/res/drawable/ic_course_chapter_icon.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - diff --git a/course/src/main/res/drawable/ic_course_completed_mark.xml b/course/src/main/res/drawable/ic_course_completed_mark.xml deleted file mode 100644 index bf3307778..000000000 --- a/course/src/main/res/drawable/ic_course_completed_mark.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - diff --git a/course/src/main/res/values-uk/strings.xml b/course/src/main/res/values-uk/strings.xml deleted file mode 100644 index ffbf7c459..000000000 --- a/course/src/main/res/values-uk/strings.xml +++ /dev/null @@ -1,53 +0,0 @@ - - - Огляд курсу - Зміст курсу - Одиниці курсу - Підрозділи курсу - Відео - Ви успішно пройшли курс! Тепер ви можете отримати сертифікат - Ви успішно пройшли курс - Вітаємо! - Переглянути сертифікат - Ви можете отримати сертифікат після проходження курсу (заробіть необхідну оцінку) - Отримати сертифікат - Назад - Попередня одиниця - Далі - Наступна одиниця - Завершити - Цей курс не містить відео. - Остання одиниця: - Продовжити - Обговорення - Роздаткові матеріали - Оголошення - Знайдіть важливу інформацію про курс - Будьте в курсі останніх новин - Гарна робота! - Секція \"%s\" завершена. - Наступний розділ - Повернутись до модуля - Цей курс ще не розпочався. - Ви не підключені до Інтернету. Будь ласка, перевірте ваше підключення до Інтернету. - Курс - Відео - Обговорення - Матеріали - Ви можете завантажувати контент тільки через Wi-Fi - Ця інтерактивна компонента ще не доступна - Досліджуйте інші частини цього курсу або перегляньте це на веб-сайті. - Відкрити в браузері - Субтитри - Остання активність: - Продовжити - Щоб перейти до \"%s\", натисніть \"Наступний розділ\". - - - Відеоплеєр - Видалити секцію курсу - Завантажити секцію курсу - Зупинити завантаження секції курсу - Секція завершена - Секція не завершена - diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index c6b370267..eefe590d8 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -5,18 +5,13 @@ Course units Course subsections Videos - You have passed the course! Now you can get the certificate - You’ve completed the course - Congratulations! - View the certificate - You can get a certificate after completing the course (earn required grade) - Get the certificate + Congratulations, you have earned this course certificate in \"%s\". + View certificate Prev Previous Unit Next Next Unit Finish - This course does not include any videos. Last unit: Resume Discussion @@ -31,8 +26,8 @@ This course hasn’t started yet. You are not connected to the Internet. Please check your Internet connection. You can download content only from Wi-fi - This interactive component isn\'t available on mobile. - Explore other parts of this course or view this on web. + This interactive component isn’t yet available + Explore other parts of this course or view this on web. Open in browser Subtitles Continue with: @@ -45,40 +40,12 @@ Course dates are not currently available. - - Sync to calendar - Automatically sync all deadlines and due dates for this course to your calendar. - - \“%s\” Would Like to Access Your Calendar - %s would like to use your calendar list to subscribe to your personalized %s calendar for this course. - Don’t allow - - Add Course Dates to Calendar - Would you like to add \“%s\” dates to your calendar? \n\nYou can edit or remove your course dates at any time from your calendar or settings. - - Syncing calendar… - - \“%s\” has been added to your phone\'s calendar. - View Events - Done - - Remove Course Dates from Calendar - Would you like to remove the \“%s\” dates from your calendar? - Remove - - Your course calendar is out of date - Your course dates have been shifted and your course calendar is no longer up to date with your new schedule. - Update Now - Remove Course Calendar - - Your course calendar has been added. - Your course calendar has been removed. - Your course calendar has been updated. - Error Adding Calendar, Please try later - - Assignment Due - - + Home + Videos + Discussions + More + Dates + Downloads Video player @@ -95,5 +62,46 @@ Turning off the switch will stop downloading and delete all downloaded videos for \"%s\"? Are you sure you want to delete all video(s) for \"%s\"? Are you sure you want to delete video(s) for \"%s\"? + %1$s - %2$s - %3$d / %4$d + Downloading this content requires an active internet connection. Please connect to the internet and try again. + Wi-Fi Required + Downloading this content requires an active WiFi connection. Please connect to a WiFi network and try again. + Download Failed + Unfortunately, this content failed to download. Please try again later or report this issue. + Downloading this %1$s of content will save available blocks offline. + Download on Cellular? + Downloading this content will use %1$s of cellular data. + Remove Offline Content? + Removing this content will free up %1$s. + Download + Remove + Device Storage Full + Your device does not have enough free space to download this content. Please free up some space and try again. + %1$s used, %2$s free + 0MB + Available to download + None of this course’s content is currently available to download offline. + Download all + Downloaded + Ready to Download + You can download course content offline to learn on the go, without requiring an active internet connection or using mobile data. + Downloading + Largest Downloads + Remove all downloads + Cancel Course Download + This component is not yet available offline + Explore other parts of this course or view this when you reconnect. + This component is not downloaded + Explore other parts of this course or download this when you reconnect. + + + %1$s of %2$s assignment complete + %1$s of %2$s assignments complete + + Back + Your free audit access to this course expired on %s. + Find a new course + This course will begin on %s. Come back then to start learning! + An error occurred while loading your course diff --git a/course/src/test/java/org/openedx/course/presentation/container/CourseContainerViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/container/CourseContainerViewModelTest.kt index 63dce6272..98cf58a8b 100644 --- a/course/src/test/java/org/openedx/course/presentation/container/CourseContainerViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/container/CourseContainerViewModelTest.kt @@ -22,25 +22,31 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule -import org.openedx.core.ImageProcessor import org.openedx.core.R import org.openedx.core.config.Config +import org.openedx.core.data.api.CourseApi import org.openedx.core.data.model.User import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.AppConfig +import org.openedx.core.domain.model.CourseAccessDetails +import org.openedx.core.domain.model.CourseAccessError import org.openedx.core.domain.model.CourseDatesCalendarSync +import org.openedx.core.domain.model.CourseEnrollmentDetails +import org.openedx.core.domain.model.CourseInfoOverview +import org.openedx.core.domain.model.CourseSharingUtmParameters import org.openedx.core.domain.model.CourseStructure import org.openedx.core.domain.model.CoursewareAccess -import org.openedx.core.system.ResourceManager +import org.openedx.core.domain.model.EnrollmentDetails import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseStructureUpdated -import org.openedx.course.data.storage.CoursePreferences +import org.openedx.core.worker.CalendarSyncScheduler import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseAnalyticsEvent import org.openedx.course.presentation.CourseRouter -import org.openedx.course.presentation.calendarsync.CalendarManager +import org.openedx.course.utils.ImageProcessor +import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException import java.util.Date @@ -55,18 +61,17 @@ class CourseContainerViewModelTest { private val resourceManager = mockk() private val config = mockk() private val interactor = mockk() - private val calendarManager = mockk() private val networkConnection = mockk() - private val notifier = spyk() + private val courseNotifier = spyk() private val analytics = mockk() private val corePreferences = mockk() - private val coursePreferences = mockk() private val mockBitmap = mockk() private val imageProcessor = mockk() private val courseRouter = mockk() + private val courseApi = mockk() + private val calendarSyncScheduler = mockk() private val openEdx = "OpenEdx" - private val calendarTitle = "OpenEdx - Abc" private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" @@ -84,6 +89,35 @@ class CourseContainerViewModelTest { isDeepLinkEnabled = false, ) ) + private val courseDetails = CourseEnrollmentDetails( + id = "id", + courseUpdates = "", + courseHandouts = "", + discussionUrl = "", + courseAccessDetails = CourseAccessDetails( + false, + false, + false, + null, + coursewareAccess = CoursewareAccess( + false, "", "", "", + "", "" + + ) + ), + certificate = null, + enrollmentDetails = EnrollmentDetails( + null, "audit", false, Date() + ), + courseInfoOverview = CourseInfoOverview( + "Open edX Demo Course", "", "OpenedX", Date(), + "", "", null, false, null, + CourseSharingUtmParameters("", ""), + "", + ) + + ) + private val courseStructure = CourseStructure( root = "", blockData = listOf(), @@ -105,7 +139,35 @@ class CourseContainerViewModelTest { ), media = null, certificate = null, - isSelfPaced = false + isSelfPaced = false, + progress = null + ) + + private val enrollmentDetails = CourseEnrollmentDetails( + id = "", + courseUpdates = "", + courseHandouts = "", + discussionUrl = "", + courseAccessDetails = CourseAccessDetails( + false, + false, + false, + null, + CoursewareAccess( + false, "", "", "", + "", "" + ) + ), + certificate = null, + enrollmentDetails = EnrollmentDetails( + null, "", false, null + ), + courseInfoOverview = CourseInfoOverview( + "Open edX Demo Course", "", "OpenedX", null, + "", "", null, false, null, + CourseSharingUtmParameters("", ""), + "", + ) ) @Before @@ -116,9 +178,9 @@ class CourseContainerViewModelTest { every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong every { corePreferences.user } returns user every { corePreferences.appConfig } returns appConfig - every { notifier.notifier } returns emptyFlow() - every { calendarManager.getCourseCalendarTitle(any()) } returns calendarTitle + every { courseNotifier.notifier } returns emptyFlow() every { config.getApiHostURL() } returns "baseUrl" + coEvery { interactor.getEnrollmentDetails(any()) } returns courseDetails every { imageProcessor.loadImage(any(), any(), any()) } returns Unit every { imageProcessor.applyBlur(any(), any()) } returns mockBitmap } @@ -129,136 +191,167 @@ class CourseContainerViewModelTest { } @Test - fun `preloadCourseStructure internet connection exception`() = runTest { + fun `getCourseEnrollmentDetails internet connection exception`() = runTest { val viewModel = CourseContainerViewModel( "", "", "", config, interactor, - calendarManager, resourceManager, - notifier, + courseNotifier, networkConnection, corePreferences, - coursePreferences, analytics, imageProcessor, - courseRouter + calendarSyncScheduler, + courseRouter, ) every { networkConnection.isOnline() } returns true - coEvery { interactor.preloadCourseStructure(any()) } throws UnknownHostException() - every { analytics.logEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } returns Unit - viewModel.preloadCourseStructure() + coEvery { interactor.getCourseStructure(any(), any()) } returns courseStructure + coEvery { interactor.getEnrollmentDetails(any()) } throws UnknownHostException() + every { + analytics.logScreenEvent( + CourseAnalyticsEvent.DASHBOARD.eventName, + any() + ) + } returns Unit + viewModel.fetchCourseDetails() advanceUntilIdle() - coVerify(exactly = 1) { interactor.preloadCourseStructure(any()) } - verify(exactly = 1) { analytics.logEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } + coVerify(exactly = 1) { interactor.getEnrollmentDetails(any()) } + verify(exactly = 1) { + analytics.logScreenEvent( + CourseAnalyticsEvent.DASHBOARD.eventName, + any() + ) + } val message = viewModel.errorMessage.value assertEquals(noInternet, message) assert(!viewModel.refreshing.value) - assert(viewModel.dataReady.value == null) + assert(viewModel.courseAccessStatus.value == null) } @Test - fun `preloadCourseStructure unknown exception`() = runTest { + fun `getCourseEnrollmentDetails unknown exception`() = runTest { val viewModel = CourseContainerViewModel( "", "", "", config, interactor, - calendarManager, resourceManager, - notifier, + courseNotifier, networkConnection, corePreferences, - coursePreferences, analytics, imageProcessor, + calendarSyncScheduler, courseRouter ) every { networkConnection.isOnline() } returns true - coEvery { interactor.preloadCourseStructure(any()) } throws Exception() - every { analytics.logEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } returns Unit - viewModel.preloadCourseStructure() + coEvery { interactor.getCourseStructure(any(), any()) } returns courseStructure + coEvery { interactor.getEnrollmentDetails(any()) } throws Exception() + every { + analytics.logScreenEvent( + CourseAnalyticsEvent.DASHBOARD.eventName, + any() + ) + } returns Unit + viewModel.fetchCourseDetails() advanceUntilIdle() - coVerify(exactly = 1) { interactor.preloadCourseStructure(any()) } - verify(exactly = 1) { analytics.logEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } - - val message = viewModel.errorMessage.value - assertEquals(somethingWrong, message) + coVerify(exactly = 1) { interactor.getEnrollmentDetails(any()) } + verify(exactly = 1) { + analytics.logScreenEvent( + CourseAnalyticsEvent.DASHBOARD.eventName, + any() + ) + } assert(!viewModel.refreshing.value) - assert(viewModel.dataReady.value == null) + assert(viewModel.courseAccessStatus.value == CourseAccessError.UNKNOWN) } @Test - fun `preloadCourseStructure success with internet`() = runTest { + fun `getCourseEnrollmentDetails success with internet`() = runTest { val viewModel = CourseContainerViewModel( "", "", "", config, interactor, - calendarManager, resourceManager, - notifier, + courseNotifier, networkConnection, corePreferences, - coursePreferences, analytics, imageProcessor, + calendarSyncScheduler, courseRouter ) every { networkConnection.isOnline() } returns true - coEvery { interactor.preloadCourseStructure(any()) } returns Unit - every { interactor.getCourseStructureFromCache() } returns courseStructure - every { analytics.logEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } returns Unit - viewModel.preloadCourseStructure() + coEvery { interactor.getCourseStructure(any(), any()) } returns courseStructure + coEvery { interactor.getEnrollmentDetails(any()) } returns enrollmentDetails + every { + analytics.logScreenEvent( + CourseAnalyticsEvent.DASHBOARD.eventName, + any() + ) + } returns Unit + viewModel.fetchCourseDetails() advanceUntilIdle() - coVerify(exactly = 1) { interactor.preloadCourseStructure(any()) } - verify(exactly = 1) { analytics.logEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } - + coVerify(exactly = 1) { interactor.getEnrollmentDetails(any()) } + verify(exactly = 1) { + analytics.logScreenEvent( + CourseAnalyticsEvent.DASHBOARD.eventName, + any() + ) + } assert(viewModel.errorMessage.value == null) assert(!viewModel.refreshing.value) - assert(viewModel.dataReady.value != null) + assert(viewModel.courseAccessStatus.value != null) } @Test - fun `preloadCourseStructure success without internet`() = runTest { + fun `getCourseEnrollmentDetails success without internet`() = runTest { val viewModel = CourseContainerViewModel( "", "", "", config, interactor, - calendarManager, resourceManager, - notifier, + courseNotifier, networkConnection, corePreferences, - coursePreferences, analytics, imageProcessor, + calendarSyncScheduler, courseRouter ) every { networkConnection.isOnline() } returns false - coEvery { interactor.preloadCourseStructureFromCache(any()) } returns Unit - every { interactor.getCourseStructureFromCache() } returns courseStructure - every { analytics.logEvent(any(), any()) } returns Unit - viewModel.preloadCourseStructure() + coEvery { interactor.getEnrollmentDetails(any()) } returns enrollmentDetails + every { + analytics.logScreenEvent( + CourseAnalyticsEvent.DASHBOARD.eventName, + any() + ) + } returns Unit + viewModel.fetchCourseDetails() advanceUntilIdle() - - coVerify(exactly = 0) { interactor.preloadCourseStructure(any()) } - coVerify(exactly = 1) { interactor.preloadCourseStructureFromCache(any()) } - verify(exactly = 1) { analytics.logEvent(any(), any()) } + coVerify(exactly = 0) { courseApi.getEnrollmentDetails(any()) } + verify(exactly = 1) { + analytics.logScreenEvent( + CourseAnalyticsEvent.DASHBOARD.eventName, + any() + ) + } assert(viewModel.errorMessage.value == null) assert(!viewModel.refreshing.value) - assert(viewModel.dataReady.value != null) + assert(viewModel.courseAccessStatus.value != null) } @Test @@ -269,22 +362,21 @@ class CourseContainerViewModelTest { "", config, interactor, - calendarManager, resourceManager, - notifier, + courseNotifier, networkConnection, corePreferences, - coursePreferences, analytics, imageProcessor, + calendarSyncScheduler, courseRouter ) - coEvery { interactor.preloadCourseStructure(any()) } throws UnknownHostException() - coEvery { notifier.send(CourseStructureUpdated("")) } returns Unit + coEvery { interactor.getCourseStructure(any(), true) } throws UnknownHostException() + coEvery { courseNotifier.send(CourseStructureUpdated("")) } returns Unit viewModel.updateData() advanceUntilIdle() - coVerify(exactly = 1) { interactor.preloadCourseStructure(any()) } + coVerify(exactly = 1) { interactor.getCourseStructure(any(), true) } val message = viewModel.errorMessage.value assertEquals(noInternet, message) @@ -299,22 +391,21 @@ class CourseContainerViewModelTest { "", config, interactor, - calendarManager, resourceManager, - notifier, + courseNotifier, networkConnection, corePreferences, - coursePreferences, analytics, imageProcessor, + calendarSyncScheduler, courseRouter ) - coEvery { interactor.preloadCourseStructure(any()) } throws Exception() - coEvery { notifier.send(CourseStructureUpdated("")) } returns Unit + coEvery { interactor.getCourseStructure(any(), true) } throws Exception() + coEvery { courseNotifier.send(CourseStructureUpdated("")) } returns Unit viewModel.updateData() advanceUntilIdle() - coVerify(exactly = 1) { interactor.preloadCourseStructure(any()) } + coVerify(exactly = 1) { interactor.getCourseStructure(any(), true) } val message = viewModel.errorMessage.value assertEquals(somethingWrong, message) @@ -329,22 +420,22 @@ class CourseContainerViewModelTest { "", config, interactor, - calendarManager, resourceManager, - notifier, + courseNotifier, networkConnection, corePreferences, - coursePreferences, analytics, imageProcessor, + calendarSyncScheduler, courseRouter ) - coEvery { interactor.preloadCourseStructure(any()) } returns Unit - coEvery { notifier.send(CourseStructureUpdated("")) } returns Unit + coEvery { interactor.getEnrollmentDetails(any()) } returns courseDetails + coEvery { interactor.getCourseStructure(any(), true) } returns courseStructure + coEvery { courseNotifier.send(CourseStructureUpdated("")) } returns Unit viewModel.updateData() advanceUntilIdle() - coVerify(exactly = 1) { interactor.preloadCourseStructure(any()) } + coVerify(exactly = 1) { interactor.getCourseStructure(any(), true) } assert(viewModel.errorMessage.value == null) assert(!viewModel.refreshing.value) diff --git a/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt index 40a2d41c0..a8d4466dd 100644 --- a/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt @@ -23,13 +23,15 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule +import org.openedx.core.CalendarRouter import org.openedx.core.R -import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.data.model.DateType import org.openedx.core.data.model.User import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.interactor.CalendarInteractor import org.openedx.core.domain.model.AppConfig +import org.openedx.core.domain.model.CourseCalendarState import org.openedx.core.domain.model.CourseDateBlock import org.openedx.core.domain.model.CourseDatesBannerInfo import org.openedx.core.domain.model.CourseDatesCalendarSync @@ -37,14 +39,17 @@ import org.openedx.core.domain.model.CourseDatesResult import org.openedx.core.domain.model.CourseStructure import org.openedx.core.domain.model.CoursewareAccess import org.openedx.core.domain.model.DatesSection -import org.openedx.core.system.ResourceManager import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEvent -import org.openedx.core.system.notifier.CourseDataReady import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier +import org.openedx.core.system.notifier.calendar.CalendarEvent +import org.openedx.core.system.notifier.calendar.CalendarNotifier +import org.openedx.core.system.notifier.calendar.CalendarSynced import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics -import org.openedx.course.presentation.calendarsync.CalendarManager +import org.openedx.course.presentation.CourseRouter +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException import java.util.Date @@ -58,13 +63,16 @@ class CourseDatesViewModelTest { private val resourceManager = mockk() private val notifier = mockk() private val interactor = mockk() - private val calendarManager = mockk() private val corePreferences = mockk() private val analytics = mockk() private val config = mockk() + private val courseRouter = mockk() + private val calendarRouter = mockk() + private val calendarNotifier = mockk() + private val calendarInteractor = mockk() + private val preferencesManager = mockk() private val openEdx = "OpenEdx" - private val calendarTitle = "OpenEdx - Abc" private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" @@ -135,6 +143,7 @@ class CourseDatesViewModelTest { media = null, certificate = null, isSelfPaced = true, + progress = null ) @Before @@ -143,15 +152,20 @@ class CourseDatesViewModelTest { every { resourceManager.getString(id = R.string.platform_name) } returns openEdx every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong - every { interactor.getCourseStructureFromCache() } returns courseStructure + coEvery { interactor.getCourseStructure(any()) } returns courseStructure every { corePreferences.user } returns user every { corePreferences.appConfig } returns appConfig - every { notifier.notifier } returns flowOf(CourseDataReady(courseStructure)) - every { calendarManager.getCourseCalendarTitle(any()) } returns calendarTitle - every { calendarManager.isCalendarExists(any()) } returns true + every { notifier.notifier } returns flowOf(CourseLoading(false)) coEvery { notifier.send(any()) } returns Unit coEvery { notifier.send(any()) } returns Unit - coEvery { notifier.send(any()) } returns Unit + every { calendarNotifier.notifier } returns flowOf(CalendarSynced) + coEvery { calendarNotifier.send(any()) } returns Unit + every { preferencesManager.isRelativeDatesEnabled } returns true + coEvery { calendarInteractor.getCourseCalendarStateByIdFromCache(any()) } returns CourseCalendarState( + 0, + "", + true + ) } @After @@ -162,14 +176,18 @@ class CourseDatesViewModelTest { @Test fun `getCourseDates no internet connection exception`() = runTest(UnconfinedTestDispatcher()) { val viewModel = CourseDatesViewModel( + "id", "", notifier, interactor, - calendarManager, resourceManager, - corePreferences, analytics, - config + config, + calendarInteractor, + calendarNotifier, + preferencesManager, + courseRouter, + calendarRouter, ) coEvery { interactor.getCourseDates(any()) } throws UnknownHostException() val message = async { @@ -179,23 +197,27 @@ class CourseDatesViewModelTest { } advanceUntilIdle() - coVerify(exactly = 1) { interactor.getCourseDates(any()) } + coVerify(exactly = 1) { interactor.getCourseDates(any()) } Assert.assertEquals(noInternet, message.await()?.message) - assert(viewModel.uiState.value is DatesUIState.Loading) + assert(viewModel.uiState.value is CourseDatesUIState.Error) } @Test - fun `getCourseDates unknown exception`() = runTest(UnconfinedTestDispatcher()) { + fun `getCourseDates unknown exception`() = runTest(UnconfinedTestDispatcher()) { val viewModel = CourseDatesViewModel( + "id", "", notifier, interactor, - calendarManager, resourceManager, - corePreferences, analytics, - config + config, + calendarInteractor, + calendarNotifier, + preferencesManager, + courseRouter, + calendarRouter, ) coEvery { interactor.getCourseDates(any()) } throws Exception() val message = async { @@ -207,21 +229,25 @@ class CourseDatesViewModelTest { coVerify(exactly = 1) { interactor.getCourseDates(any()) } - Assert.assertEquals(somethingWrong, message.await()?.message) - assert(viewModel.uiState.value is DatesUIState.Loading) + assert(message.await()?.message.isNullOrEmpty()) + assert(viewModel.uiState.value is CourseDatesUIState.Error) } @Test fun `getCourseDates success with internet`() = runTest(UnconfinedTestDispatcher()) { val viewModel = CourseDatesViewModel( + "id", "", notifier, interactor, - calendarManager, resourceManager, - corePreferences, analytics, - config + config, + calendarInteractor, + calendarNotifier, + preferencesManager, + courseRouter, + calendarRouter, ) coEvery { interactor.getCourseDates(any()) } returns mockedCourseDatesResult val message = async { @@ -234,20 +260,24 @@ class CourseDatesViewModelTest { coVerify(exactly = 1) { interactor.getCourseDates(any()) } assert(message.await()?.message.isNullOrEmpty()) - assert(viewModel.uiState.value is DatesUIState.Dates) + assert(viewModel.uiState.value is CourseDatesUIState.CourseDates) } @Test fun `getCourseDates success with EmptyList`() = runTest(UnconfinedTestDispatcher()) { val viewModel = CourseDatesViewModel( + "id", "", notifier, interactor, - calendarManager, resourceManager, - corePreferences, analytics, - config + config, + calendarInteractor, + calendarNotifier, + preferencesManager, + courseRouter, + calendarRouter, ) coEvery { interactor.getCourseDates(any()) } returns CourseDatesResult( datesSection = linkedMapOf(), @@ -263,6 +293,6 @@ class CourseDatesViewModelTest { coVerify(exactly = 1) { interactor.getCourseDates(any()) } assert(message.await()?.message.isNullOrEmpty()) - assert(viewModel.uiState.value is DatesUIState.Empty) + assert(viewModel.uiState.value is CourseDatesUIState.Error) } } diff --git a/course/src/test/java/org/openedx/course/presentation/handouts/HandoutsViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/handouts/HandoutsViewModelTest.kt index 6e8d2dab2..41074294a 100644 --- a/course/src/test/java/org/openedx/course/presentation/handouts/HandoutsViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/handouts/HandoutsViewModelTest.kt @@ -5,22 +5,24 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk -import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.* +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain import org.junit.After -import org.junit.Assert.* import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule import org.openedx.core.config.Config -import org.openedx.core.domain.model.* +import org.openedx.core.domain.model.AnnouncementModel +import org.openedx.core.domain.model.HandoutsModel import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics import java.net.UnknownHostException -import java.util.* @OptIn(ExperimentalCoroutinesApi::class) class HandoutsViewModelTest { @@ -57,7 +59,7 @@ class HandoutsViewModelTest { coEvery { interactor.getHandouts(any()) } throws UnknownHostException() advanceUntilIdle() - assert(viewModel.htmlContent.value == null) + assert(viewModel.uiState.value == HandoutsUIState.Error) } @Test @@ -66,7 +68,7 @@ class HandoutsViewModelTest { coEvery { interactor.getHandouts(any()) } throws Exception() advanceUntilIdle() - assert(viewModel.htmlContent.value == null) + assert(viewModel.uiState.value == HandoutsUIState.Error) } @Test @@ -79,7 +81,7 @@ class HandoutsViewModelTest { coVerify(exactly = 1) { interactor.getHandouts(any()) } coVerify(exactly = 0) { interactor.getAnnouncements(any()) } - assert(viewModel.htmlContent.value != null) + assert(viewModel.uiState.value is HandoutsUIState.HTMLContent) } @Test @@ -97,7 +99,7 @@ class HandoutsViewModelTest { coVerify(exactly = 0) { interactor.getHandouts(any()) } coVerify(exactly = 1) { interactor.getAnnouncements(any()) } - assert(viewModel.htmlContent.value != null) + assert(viewModel.uiState.value is HandoutsUIState.HTMLContent) } @Test @@ -111,7 +113,7 @@ class HandoutsViewModelTest { ) ) viewModel.injectDarkMode( - viewModel.htmlContent.value.toString(), + viewModel.uiState.value.toString(), ULong.MAX_VALUE, ULong.MAX_VALUE ) @@ -119,6 +121,6 @@ class HandoutsViewModelTest { coVerify(exactly = 0) { interactor.getHandouts(any()) } coVerify(exactly = 1) { interactor.getAnnouncements(any()) } - assert(viewModel.htmlContent.value != null) + assert(viewModel.uiState.value is HandoutsUIState.HTMLContent) } } diff --git a/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt index 098960a2a..58574b5bd 100644 --- a/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt @@ -29,10 +29,10 @@ import org.junit.Test import org.junit.rules.TestRule import org.openedx.core.BlockType import org.openedx.core.R -import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.data.model.DateType import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts import org.openedx.core.domain.model.CourseComponentStatus @@ -48,14 +48,19 @@ import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadModelEntity import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.db.FileType +import org.openedx.core.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.presentation.CoreAnalyticsEvent -import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseStructureUpdated import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics +import org.openedx.course.presentation.CourseRouter +import org.openedx.course.presentation.download.DownloadDialogManager +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager +import org.openedx.foundation.utils.FileUtil import java.net.UnknownHostException import java.util.Date @@ -77,11 +82,21 @@ class CourseOutlineViewModelTest { private val workerController = mockk() private val analytics = mockk() private val coreAnalytics = mockk() + private val courseRouter = mockk() + private val fileUtil = mockk() + private val downloadDialogManager = mockk() + private val downloadHelper = mockk() private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" private val cantDownload = "You can download content only from Wi-fi" + private val assignmentProgress = AssignmentProgress( + assignmentType = "Homework", + numPointsEarned = 1f, + numPointsPossible = 3f + ) + private val blocks = listOf( Block( id = "id", @@ -97,7 +112,10 @@ class CourseOutlineViewModelTest { blockCounts = BlockCounts(0), descendants = listOf("1", "id1"), descendantsType = BlockType.HTML, - completion = 0.0 + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date(), + offlineDownload = null, ), Block( id = "id1", @@ -113,7 +131,10 @@ class CourseOutlineViewModelTest { blockCounts = BlockCounts(0), descendants = listOf("id2"), descendantsType = BlockType.HTML, - completion = 0.0 + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date(), + offlineDownload = null, ), Block( id = "id2", @@ -129,7 +150,10 @@ class CourseOutlineViewModelTest { blockCounts = BlockCounts(0), descendants = emptyList(), descendantsType = BlockType.HTML, - completion = 0.0 + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date(), + offlineDownload = null, ) ) @@ -154,7 +178,8 @@ class CourseOutlineViewModelTest { ), media = null, certificate = null, - isSelfPaced = false + isSelfPaced = false, + progress = null ) private val dateBlock = CourseDateBlock( @@ -192,6 +217,7 @@ class CourseOutlineViewModelTest { private val downloadModel = DownloadModel( "id", "title", + "", 0, "", "url", @@ -207,6 +233,8 @@ class CourseOutlineViewModelTest { every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong every { resourceManager.getString(org.openedx.course.R.string.course_can_download_only_with_wifi) } returns cantDownload every { config.getApiHostURL() } returns "http://localhost:8000" + every { downloadDialogManager.showDownloadFailedPopup(any(), any()) } returns Unit + every { preferencesManager.isRelativeDatesEnabled } returns true coEvery { interactor.getCourseDates(any()) } returns mockedCourseDatesResult } @@ -218,9 +246,10 @@ class CourseOutlineViewModelTest { @Test fun `getCourseDataInternal no internet connection exception`() = runTest(UnconfinedTestDispatcher()) { - every { interactor.getCourseStructureFromCache() } returns courseStructure + coEvery { interactor.getCourseStructure(any()) } returns courseStructure every { networkConnection.isOnline() } returns true - every { downloadDao.readAllData() } returns flow { emit(emptyList()) } + every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } + every { downloadDialogManager.showPopup(any(), any(), any(), any(), any(), any(), any()) } returns Unit coEvery { interactor.getCourseStatus(any()) } throws UnknownHostException() val viewModel = CourseOutlineViewModel( @@ -233,9 +262,13 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + downloadDialogManager, + fileUtil, + courseRouter, coreAnalytics, downloadDao, workerController, + downloadHelper, ) val message = async { @@ -244,18 +277,18 @@ class CourseOutlineViewModelTest { viewModel.getCourseData() advanceUntilIdle() - verify(exactly = 1) { interactor.getCourseStructureFromCache() } - coVerify(exactly = 1) { interactor.getCourseStatus(any()) } + coVerify(exactly = 2) { interactor.getCourseStructure(any()) } + coVerify(exactly = 2) { interactor.getCourseStatus(any()) } assertEquals(noInternet, message.await()?.message) - assert(viewModel.uiState.value is CourseOutlineUIState.Loading) + assert(viewModel.uiState.value is CourseOutlineUIState.Error) } @Test fun `getCourseDataInternal unknown exception`() = runTest(UnconfinedTestDispatcher()) { - every { interactor.getCourseStructureFromCache() } returns courseStructure + coEvery { interactor.getCourseStructure(any()) } returns courseStructure every { networkConnection.isOnline() } returns true - every { downloadDao.readAllData() } returns flow { emit(emptyList()) } + every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } coEvery { interactor.getCourseStatus(any()) } throws Exception() val viewModel = CourseOutlineViewModel( "", @@ -267,9 +300,13 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + downloadDialogManager, + fileUtil, + courseRouter, coreAnalytics, downloadDao, - workerController + workerController, + downloadHelper, ) val message = async { @@ -278,18 +315,18 @@ class CourseOutlineViewModelTest { viewModel.getCourseData() advanceUntilIdle() - verify(exactly = 1) { interactor.getCourseStructureFromCache() } - coVerify(exactly = 1) { interactor.getCourseStatus(any()) } + coVerify(exactly = 2) { interactor.getCourseStructure(any()) } + coVerify(exactly = 2) { interactor.getCourseStatus(any()) } assertEquals(somethingWrong, message.await()?.message) - assert(viewModel.uiState.value is CourseOutlineUIState.Loading) + assert(viewModel.uiState.value is CourseOutlineUIState.Error) } @Test fun `getCourseDataInternal success with internet connection`() = runTest(UnconfinedTestDispatcher()) { - every { interactor.getCourseStructureFromCache() } returns courseStructure + coEvery { interactor.getCourseStructure(any()) } returns courseStructure every { networkConnection.isOnline() } returns true - coEvery { downloadDao.readAllData() } returns flow { + coEvery { downloadDao.getAllDataFlow() } returns flow { emit( listOf( DownloadModelEntity.createFrom( @@ -299,7 +336,7 @@ class CourseOutlineViewModelTest { ) } coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") - every { config.isCourseNestedListEnabled() } returns false + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false val viewModel = CourseOutlineViewModel( "", @@ -311,9 +348,13 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + downloadDialogManager, + fileUtil, + courseRouter, coreAnalytics, downloadDao, - workerController + workerController, + downloadHelper, ) val message = async { @@ -321,11 +362,12 @@ class CourseOutlineViewModelTest { viewModel.uiMessage.first() as? UIMessage.SnackBarMessage } } + viewModel.getCourseData() advanceUntilIdle() - verify(exactly = 1) { interactor.getCourseStructureFromCache() } - coVerify(exactly = 1) { interactor.getCourseStatus(any()) } + coVerify(exactly = 2) { interactor.getCourseStructure(any()) } + coVerify(exactly = 2) { interactor.getCourseStatus(any()) } assert(message.await() == null) assert(viewModel.uiState.value is CourseOutlineUIState.CourseData) @@ -333,9 +375,9 @@ class CourseOutlineViewModelTest { @Test fun `getCourseDataInternal success without internet connection`() = runTest(UnconfinedTestDispatcher()) { - every { interactor.getCourseStructureFromCache() } returns courseStructure + coEvery { interactor.getCourseStructure(any()) } returns courseStructure every { networkConnection.isOnline() } returns false - coEvery { downloadDao.readAllData() } returns flow { + coEvery { downloadDao.getAllDataFlow() } returns flow { emit( listOf( DownloadModelEntity.createFrom( @@ -345,7 +387,7 @@ class CourseOutlineViewModelTest { ) } coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") - every { config.isCourseNestedListEnabled() } returns false + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false val viewModel = CourseOutlineViewModel( "", @@ -357,9 +399,13 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + downloadDialogManager, + fileUtil, + courseRouter, coreAnalytics, downloadDao, - workerController + workerController, + downloadHelper, ) val message = async { @@ -370,7 +416,7 @@ class CourseOutlineViewModelTest { viewModel.getCourseData() advanceUntilIdle() - verify(exactly = 1) { interactor.getCourseStructureFromCache() } + coVerify(exactly = 2) { interactor.getCourseStructure(any()) } coVerify(exactly = 0) { interactor.getCourseStatus(any()) } assert(message.await() == null) @@ -379,9 +425,9 @@ class CourseOutlineViewModelTest { @Test fun `updateCourseData success with internet connection`() = runTest(UnconfinedTestDispatcher()) { - every { interactor.getCourseStructureFromCache() } returns courseStructure + coEvery { interactor.getCourseStructure(any()) } returns courseStructure every { networkConnection.isOnline() } returns true - coEvery { downloadDao.readAllData() } returns flow { + coEvery { downloadDao.getAllDataFlow() } returns flow { emit( listOf( DownloadModelEntity.createFrom( @@ -391,7 +437,7 @@ class CourseOutlineViewModelTest { ) } coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") - every { config.isCourseNestedListEnabled() } returns false + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false val viewModel = CourseOutlineViewModel( "", @@ -403,9 +449,13 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + downloadDialogManager, + fileUtil, + courseRouter, coreAnalytics, downloadDao, - workerController + workerController, + downloadHelper, ) val message = async { @@ -414,10 +464,9 @@ class CourseOutlineViewModelTest { } } viewModel.getCourseData() - viewModel.updateCourseData() advanceUntilIdle() - coVerify(exactly = 2) { interactor.getCourseStructureFromCache() } + coVerify(exactly = 2) { interactor.getCourseStructure(any()) } coVerify(exactly = 2) { interactor.getCourseStatus(any()) } assert(message.await() == null) @@ -426,7 +475,7 @@ class CourseOutlineViewModelTest { @Test fun `CourseStructureUpdated notifier test`() = runTest(UnconfinedTestDispatcher()) { - coEvery { downloadDao.readAllData() } returns flow { emit(emptyList()) } + coEvery { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } val viewModel = CourseOutlineViewModel( "", "", @@ -437,12 +486,16 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + downloadDialogManager, + fileUtil, + courseRouter, coreAnalytics, downloadDao, - workerController + workerController, + downloadHelper, ) coEvery { notifier.notifier } returns flow { emit(CourseStructureUpdated("")) } - every { interactor.getCourseStructureFromCache() } returns courseStructure + coEvery { interactor.getCourseStructure(any()) } returns courseStructure every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") @@ -454,14 +507,14 @@ class CourseOutlineViewModelTest { viewModel.getCourseData() advanceUntilIdle() - coVerify(exactly = 1) { interactor.getCourseStructureFromCache() } + coVerify(exactly = 2) { interactor.getCourseStructure(any()) } coVerify(exactly = 1) { interactor.getCourseStatus(any()) } } @Test fun `saveDownloadModels test`() = runTest(UnconfinedTestDispatcher()) { every { preferencesManager.videoSettings.wifiDownloadOnly } returns false - every { interactor.getCourseStructureFromCache() } returns courseStructure + coEvery { interactor.getCourseStructure(any()) } returns courseStructure every { networkConnection.isWifiConnected() } returns true every { networkConnection.isOnline() } returns true every { @@ -472,8 +525,8 @@ class CourseOutlineViewModelTest { } returns Unit coEvery { workerController.saveModels(any()) } returns Unit coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") - coEvery { downloadDao.readAllData() } returns flow { emit(emptyList()) } - every { config.isCourseNestedListEnabled() } returns false + coEvery { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false val viewModel = CourseOutlineViewModel( "", @@ -485,9 +538,13 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + downloadDialogManager, + fileUtil, + courseRouter, coreAnalytics, downloadDao, - workerController + workerController, + downloadHelper, ) val message = async { withTimeoutOrNull(5000) { @@ -508,14 +565,14 @@ class CourseOutlineViewModelTest { @Test fun `saveDownloadModels only wifi download, with connection`() = runTest(UnconfinedTestDispatcher()) { - every { interactor.getCourseStructureFromCache() } returns courseStructure + coEvery { interactor.getCourseStructure(any()) } returns courseStructure coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") every { preferencesManager.videoSettings.wifiDownloadOnly } returns true every { networkConnection.isWifiConnected() } returns true every { networkConnection.isOnline() } returns true coEvery { workerController.saveModels(any()) } returns Unit - coEvery { downloadDao.readAllData() } returns flow { emit(emptyList()) } - every { config.isCourseNestedListEnabled() } returns false + coEvery { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false every { coreAnalytics.logEvent(any(), any()) } returns Unit val viewModel = CourseOutlineViewModel( @@ -528,44 +585,13 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + downloadDialogManager, + fileUtil, + courseRouter, coreAnalytics, downloadDao, - workerController - ) - val message = async { - withTimeoutOrNull(5000) { - viewModel.uiMessage.first() as? UIMessage.SnackBarMessage - } - } - viewModel.saveDownloadModels("", "") - advanceUntilIdle() - - assert(message.await()?.message.isNullOrEmpty()) - } - - @Test - fun `saveDownloadModels only wifi download, without connection`() = runTest(UnconfinedTestDispatcher()) { - every { interactor.getCourseStructureFromCache() } returns courseStructure - every { preferencesManager.videoSettings.wifiDownloadOnly } returns true - every { networkConnection.isWifiConnected() } returns false - every { networkConnection.isOnline() } returns false - coEvery { workerController.saveModels(any()) } returns Unit - coEvery { downloadDao.readAllData() } returns flow { emit(emptyList()) } - every { config.isCourseNestedListEnabled() } returns false - - val viewModel = CourseOutlineViewModel( - "", - "", - config, - interactor, - resourceManager, - notifier, - networkConnection, - preferencesManager, - analytics, - coreAnalytics, - downloadDao, - workerController + workerController, + downloadHelper, ) val message = async { withTimeoutOrNull(5000) { @@ -573,7 +599,6 @@ class CourseOutlineViewModelTest { } } viewModel.saveDownloadModels("", "") - advanceUntilIdle() assert(message.await()?.message.isNullOrEmpty()) diff --git a/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt index ba6aa779c..e1c6a98ca 100644 --- a/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt @@ -25,8 +25,8 @@ import org.junit.Test import org.junit.rules.TestRule import org.openedx.core.BlockType import org.openedx.core.R -import org.openedx.core.UIMessage import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts import org.openedx.core.domain.model.CourseStructure @@ -37,13 +37,15 @@ import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadModelEntity import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.db.FileType +import org.openedx.core.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.presentation.course.CourseViewMode -import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException import java.util.Date @@ -64,11 +66,17 @@ class CourseSectionViewModelTest { private val notifier = mockk() private val analytics = mockk() private val coreAnalytics = mockk() + private val downloadHelper = mockk() private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" private val cantDownload = "You can download content only from Wi-fi" + private val assignmentProgress = AssignmentProgress( + assignmentType = "Homework", + numPointsEarned = 1f, + numPointsPossible = 3f + ) private val blocks = listOf( Block( @@ -85,7 +93,10 @@ class CourseSectionViewModelTest { blockCounts = BlockCounts(0), descendants = listOf("1", "id1"), descendantsType = BlockType.HTML, - completion = 0.0 + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date(), + offlineDownload = null, ), Block( id = "id1", @@ -101,7 +112,10 @@ class CourseSectionViewModelTest { blockCounts = BlockCounts(0), descendants = listOf("id2"), descendantsType = BlockType.HTML, - completion = 0.0 + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date(), + offlineDownload = null, ), Block( id = "id2", @@ -117,7 +131,10 @@ class CourseSectionViewModelTest { blockCounts = BlockCounts(0), descendants = emptyList(), descendantsType = BlockType.HTML, - completion = 0.0 + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date(), + offlineDownload = null, ) ) @@ -142,12 +159,14 @@ class CourseSectionViewModelTest { ), media = null, certificate = null, - isSelfPaced = false + isSelfPaced = false, + progress = null ) private val downloadModel = DownloadModel( "id", "title", + "", 0, "", "url", @@ -171,28 +190,23 @@ class CourseSectionViewModelTest { @Test fun `getBlocks no internet connection exception`() = runTest { - every { downloadDao.readAllData() } returns flow { emit(emptyList()) } + every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } val viewModel = CourseSectionViewModel( "", interactor, resourceManager, - networkConnection, - preferencesManager, notifier, analytics, - coreAnalytics, - workerController, - downloadDao, ) - coEvery { interactor.getCourseStructureFromCache() } throws UnknownHostException() - coEvery { interactor.getCourseStructureForVideos() } throws UnknownHostException() + coEvery { interactor.getCourseStructure(any()) } throws UnknownHostException() + coEvery { interactor.getCourseStructureForVideos(any()) } throws UnknownHostException() viewModel.getBlocks("", CourseViewMode.FULL) advanceUntilIdle() - coVerify(exactly = 1) { interactor.getCourseStructureFromCache() } - coVerify(exactly = 0) { interactor.getCourseStructureForVideos() } + coVerify(exactly = 1) { interactor.getCourseStructure(any()) } + coVerify(exactly = 0) { interactor.getCourseStructureForVideos(any()) } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assertEquals(noInternet, message?.message) @@ -201,28 +215,23 @@ class CourseSectionViewModelTest { @Test fun `getBlocks unknown exception`() = runTest { - every { downloadDao.readAllData() } returns flow { emit(emptyList()) } + every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } val viewModel = CourseSectionViewModel( "", interactor, resourceManager, - networkConnection, - preferencesManager, notifier, analytics, - coreAnalytics, - workerController, - downloadDao, ) - coEvery { interactor.getCourseStructureFromCache() } throws Exception() - coEvery { interactor.getCourseStructureForVideos() } throws Exception() + coEvery { interactor.getCourseStructure(any()) } throws Exception() + coEvery { interactor.getCourseStructureForVideos(any()) } throws Exception() viewModel.getBlocks("id2", CourseViewMode.FULL) advanceUntilIdle() - coVerify(exactly = 1) { interactor.getCourseStructureFromCache() } - coVerify(exactly = 0) { interactor.getCourseStructureForVideos() } + coVerify(exactly = 1) { interactor.getCourseStructure(any()) } + coVerify(exactly = 0) { interactor.getCourseStructureForVideos(any()) } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assertEquals(somethingWrong, message?.message) @@ -231,30 +240,28 @@ class CourseSectionViewModelTest { @Test fun `getBlocks success`() = runTest { - coEvery { downloadDao.readAllData() } returns flow { + coEvery { downloadDao.getAllDataFlow() } returns flow { emit(listOf(DownloadModelEntity.createFrom(downloadModel))) } val viewModel = CourseSectionViewModel( "", interactor, resourceManager, - networkConnection, - preferencesManager, notifier, analytics, - coreAnalytics, - workerController, - downloadDao, ) - coEvery { interactor.getCourseStructureFromCache() } returns courseStructure - coEvery { interactor.getCourseStructureForVideos() } returns courseStructure + coEvery { downloadDao.getAllDataFlow() } returns flow { + emit(listOf(DownloadModelEntity.createFrom(downloadModel))) + } + coEvery { interactor.getCourseStructure(any()) } returns courseStructure + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure viewModel.getBlocks("id", CourseViewMode.VIDEOS) advanceUntilIdle() - coVerify(exactly = 0) { interactor.getCourseStructureFromCache() } - coVerify(exactly = 1) { interactor.getCourseStructureForVideos() } + coVerify(exactly = 0) { interactor.getCourseStructure(any()) } + coVerify(exactly = 1) { interactor.getCourseStructureForVideos(any()) } assert(viewModel.uiMessage.value == null) assert(viewModel.uiState.value is CourseSectionUIState.Blocks) @@ -262,27 +269,21 @@ class CourseSectionViewModelTest { @Test fun `saveDownloadModels test`() = runTest { - coEvery { downloadDao.readAllData() } returns flow { + coEvery { downloadDao.getAllDataFlow() } returns flow { emit(listOf(DownloadModelEntity.createFrom(downloadModel))) } val viewModel = CourseSectionViewModel( "", interactor, resourceManager, - networkConnection, - preferencesManager, notifier, analytics, - coreAnalytics, - workerController, - downloadDao, ) every { preferencesManager.videoSettings.wifiDownloadOnly } returns false every { networkConnection.isWifiConnected() } returns true coEvery { workerController.saveModels(any()) } returns Unit every { coreAnalytics.logEvent(any(), any()) } returns Unit - viewModel.saveDownloadModels("", "") advanceUntilIdle() assert(viewModel.uiMessage.value == null) @@ -290,63 +291,29 @@ class CourseSectionViewModelTest { @Test fun `saveDownloadModels only wifi download, with connection`() = runTest { - coEvery { downloadDao.readAllData() } returns flow { + coEvery { downloadDao.getAllDataFlow() } returns flow { emit(listOf(DownloadModelEntity.createFrom(downloadModel))) } val viewModel = CourseSectionViewModel( "", interactor, resourceManager, - networkConnection, - preferencesManager, notifier, analytics, - coreAnalytics, - workerController, - downloadDao, ) every { preferencesManager.videoSettings.wifiDownloadOnly } returns true every { networkConnection.isWifiConnected() } returns true coEvery { workerController.saveModels(any()) } returns Unit every { coreAnalytics.logEvent(any(), any()) } returns Unit - viewModel.saveDownloadModels("", "") advanceUntilIdle() assert(viewModel.uiMessage.value == null) } - @Test - fun `saveDownloadModels only wifi download, without connection`() = runTest { - every { downloadDao.readAllData() } returns flow { emit(emptyList()) } - val viewModel = CourseSectionViewModel( - "", - interactor, - resourceManager, - networkConnection, - preferencesManager, - notifier, - analytics, - coreAnalytics, - workerController, - downloadDao, - ) - every { preferencesManager.videoSettings.wifiDownloadOnly } returns true - every { networkConnection.isWifiConnected() } returns false - every { networkConnection.isOnline() } returns false - coEvery { workerController.saveModels(any()) } returns Unit - - viewModel.saveDownloadModels("", "") - - advanceUntilIdle() - - assert(viewModel.uiMessage.value != null) - } - - @Test fun `updateVideos success`() = runTest { - every { downloadDao.readAllData() } returns flow { + every { downloadDao.getAllDataFlow() } returns flow { repeat(5) { delay(10000) emit(emptyList()) @@ -356,18 +323,13 @@ class CourseSectionViewModelTest { "", interactor, resourceManager, - networkConnection, - preferencesManager, notifier, analytics, - coreAnalytics, - workerController, - downloadDao, ) coEvery { notifier.notifier } returns flow { } - coEvery { interactor.getCourseStructureFromCache() } returns courseStructure - coEvery { interactor.getCourseStructureForVideos() } returns courseStructure + coEvery { interactor.getCourseStructure(any()) } returns courseStructure + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure val mockLifeCycleOwner: LifecycleOwner = mockk() val lifecycleRegistry = LifecycleRegistry(mockLifeCycleOwner) @@ -378,7 +340,6 @@ class CourseSectionViewModelTest { advanceUntilIdle() assert(viewModel.uiState.value is CourseSectionUIState.Blocks) - } } diff --git a/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt index b92a02f5a..ffb1d124d 100644 --- a/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt @@ -1,13 +1,18 @@ package org.openedx.course.presentation.unit.container import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk -import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.test.* +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Before import org.junit.Rule @@ -15,11 +20,13 @@ import org.junit.Test import org.junit.rules.TestRule import org.openedx.core.BlockType import org.openedx.core.config.Config +import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts import org.openedx.core.domain.model.CourseStructure import org.openedx.core.domain.model.CoursewareAccess import org.openedx.core.presentation.course.CourseViewMode +import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics @@ -38,6 +45,13 @@ class CourseUnitContainerViewModelTest { private val interactor = mockk() private val notifier = mockk() private val analytics = mockk() + private val networkConnection = mockk() + + private val assignmentProgress = AssignmentProgress( + assignmentType = "Homework", + numPointsEarned = 1f, + numPointsPossible = 3f + ) private val blocks = listOf( Block( @@ -54,7 +68,10 @@ class CourseUnitContainerViewModelTest { blockCounts = BlockCounts(0), descendants = listOf("id2", "id1"), descendantsType = BlockType.HTML, - completion = 0.0 + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date(), + offlineDownload = null, ), Block( id = "id1", @@ -70,7 +87,10 @@ class CourseUnitContainerViewModelTest { blockCounts = BlockCounts(0), descendants = listOf("id2", "id"), descendantsType = BlockType.HTML, - completion = 0.0 + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date(), + offlineDownload = null, ), Block( id = "id2", @@ -86,7 +106,10 @@ class CourseUnitContainerViewModelTest { blockCounts = BlockCounts(0), descendants = emptyList(), descendantsType = BlockType.HTML, - completion = 0.0 + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date(), + offlineDownload = null, ), Block( id = "id3", @@ -102,7 +125,10 @@ class CourseUnitContainerViewModelTest { blockCounts = BlockCounts(0), descendants = emptyList(), descendantsType = BlockType.HTML, - completion = 0.0 + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date(), + offlineDownload = null, ) ) @@ -128,7 +154,8 @@ class CourseUnitContainerViewModelTest { ), media = null, certificate = null, - isSelfPaced = false + isSelfPaced = false, + progress = null ) @Before @@ -145,161 +172,163 @@ class CourseUnitContainerViewModelTest { fun `getBlocks no internet connection exception`() = runTest { every { notifier.notifier } returns MutableSharedFlow() val viewModel = - CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics) + CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics, networkConnection) - every { interactor.getCourseStructureFromCache() } throws UnknownHostException() - every { interactor.getCourseStructureForVideos() } throws UnknownHostException() + coEvery { interactor.getCourseStructure(any()) } throws UnknownHostException() + coEvery { interactor.getCourseStructureForVideos(any()) } throws UnknownHostException() viewModel.loadBlocks(CourseViewMode.FULL) advanceUntilIdle() - verify(exactly = 1) { interactor.getCourseStructureFromCache() } + coVerify(exactly = 1) { interactor.getCourseStructure(any()) } } @Test fun `getBlocks unknown exception`() = runTest { every { notifier.notifier } returns MutableSharedFlow() - val viewModel = CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics) + val viewModel = + CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics, networkConnection) - every { interactor.getCourseStructureFromCache() } throws UnknownHostException() - every { interactor.getCourseStructureForVideos() } throws UnknownHostException() + coEvery { interactor.getCourseStructure(any()) } throws UnknownHostException() + coEvery { interactor.getCourseStructureForVideos(any()) } throws UnknownHostException() viewModel.loadBlocks(CourseViewMode.FULL) advanceUntilIdle() - verify(exactly = 1) { interactor.getCourseStructureFromCache() } + coVerify(exactly = 1) { interactor.getCourseStructure(any()) } } @Test fun `getBlocks unknown success`() = runTest { every { notifier.notifier } returns MutableSharedFlow() - val viewModel = CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics) + val viewModel = + CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics, networkConnection) - every { interactor.getCourseStructureFromCache() } returns courseStructure - every { interactor.getCourseStructureForVideos() } returns courseStructure + coEvery { interactor.getCourseStructure(any()) } returns courseStructure + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure viewModel.loadBlocks(CourseViewMode.VIDEOS) advanceUntilIdle() - verify(exactly = 0) { interactor.getCourseStructureFromCache() } - verify(exactly = 1) { interactor.getCourseStructureForVideos() } + coVerify(exactly = 0) { interactor.getCourseStructure(any()) } + coVerify(exactly = 1) { interactor.getCourseStructureForVideos(any()) } } @Test fun setupCurrentIndex() = runTest { every { notifier.notifier } returns MutableSharedFlow() - val viewModel = CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics) - every { interactor.getCourseStructureFromCache() } returns courseStructure - every { interactor.getCourseStructureForVideos() } returns courseStructure + val viewModel = + CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics, networkConnection) + coEvery { interactor.getCourseStructure(any()) } returns courseStructure + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - viewModel.loadBlocks(CourseViewMode.VIDEOS) - viewModel.setupCurrentIndex("id") + viewModel.loadBlocks(CourseViewMode.VIDEOS, "id") advanceUntilIdle() - verify(exactly = 0) { interactor.getCourseStructureFromCache() } - verify(exactly = 1) { interactor.getCourseStructureForVideos() } + coVerify(exactly = 0) { interactor.getCourseStructure(any()) } + coVerify(exactly = 1) { interactor.getCourseStructureForVideos(any()) } } @Test fun `getCurrentBlock test`() = runTest { every { notifier.notifier } returns MutableSharedFlow() - val viewModel = CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics) - every { interactor.getCourseStructureFromCache() } returns courseStructure - every { interactor.getCourseStructureForVideos() } returns courseStructure + val viewModel = + CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics, networkConnection) + coEvery { interactor.getCourseStructure(any()) } returns courseStructure + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - viewModel.loadBlocks(CourseViewMode.VIDEOS) - viewModel.setupCurrentIndex("id") + viewModel.loadBlocks(CourseViewMode.VIDEOS, "id") advanceUntilIdle() - verify(exactly = 0) { interactor.getCourseStructureFromCache() } - verify(exactly = 1) { interactor.getCourseStructureForVideos() } + coVerify(exactly = 0) { interactor.getCourseStructure("") } + coVerify(exactly = 1) { interactor.getCourseStructureForVideos("") } assert(viewModel.getCurrentBlock().id == "id") } @Test fun `moveToPrevBlock null`() = runTest { every { notifier.notifier } returns MutableSharedFlow() - val viewModel = CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics) - every { interactor.getCourseStructureFromCache() } returns courseStructure - every { interactor.getCourseStructureForVideos() } returns courseStructure + val viewModel = + CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics, networkConnection) + coEvery { interactor.getCourseStructure(any()) } returns courseStructure + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - viewModel.loadBlocks(CourseViewMode.VIDEOS) - viewModel.setupCurrentIndex("id") + viewModel.loadBlocks(CourseViewMode.VIDEOS, "id") advanceUntilIdle() - verify(exactly = 0) { interactor.getCourseStructureFromCache() } - verify(exactly = 1) { interactor.getCourseStructureForVideos() } + coVerify(exactly = 0) { interactor.getCourseStructure("") } + coVerify(exactly = 1) { interactor.getCourseStructureForVideos("") } assert(viewModel.moveToPrevBlock() == null) } @Test fun `moveToPrevBlock not null`() = runTest { every { notifier.notifier } returns MutableSharedFlow() - val viewModel = CourseUnitContainerViewModel("", "id", config, interactor, notifier, analytics) - every { interactor.getCourseStructureFromCache() } returns courseStructure - every { interactor.getCourseStructureForVideos() } returns courseStructure + val viewModel = + CourseUnitContainerViewModel("", "id", config, interactor, notifier, analytics, networkConnection) + coEvery { interactor.getCourseStructure(any()) } returns courseStructure + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - viewModel.loadBlocks(CourseViewMode.VIDEOS) - viewModel.setupCurrentIndex("id1") + viewModel.loadBlocks(CourseViewMode.VIDEOS, "id1") advanceUntilIdle() - verify(exactly = 0) { interactor.getCourseStructureFromCache() } - verify(exactly = 1) { interactor.getCourseStructureForVideos() } + coVerify(exactly = 0) { interactor.getCourseStructure("") } + coVerify(exactly = 1) { interactor.getCourseStructureForVideos("") } assert(viewModel.moveToPrevBlock() != null) } @Test fun `moveToNextBlock null`() = runTest { every { notifier.notifier } returns MutableSharedFlow() - val viewModel = CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics) - every { interactor.getCourseStructureFromCache() } returns courseStructure - every { interactor.getCourseStructureForVideos() } returns courseStructure + val viewModel = + CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics, networkConnection) + coEvery { interactor.getCourseStructure(any()) } returns courseStructure + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - viewModel.loadBlocks(CourseViewMode.VIDEOS) - viewModel.setupCurrentIndex("id3") + viewModel.loadBlocks(CourseViewMode.VIDEOS, "id3") advanceUntilIdle() - verify(exactly = 0) { interactor.getCourseStructureFromCache() } - verify(exactly = 1) { interactor.getCourseStructureForVideos() } + coVerify(exactly = 0) { interactor.getCourseStructure("") } + coVerify(exactly = 1) { interactor.getCourseStructureForVideos("") } assert(viewModel.moveToNextBlock() == null) } @Test fun `moveToNextBlock not null`() = runTest { every { notifier.notifier } returns MutableSharedFlow() - val viewModel = CourseUnitContainerViewModel("", "id", config, interactor, notifier, analytics) - every { interactor.getCourseStructureFromCache() } returns courseStructure - every { interactor.getCourseStructureForVideos() } returns courseStructure + val viewModel = + CourseUnitContainerViewModel("", "id", config, interactor, notifier, analytics, networkConnection) + coEvery { interactor.getCourseStructure("") } returns courseStructure + coEvery { interactor.getCourseStructureForVideos("") } returns courseStructure - viewModel.loadBlocks(CourseViewMode.VIDEOS) - viewModel.setupCurrentIndex("id") + viewModel.loadBlocks(CourseViewMode.VIDEOS, "id") advanceUntilIdle() - verify(exactly = 0) { interactor.getCourseStructureFromCache() } - verify(exactly = 1) { interactor.getCourseStructureForVideos() } + coVerify(exactly = 0) { interactor.getCourseStructure("") } + coVerify(exactly = 1) { interactor.getCourseStructureForVideos("") } assert(viewModel.moveToNextBlock() != null) } @Test fun `currentIndex isLastIndex`() = runTest { every { notifier.notifier } returns MutableSharedFlow() - val viewModel = CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics) - every { interactor.getCourseStructureFromCache() } returns courseStructure - every { interactor.getCourseStructureForVideos() } returns courseStructure + val viewModel = + CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics, networkConnection) + coEvery { interactor.getCourseStructure(any()) } returns courseStructure + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - viewModel.loadBlocks(CourseViewMode.VIDEOS) - viewModel.setupCurrentIndex("id3") + viewModel.loadBlocks(CourseViewMode.VIDEOS, "id3") advanceUntilIdle() - verify(exactly = 0) { interactor.getCourseStructureFromCache() } - verify(exactly = 1) { interactor.getCourseStructureForVideos() } + coVerify(exactly = 0) { interactor.getCourseStructure(any()) } + coVerify(exactly = 1) { interactor.getCourseStructureForVideos(any()) } } -} \ No newline at end of file +} diff --git a/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt index 43d057a6c..e5df7e948 100644 --- a/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt @@ -29,9 +29,9 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule import org.openedx.core.BlockType -import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts import org.openedx.core.domain.model.CourseStructure @@ -43,16 +43,21 @@ import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadModelEntity import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.db.FileType +import org.openedx.core.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics -import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection -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 import org.openedx.course.R import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics +import org.openedx.course.presentation.CourseRouter +import org.openedx.course.presentation.download.DownloadDialogManager +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager +import org.openedx.foundation.utils.FileUtil import java.util.Date @OptIn(ExperimentalCoroutinesApi::class) @@ -73,9 +78,19 @@ class CourseVideoViewModelTest { private val networkConnection = mockk() private val downloadDao = mockk() private val workerController = mockk() + private val courseRouter = mockk() + private val downloadHelper = mockk() + private val downloadDialogManager = mockk() + private val fileUtil = mockk() private val cantDownload = "You can download content only from Wi-fi" + private val assignmentProgress = AssignmentProgress( + assignmentType = "Homework", + numPointsEarned = 1f, + numPointsPossible = 3f + ) + private val blocks = listOf( Block( id = "id", @@ -91,7 +106,10 @@ class CourseVideoViewModelTest { blockCounts = BlockCounts(0), descendants = listOf("1", "id1"), descendantsType = BlockType.HTML, - completion = 0.0 + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date(), + offlineDownload = null, ), Block( id = "id1", @@ -107,7 +125,10 @@ class CourseVideoViewModelTest { blockCounts = BlockCounts(0), descendants = listOf("id2"), descendantsType = BlockType.HTML, - completion = 0.0 + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date(), + offlineDownload = null, ), Block( id = "id2", @@ -123,7 +144,10 @@ class CourseVideoViewModelTest { blockCounts = BlockCounts(0), descendants = emptyList(), descendantsType = BlockType.HTML, - completion = 0.0 + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date(), + offlineDownload = null, ) ) @@ -148,15 +172,17 @@ class CourseVideoViewModelTest { ), media = null, certificate = null, - isSelfPaced = false + isSelfPaced = false, + progress = null ) private val downloadModelEntity = - DownloadModelEntity("", "", 1, "", "", "VIDEO", "DOWNLOADED", null) + DownloadModelEntity("", "", "", 1, "", "", "VIDEO", "DOWNLOADED", null) private val downloadModel = DownloadModel( "id", "title", + "", 0, "", "url", @@ -167,11 +193,12 @@ class CourseVideoViewModelTest { @Before fun setUp() { - every { resourceManager.getString(R.string.course_does_not_include_videos) } returns "" every { resourceManager.getString(R.string.course_can_download_only_with_wifi) } returns cantDownload Dispatchers.setMain(dispatcher) every { config.getApiHostURL() } returns "http://localhost:8000" - every { courseNotifier.notifier } returns flowOf(CourseDataReady(courseStructure)) + every { courseNotifier.notifier } returns flowOf(CourseLoading(false)) + every { preferencesManager.isRelativeDatesEnabled } returns true + every { downloadDialogManager.showPopup(any(), any(), any(), any(), any(), any(), any()) } returns Unit } @After @@ -181,9 +208,10 @@ class CourseVideoViewModelTest { @Test fun `getVideos empty list`() = runTest { - every { config.isCourseNestedListEnabled() } returns false - every { interactor.getCourseStructureForVideos() } returns courseStructure.copy(blockData = emptyList()) - every { downloadDao.readAllData() } returns flow { emit(emptyList()) } + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false + coEvery { interactor.getCourseStructureForVideos(any()) } returns + courseStructure.copy(blockData = emptyList()) + every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( "", @@ -196,24 +224,28 @@ class CourseVideoViewModelTest { courseNotifier, videoNotifier, analytics, + downloadDialogManager, + fileUtil, + courseRouter, coreAnalytics, downloadDao, - workerController + workerController, + downloadHelper, ) viewModel.getVideos() advanceUntilIdle() - coVerify(exactly = 2) { interactor.getCourseStructureForVideos() } + coVerify(exactly = 2) { interactor.getCourseStructureForVideos(any()) } assert(viewModel.uiState.value is CourseVideosUIState.Empty) } @Test fun `getVideos success`() = runTest { - every { config.isCourseNestedListEnabled() } returns false - every { interactor.getCourseStructureForVideos() } returns courseStructure - every { downloadDao.readAllData() } returns flow { emit(emptyList()) } + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure + every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( @@ -227,29 +259,32 @@ class CourseVideoViewModelTest { courseNotifier, videoNotifier, analytics, + downloadDialogManager, + fileUtil, + courseRouter, coreAnalytics, downloadDao, - workerController + workerController, + downloadHelper, ) viewModel.getVideos() advanceUntilIdle() - coVerify(exactly = 2) { interactor.getCourseStructureForVideos() } + coVerify(exactly = 2) { interactor.getCourseStructureForVideos(any()) } assert(viewModel.uiState.value is CourseVideosUIState.CourseData) } @Test fun `updateVideos success`() = runTest { - every { config.isCourseNestedListEnabled() } returns false - every { interactor.getCourseStructureForVideos() } returns courseStructure + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure coEvery { courseNotifier.notifier } returns flow { emit(CourseStructureUpdated("")) - emit(CourseDataReady(courseStructure)) } - every { downloadDao.readAllData() } returns flow { + every { downloadDao.getAllDataFlow() } returns flow { repeat(5) { delay(10000) emit(emptyList()) @@ -267,9 +302,13 @@ class CourseVideoViewModelTest { courseNotifier, videoNotifier, analytics, + downloadDialogManager, + fileUtil, + courseRouter, coreAnalytics, downloadDao, - workerController + workerController, + downloadHelper, ) val mockLifeCycleOwner: LifecycleOwner = mockk() @@ -279,23 +318,23 @@ class CourseVideoViewModelTest { advanceUntilIdle() - coVerify(exactly = 2) { interactor.getCourseStructureForVideos() } + coVerify(exactly = 2) { interactor.getCourseStructureForVideos(any()) } assert(viewModel.uiState.value is CourseVideosUIState.CourseData) } @Test fun `setIsUpdating success`() = runTest { - every { config.isCourseNestedListEnabled() } returns false + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false every { preferencesManager.videoSettings } returns VideoSettings.default - coEvery { interactor.getCourseStructureForVideos() } returns courseStructure - coEvery { downloadDao.readAllData() } returns flow { emit(listOf(downloadModelEntity)) } + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure + coEvery { downloadDao.getAllDataFlow() } returns flow { emit(listOf(downloadModelEntity)) } advanceUntilIdle() } @Test fun `saveDownloadModels test`() = runTest(UnconfinedTestDispatcher()) { - every { config.isCourseNestedListEnabled() } returns false + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( "", @@ -308,12 +347,16 @@ class CourseVideoViewModelTest { courseNotifier, videoNotifier, analytics, + downloadDialogManager, + fileUtil, + courseRouter, coreAnalytics, downloadDao, - workerController + workerController, + downloadHelper, ) - coEvery { interactor.getCourseStructureForVideos() } returns courseStructure - coEvery { downloadDao.readAllData() } returns flow { emit(listOf(downloadModelEntity)) } + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure + coEvery { downloadDao.getAllDataFlow() } returns flow { emit(listOf(downloadModelEntity)) } every { preferencesManager.videoSettings.wifiDownloadOnly } returns false every { networkConnection.isWifiConnected() } returns true coEvery { workerController.saveModels(any()) } returns Unit @@ -331,7 +374,7 @@ class CourseVideoViewModelTest { @Test fun `saveDownloadModels only wifi download, with connection`() = runTest(UnconfinedTestDispatcher()) { - every { config.isCourseNestedListEnabled() } returns false + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( "", @@ -344,16 +387,20 @@ class CourseVideoViewModelTest { courseNotifier, videoNotifier, analytics, + downloadDialogManager, + fileUtil, + courseRouter, coreAnalytics, downloadDao, - workerController + workerController, + downloadHelper, ) - coEvery { interactor.getCourseStructureForVideos() } returns courseStructure - coEvery { downloadDao.readAllData() } returns flow { emit(listOf(downloadModelEntity)) } + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure + coEvery { downloadDao.getAllDataFlow() } returns flow { emit(listOf(downloadModelEntity)) } every { preferencesManager.videoSettings.wifiDownloadOnly } returns true every { networkConnection.isWifiConnected() } returns true coEvery { workerController.saveModels(any()) } returns Unit - coEvery { downloadDao.readAllData() } returns flow { + coEvery { downloadDao.getAllDataFlow() } returns flow { emit(listOf(DownloadModelEntity.createFrom(downloadModel))) } every { coreAnalytics.logEvent(any(), any()) } returns Unit @@ -371,7 +418,7 @@ class CourseVideoViewModelTest { @Test fun `saveDownloadModels only wifi download, without connection`() = runTest(UnconfinedTestDispatcher()) { - every { config.isCourseNestedListEnabled() } returns false + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( "", @@ -384,15 +431,19 @@ class CourseVideoViewModelTest { courseNotifier, videoNotifier, analytics, + downloadDialogManager, + fileUtil, + courseRouter, coreAnalytics, downloadDao, - workerController + workerController, + downloadHelper, ) every { preferencesManager.videoSettings.wifiDownloadOnly } returns true every { networkConnection.isWifiConnected() } returns false every { networkConnection.isOnline() } returns false - coEvery { interactor.getCourseStructureForVideos() } returns courseStructure - coEvery { downloadDao.readAllData() } returns flow { emit(listOf(downloadModelEntity)) } + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure + coEvery { downloadDao.getAllDataFlow() } returns flow { emit(listOf(downloadModelEntity)) } coEvery { workerController.saveModels(any()) } returns Unit val message = async { withTimeoutOrNull(5000) { diff --git a/dashboard/build.gradle b/dashboard/build.gradle index c0c3192d0..13119287f 100644 --- a/dashboard/build.gradle +++ b/dashboard/build.gradle @@ -1,6 +1,7 @@ plugins { id 'com.android.library' id 'org.jetbrains.kotlin.android' + id "org.jetbrains.kotlin.plugin.compose" } android { @@ -29,15 +30,13 @@ android { } kotlinOptions { jvmTarget = JavaVersion.VERSION_17 + freeCompilerArgs = List.of("-Xstring-concat=inline") } buildFeatures { viewBinding true compose true } - composeOptions { - kotlinCompilerExtensionVersion = "$compose_compiler_version" - } flavorDimensions += "env" productFlavors { @@ -56,13 +55,10 @@ android { dependencies { implementation project(path: ':core') - androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' - androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version" - debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version" + androidTestImplementation 'androidx.test.ext:junit:1.2.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' testImplementation "junit:junit:$junit_version" testImplementation "io.mockk:mockk:$mockk_version" testImplementation "io.mockk:mockk-android:$mockk_version" - testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version" testImplementation "androidx.arch.core:core-testing:$android_arch_version" } \ No newline at end of file diff --git a/dashboard/proguard-rules.pro b/dashboard/proguard-rules.pro index 481bb4348..cdb308aa0 100644 --- a/dashboard/proguard-rules.pro +++ b/dashboard/proguard-rules.pro @@ -1,21 +1,7 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +# Prevent shrinking, optimization, and obfuscation of the library when consumed by other modules. +# This ensures that all classes and methods remain available for use by the consumer of the library. +# Disabling these steps at the library level is important because the main app module will handle +# shrinking, optimization, and obfuscation for the entire application, including this library. +-dontshrink +-dontoptimize +-dontobfuscate diff --git a/dashboard/src/androidTest/java/org/openedx/dashboard/presentation/MyCoursesScreenTest.kt b/dashboard/src/androidTest/java/org/openedx/dashboard/presentation/MyCoursesScreenTest.kt index dbf15acd4..910605415 100644 --- a/dashboard/src/androidTest/java/org/openedx/dashboard/presentation/MyCoursesScreenTest.kt +++ b/dashboard/src/androidTest/java/org/openedx/dashboard/presentation/MyCoursesScreenTest.kt @@ -17,6 +17,7 @@ import org.openedx.core.domain.model.CourseSharingUtmParameters import org.openedx.core.domain.model.CoursewareAccess import org.openedx.core.domain.model.EnrolledCourse import org.openedx.core.domain.model.EnrolledCourseData +import org.openedx.core.domain.model.Progress import org.openedx.core.ui.WindowSize import org.openedx.core.ui.WindowType import java.util.Date @@ -61,14 +62,17 @@ class MyCoursesScreenTest { discussionUrl = "", videoOutline = "", isSelfPaced = false - ) + ), + progress = Progress(0, 0), + courseStatus = null, + courseAssignments = null, ) //endregion @Test fun dashboardScreenLoading() { composeTestRule.setContent { - MyCoursesScreen( + DashboardListView( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), apiHostUrl = "http://localhost:8000", state = DashboardUIState.Courses(listOf(mockCourseEnrolled, mockCourseEnrolled)), @@ -81,7 +85,6 @@ class MyCoursesScreenTest { paginationCallback = {}, onItemClick = {}, appUpgradeParameters = AppUpdateState.AppUpgradeParameters(), - onSettingsClick = {} ) } @@ -101,7 +104,7 @@ class MyCoursesScreenTest { @Test fun dashboardScreenLoaded() { composeTestRule.setContent { - MyCoursesScreen( + DashboardListView( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), apiHostUrl = "http://localhost:8000", state = DashboardUIState.Courses(listOf(mockCourseEnrolled, mockCourseEnrolled)), @@ -114,7 +117,6 @@ class MyCoursesScreenTest { paginationCallback = {}, onItemClick = {}, appUpgradeParameters = AppUpdateState.AppUpgradeParameters(), - onSettingsClick = {} ) } @@ -127,7 +129,7 @@ class MyCoursesScreenTest { @Test fun dashboardScreenRefreshing() { composeTestRule.setContent { - MyCoursesScreen( + DashboardListView( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), apiHostUrl = "http://localhost:8000", state = DashboardUIState.Courses(listOf(mockCourseEnrolled, mockCourseEnrolled)), @@ -140,7 +142,6 @@ class MyCoursesScreenTest { paginationCallback = {}, onItemClick = {}, appUpgradeParameters = AppUpdateState.AppUpgradeParameters(), - onSettingsClick = {} ) } @@ -162,5 +163,4 @@ class MyCoursesScreenTest { ) } } - } diff --git a/dashboard/src/main/java/org/openedx/DashboardNavigator.kt b/dashboard/src/main/java/org/openedx/DashboardNavigator.kt new file mode 100644 index 000000000..1705860b6 --- /dev/null +++ b/dashboard/src/main/java/org/openedx/DashboardNavigator.kt @@ -0,0 +1,17 @@ +package org.openedx + +import androidx.fragment.app.Fragment +import org.openedx.core.config.DashboardConfig +import org.openedx.courses.presentation.DashboardGalleryFragment +import org.openedx.dashboard.presentation.DashboardListFragment + +class DashboardNavigator( + private val dashboardType: DashboardConfig.DashboardType, +) { + fun getDashboardFragment(): Fragment { + return when (dashboardType) { + DashboardConfig.DashboardType.GALLERY -> DashboardGalleryFragment() + else -> DashboardListFragment() + } + } +} diff --git a/dashboard/src/main/java/org/openedx/DashboardUI.kt b/dashboard/src/main/java/org/openedx/DashboardUI.kt new file mode 100644 index 000000000..13a3f42d1 --- /dev/null +++ b/dashboard/src/main/java/org/openedx/DashboardUI.kt @@ -0,0 +1,49 @@ +package org.openedx + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Lock +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors + +@Composable +fun Lock(modifier: Modifier = Modifier) { + Box( + modifier = modifier.fillMaxSize() + ) { + Icon( + modifier = Modifier + .size(32.dp) + .padding(top = 8.dp, end = 8.dp) + .background( + color = MaterialTheme.appColors.onPrimary.copy(0.5f), + shape = CircleShape + ) + .padding(4.dp) + .align(Alignment.TopEnd), + imageVector = Icons.Default.Lock, + contentDescription = null, + tint = MaterialTheme.appColors.onSurface + ) + } +} + +@Preview +@Composable +private fun LockPreview() { + OpenEdXTheme { + Lock() + } +} diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesAction.kt b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesAction.kt new file mode 100644 index 000000000..7655fd6a2 --- /dev/null +++ b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesAction.kt @@ -0,0 +1,14 @@ +package org.openedx.courses.presentation + +import org.openedx.core.domain.model.EnrolledCourse +import org.openedx.dashboard.domain.CourseStatusFilter + +interface AllEnrolledCoursesAction { + object Reload : AllEnrolledCoursesAction + object SwipeRefresh : AllEnrolledCoursesAction + object EndOfPage : AllEnrolledCoursesAction + object Back : AllEnrolledCoursesAction + object Search : AllEnrolledCoursesAction + data class OpenCourse(val enrolledCourse: EnrolledCourse) : AllEnrolledCoursesAction + data class FilterChange(val courseStatusFilter: CourseStatusFilter?) : AllEnrolledCoursesAction +} diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesFragment.kt b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesFragment.kt new file mode 100644 index 000000000..e59a73fde --- /dev/null +++ b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesFragment.kt @@ -0,0 +1,27 @@ +package org.openedx.courses.presentation + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.Fragment +import org.openedx.core.ui.theme.OpenEdXTheme + +class AllEnrolledCoursesFragment : Fragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + AllEnrolledCoursesView( + fragmentManager = requireActivity().supportFragmentManager + ) + } + } + } +} diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesUIState.kt b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesUIState.kt new file mode 100644 index 000000000..2d7efb51b --- /dev/null +++ b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesUIState.kt @@ -0,0 +1,10 @@ +package org.openedx.courses.presentation + +import org.openedx.core.domain.model.EnrolledCourse + +data class AllEnrolledCoursesUIState( + val courses: List? = null, + val refreshing: Boolean = false, + val canLoadMore: Boolean = false, + val showProgress: Boolean = false, +) diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt new file mode 100644 index 000000000..10fefe8f1 --- /dev/null +++ b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt @@ -0,0 +1,637 @@ +package org.openedx.courses.presentation + +import android.content.res.Configuration +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.Card +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentManager +import coil.compose.AsyncImage +import coil.request.ImageRequest +import org.koin.androidx.compose.koinViewModel +import org.openedx.Lock +import org.openedx.core.R +import org.openedx.core.domain.model.Certificate +import org.openedx.core.domain.model.CourseAssignments +import org.openedx.core.domain.model.CourseSharingUtmParameters +import org.openedx.core.domain.model.CourseStatus +import org.openedx.core.domain.model.CoursewareAccess +import org.openedx.core.domain.model.EnrolledCourse +import org.openedx.core.domain.model.EnrolledCourseData +import org.openedx.core.domain.model.Progress +import org.openedx.core.ui.BackBtn +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.OfflineModeDialog +import org.openedx.core.ui.RoundTabsBar +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.shouldLoadMore +import org.openedx.core.ui.statusBarsInset +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes +import org.openedx.core.ui.theme.appTypography +import org.openedx.core.utils.TimeUtils +import org.openedx.dashboard.domain.CourseStatusFilter +import org.openedx.foundation.extension.toImageLink +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue +import java.util.Date + +@Composable +fun AllEnrolledCoursesView( + fragmentManager: FragmentManager +) { + val viewModel: AllEnrolledCoursesViewModel = koinViewModel() + val uiState by viewModel.uiState.collectAsState() + val uiMessage by viewModel.uiMessage.collectAsState(null) + + AllEnrolledCoursesView( + apiHostUrl = viewModel.apiHostUrl, + state = uiState, + uiMessage = uiMessage, + hasInternetConnection = viewModel.hasInternetConnection, + onAction = { action -> + when (action) { + AllEnrolledCoursesAction.Reload -> { + viewModel.getCourses() + } + + AllEnrolledCoursesAction.SwipeRefresh -> { + viewModel.updateCourses() + } + + AllEnrolledCoursesAction.EndOfPage -> { + viewModel.fetchMore() + } + + AllEnrolledCoursesAction.Back -> { + fragmentManager.popBackStack() + } + + AllEnrolledCoursesAction.Search -> { + viewModel.navigateToCourseSearch(fragmentManager) + } + + is AllEnrolledCoursesAction.OpenCourse -> { + with(action.enrolledCourse) { + viewModel.navigateToCourseOutline( + fragmentManager, + course.id, + course.name, + ) + } + } + + is AllEnrolledCoursesAction.FilterChange -> { + viewModel.getCourses(action.courseStatusFilter) + } + } + } + ) +} + +@OptIn(ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class) +@Composable +private fun AllEnrolledCoursesView( + apiHostUrl: String, + state: AllEnrolledCoursesUIState, + uiMessage: UIMessage?, + hasInternetConnection: Boolean, + onAction: (AllEnrolledCoursesAction) -> Unit +) { + val windowSize = rememberWindowSize() + val layoutDirection = LocalLayoutDirection.current + val scaffoldState = rememberScaffoldState() + val scrollState = rememberLazyGridState() + val columns = if (windowSize.isTablet) 3 else 2 + val pullRefreshState = rememberPullRefreshState( + refreshing = state.refreshing, + onRefresh = { onAction(AllEnrolledCoursesAction.SwipeRefresh) } + ) + val tabPagerState = rememberPagerState(pageCount = { + CourseStatusFilter.entries.size + }) + var isInternetConnectionShown by rememberSaveable { + mutableStateOf(false) + } + val firstVisibleIndex = remember { + mutableIntStateOf(scrollState.firstVisibleItemIndex) + } + + Scaffold( + scaffoldState = scaffoldState, + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding() + .semantics { + testTagsAsResourceId = true + }, + backgroundColor = MaterialTheme.appColors.background + ) { paddingValues -> + val contentPaddings by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = PaddingValues( + top = 16.dp, + bottom = 40.dp, + ), + compact = PaddingValues(horizontal = 16.dp, vertical = 16.dp) + ) + ) + } + + val roundTapBarPaddings by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = PaddingValues(vertical = 6.dp), + compact = PaddingValues(horizontal = 16.dp, vertical = 6.dp) + ) + ) + } + + + val emptyStatePaddings by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.padding( + top = 32.dp, + bottom = 40.dp + ), + compact = Modifier.padding(horizontal = 24.dp, vertical = 24.dp) + ) + ) + } + + val contentWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 650.dp), + compact = Modifier.fillMaxWidth(), + ) + ) + } + + HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.TopCenter + ) { + Column( + modifier = Modifier + .statusBarsInset() + .displayCutoutForLandscape() + .then(contentWidth), + horizontalAlignment = Alignment.CenterHorizontally + ) { + BackBtn( + modifier = Modifier.align(Alignment.Start), + tint = MaterialTheme.appColors.textDark + ) { + onAction(AllEnrolledCoursesAction.Back) + } + + Surface( + color = MaterialTheme.appColors.background, + shape = MaterialTheme.appShapes.screenBackgroundShape + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .pullRefresh(pullRefreshState), + ) { + Column( + modifier = Modifier + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Header( + modifier = Modifier + .padding( + start = contentPaddings.calculateStartPadding(layoutDirection), + end = contentPaddings.calculateEndPadding(layoutDirection) + ), + onSearchClick = { + onAction(AllEnrolledCoursesAction.Search) + } + ) + RoundTabsBar( + modifier = Modifier.align(Alignment.Start), + items = CourseStatusFilter.entries, + contentPadding = roundTapBarPaddings, + rowState = rememberLazyListState(), + pagerState = tabPagerState, + onTabClicked = { + val newFilter = CourseStatusFilter.entries[it] + onAction(AllEnrolledCoursesAction.FilterChange(newFilter)) + } + ) + when { + state.showProgress -> { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } + } + + !state.courses.isNullOrEmpty() -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(contentPaddings), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + LazyVerticalGrid( + modifier = Modifier + .fillMaxHeight(), + state = scrollState, + columns = GridCells.Fixed(columns), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + content = { + items(state.courses) { course -> + CourseItem( + course = course, + apiHostUrl = apiHostUrl, + onClick = { + onAction(AllEnrolledCoursesAction.OpenCourse(it)) + } + ) + } + item(span = { GridItemSpan(columns) }) { + if (state.canLoadMore) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(180.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + color = MaterialTheme.appColors.primary + ) + } + } + } + } + ) + } + if (scrollState.shouldLoadMore(firstVisibleIndex, 4)) { + onAction(AllEnrolledCoursesAction.EndOfPage) + } + } + } + + state.courses?.isEmpty() == true -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier + .fillMaxHeight() + .then(emptyStatePaddings) + ) { + EmptyState( + currentCourseStatus = CourseStatusFilter.entries[tabPagerState.currentPage] + ) + } + } + } + } + } + PullRefreshIndicator( + state.refreshing, + pullRefreshState, + Modifier.align(Alignment.TopCenter) + ) + + if (!isInternetConnectionShown && !hasInternetConnection) { + OfflineModeDialog( + Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + onDismissCLick = { + isInternetConnectionShown = true + }, + onReloadClick = { + isInternetConnectionShown = true + onAction(AllEnrolledCoursesAction.Reload) + } + ) + } + } + } + } + } + } +} + +@Composable +fun CourseItem( + modifier: Modifier = Modifier, + course: EnrolledCourse, + apiHostUrl: String, + onClick: (EnrolledCourse) -> Unit, +) { + Card( + modifier = modifier + .width(170.dp) + .height(180.dp) + .clickable { + onClick(course) + }, + backgroundColor = MaterialTheme.appColors.background, + shape = MaterialTheme.appShapes.courseImageShape, + elevation = 4.dp + ) { + Box { + Column { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(course.course.courseImage.toImageLink(apiHostUrl)) + .error(R.drawable.core_no_image_course) + .placeholder(R.drawable.core_no_image_course) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .height(90.dp) + ) + val progress: Float = try { + course.progress.assignmentsCompleted.toFloat() / course.progress.totalAssignmentsCount.toFloat() + } catch (_: ArithmeticException) { + 0f + } + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(8.dp), + progress = progress, + color = MaterialTheme.appColors.primary, + backgroundColor = MaterialTheme.appColors.divider + ) + + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + .padding(top = 4.dp), + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textFieldHint, + overflow = TextOverflow.Ellipsis, + minLines = 1, + maxLines = 2, + text = TimeUtils.getCourseFormattedDate( + LocalContext.current, + Date(), + course.auditAccessExpires, + course.course.start, + course.course.end, + course.course.startType, + course.course.startDisplay + ) + ) + Text( + modifier = Modifier + .padding(horizontal = 8.dp, vertical = 4.dp), + text = course.course.name, + style = MaterialTheme.appTypography.titleSmall, + color = MaterialTheme.appColors.textDark, + overflow = TextOverflow.Ellipsis, + minLines = 1, + maxLines = 2 + ) + } + if (!course.course.coursewareAccess?.errorCode.isNullOrEmpty()) { + Lock() + } + } + } +} + +@Composable +fun Header( + modifier: Modifier = Modifier, + onSearchClick: () -> Unit +) { + Box( + modifier = modifier.fillMaxWidth() + ) { + Text( + modifier = Modifier.align(Alignment.CenterStart), + text = stringResource(id = org.openedx.dashboard.R.string.dashboard_all_courses), + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.headlineBold + ) + IconButton( + modifier = Modifier + .align(Alignment.CenterEnd) + .offset(x = 12.dp), + onClick = { + onSearchClick() + } + ) { + Icon( + imageVector = Icons.Filled.Search, + contentDescription = null, + tint = MaterialTheme.appColors.textDark + ) + } + } +} + +@Composable +fun EmptyState( + currentCourseStatus: CourseStatusFilter +) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + Modifier.width(200.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + painter = painterResource(id = R.drawable.core_ic_book), + tint = MaterialTheme.appColors.textFieldBorder, + contentDescription = null + ) + Spacer(Modifier.height(4.dp)) + Text( + modifier = Modifier + .testTag("txt_empty_state_title") + .fillMaxWidth(), + text = stringResource( + id = org.openedx.dashboard.R.string.dashboard_no_status_courses, + stringResource(currentCourseStatus.labelResId) + ), + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.titleMedium, + textAlign = TextAlign.Center + ) + } + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun CourseItemPreview() { + OpenEdXTheme { + CourseItem( + course = mockCourseEnrolled, + apiHostUrl = "", + onClick = {} + ) + } +} + +@Preview +@Composable +private fun EmptyStatePreview() { + OpenEdXTheme { + EmptyState( + currentCourseStatus = CourseStatusFilter.COMPLETE + ) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, device = Devices.NEXUS_9) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.NEXUS_9) +@Composable +private fun AllEnrolledCoursesPreview() { + OpenEdXTheme { + AllEnrolledCoursesView( + apiHostUrl = "http://localhost:8000", + state = AllEnrolledCoursesUIState( + courses = listOf( + mockCourseEnrolled, + mockCourseEnrolled, + mockCourseEnrolled, + mockCourseEnrolled, + mockCourseEnrolled, + mockCourseEnrolled + ) + ), + uiMessage = null, + hasInternetConnection = true, + onAction = {} + ) + } +} + +private val mockCourseAssignments = CourseAssignments(null, emptyList()) +private val mockCourseEnrolled = EnrolledCourse( + auditAccessExpires = Date(), + created = "created", + certificate = Certificate(""), + mode = "mode", + isActive = true, + progress = Progress.DEFAULT_PROGRESS, + courseStatus = CourseStatus("", emptyList(), "", ""), + courseAssignments = mockCourseAssignments, + course = EnrolledCourseData( + id = "id", + name = "name", + number = "", + org = "Org", + start = Date(), + startDisplay = "", + startType = "", + end = Date(), + dynamicUpgradeDeadline = "", + subscriptionId = "", + coursewareAccess = CoursewareAccess( + false, + "204", + "", + "", + "", + "" + ), + media = null, + courseImage = "", + courseAbout = "", + courseSharingUtmParameters = CourseSharingUtmParameters("", ""), + courseUpdates = "", + courseHandouts = "", + discussionUrl = "", + videoOutline = "", + isSelfPaced = false + ) +) diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesViewModel.kt b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesViewModel.kt new file mode 100644 index 000000000..ccba20242 --- /dev/null +++ b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesViewModel.kt @@ -0,0 +1,179 @@ +package org.openedx.courses.presentation + +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.openedx.core.R +import org.openedx.core.config.Config +import org.openedx.core.domain.model.EnrolledCourse +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.system.notifier.CourseDashboardUpdate +import org.openedx.core.system.notifier.DiscoveryNotifier +import org.openedx.dashboard.domain.CourseStatusFilter +import org.openedx.dashboard.domain.interactor.DashboardInteractor +import org.openedx.dashboard.presentation.DashboardAnalytics +import org.openedx.dashboard.presentation.DashboardRouter +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager + +class AllEnrolledCoursesViewModel( + private val config: Config, + private val networkConnection: NetworkConnection, + private val interactor: DashboardInteractor, + private val resourceManager: ResourceManager, + private val discoveryNotifier: DiscoveryNotifier, + private val analytics: DashboardAnalytics, + private val dashboardRouter: DashboardRouter +) : BaseViewModel() { + + val apiHostUrl get() = config.getApiHostURL() + val hasInternetConnection: Boolean + get() = networkConnection.isOnline() + + private val coursesList = mutableListOf() + private var page = 1 + private var isLoading = false + + private val _uiState = MutableStateFlow(AllEnrolledCoursesUIState()) + val uiState: StateFlow + get() = _uiState.asStateFlow() + + private val _uiMessage = MutableSharedFlow() + val uiMessage: SharedFlow + get() = _uiMessage.asSharedFlow() + + private val currentFilter: MutableStateFlow = MutableStateFlow(CourseStatusFilter.ALL) + + private var job: Job? = null + + init { + collectDiscoveryNotifier() + getCourses(currentFilter.value) + } + + fun getCourses(courseStatusFilter: CourseStatusFilter? = null) { + _uiState.update { it.copy(showProgress = true) } + coursesList.clear() + internalLoadingCourses(courseStatusFilter ?: currentFilter.value) + } + + fun updateCourses() { + viewModelScope.launch { + try { + _uiState.update { it.copy(refreshing = true) } + isLoading = true + page = 1 + val response = interactor.getAllUserCourses(page, currentFilter.value) + if (response.pagination.next.isNotEmpty() && page != response.pagination.numPages) { + _uiState.update { it.copy(canLoadMore = true) } + page++ + } else { + _uiState.update { it.copy(canLoadMore = false) } + page = -1 + } + coursesList.clear() + coursesList.addAll(response.courses) + _uiState.update { it.copy(courses = coursesList.toList()) } + } catch (e: Exception) { + if (e.isInternetError()) { + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection))) + } else { + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error))) + } + } + _uiState.update { it.copy(refreshing = false, showProgress = false) } + isLoading = false + } + } + + private fun internalLoadingCourses(courseStatusFilter: CourseStatusFilter? = null) { + if (courseStatusFilter != null) { + page = 1 + currentFilter.value = courseStatusFilter + } + job?.cancel() + job = viewModelScope.launch { + try { + isLoading = true + val response = if (networkConnection.isOnline() || page > 1) { + interactor.getAllUserCourses(page, currentFilter.value) + } else { + null + } + if (response != null) { + if (response.pagination.next.isNotEmpty() && page != response.pagination.numPages) { + _uiState.update { it.copy(canLoadMore = true) } + page++ + } else { + _uiState.update { it.copy(canLoadMore = false) } + page = -1 + } + coursesList.addAll(response.courses) + } else { + val cachedList = interactor.getEnrolledCoursesFromCache() + _uiState.update { it.copy(canLoadMore = false) } + page = -1 + coursesList.addAll(cachedList) + } + _uiState.update { it.copy(courses = coursesList.toList()) } + } catch (e: Exception) { + if (e.isInternetError()) { + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection))) + } else { + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error))) + } + } + _uiState.update { it.copy(refreshing = false, showProgress = false) } + isLoading = false + } + } + + fun fetchMore() { + if (!isLoading && page != -1) { + internalLoadingCourses() + } + } + + private fun dashboardCourseClickedEvent(courseId: String, courseName: String) { + analytics.dashboardCourseClickedEvent(courseId, courseName) + } + + private fun collectDiscoveryNotifier() { + viewModelScope.launch { + discoveryNotifier.notifier.collect { + if (it is CourseDashboardUpdate) { + updateCourses() + } + } + } + } + + fun navigateToCourseSearch(fragmentManager: FragmentManager) { + dashboardRouter.navigateToCourseSearch( + fragmentManager, "" + ) + } + + fun navigateToCourseOutline( + fragmentManager: FragmentManager, + courseId: String, + courseName: String, + ) { + dashboardCourseClickedEvent(courseId, courseName) + dashboardRouter.navigateToCourseOutline( + fm = fragmentManager, + courseId = courseId, + courseTitle = courseName + ) + } +} diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/CourseTab.kt b/dashboard/src/main/java/org/openedx/courses/presentation/CourseTab.kt new file mode 100644 index 000000000..f29e0a110 --- /dev/null +++ b/dashboard/src/main/java/org/openedx/courses/presentation/CourseTab.kt @@ -0,0 +1,5 @@ +package org.openedx.courses.presentation + +enum class CourseTab { + HOME, VIDEOS, DATES, OFFLINE, DISCUSSIONS, MORE +} diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryFragment.kt b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryFragment.kt new file mode 100644 index 000000000..b0309785c --- /dev/null +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryFragment.kt @@ -0,0 +1,24 @@ +package org.openedx.courses.presentation + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.Fragment +import org.openedx.core.ui.theme.OpenEdXTheme + +class DashboardGalleryFragment : Fragment() { + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + DashboardGalleryView(fragmentManager = requireActivity().supportFragmentManager) + } + } + } +} diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryScreenAction.kt b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryScreenAction.kt new file mode 100644 index 000000000..f612a5289 --- /dev/null +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryScreenAction.kt @@ -0,0 +1,13 @@ +package org.openedx.courses.presentation + +import org.openedx.core.domain.model.EnrolledCourse + +interface DashboardGalleryScreenAction { + object SwipeRefresh : DashboardGalleryScreenAction + object ViewAll : DashboardGalleryScreenAction + object Reload : DashboardGalleryScreenAction + object NavigateToDiscovery : DashboardGalleryScreenAction + data class OpenBlock(val enrolledCourse: EnrolledCourse, val blockId: String) : DashboardGalleryScreenAction + data class OpenCourse(val enrolledCourse: EnrolledCourse) : DashboardGalleryScreenAction + data class NavigateToDates(val enrolledCourse: EnrolledCourse) : DashboardGalleryScreenAction +} diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryUIState.kt b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryUIState.kt new file mode 100644 index 000000000..fdbc5d5db --- /dev/null +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryUIState.kt @@ -0,0 +1,9 @@ +package org.openedx.courses.presentation + +import org.openedx.core.domain.model.CourseEnrollments + +sealed class DashboardGalleryUIState { + data class Courses(val userCourses: CourseEnrollments, val useRelativeDates: Boolean) : DashboardGalleryUIState() + data object Empty : DashboardGalleryUIState() + data object Loading : DashboardGalleryUIState() +} diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt new file mode 100644 index 000000000..40e2b0318 --- /dev/null +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt @@ -0,0 +1,889 @@ +package org.openedx.courses.presentation + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Card +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Divider +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.School +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.Lifecycle +import coil.compose.AsyncImage +import coil.request.ImageRequest +import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf +import org.openedx.Lock +import org.openedx.core.domain.model.AppConfig +import org.openedx.core.domain.model.Certificate +import org.openedx.core.domain.model.CourseAssignments +import org.openedx.core.domain.model.CourseDateBlock +import org.openedx.core.domain.model.CourseDatesCalendarSync +import org.openedx.core.domain.model.CourseEnrollments +import org.openedx.core.domain.model.CourseSharingUtmParameters +import org.openedx.core.domain.model.CourseStatus +import org.openedx.core.domain.model.CoursewareAccess +import org.openedx.core.domain.model.DashboardCourseList +import org.openedx.core.domain.model.EnrolledCourse +import org.openedx.core.domain.model.EnrolledCourseData +import org.openedx.core.domain.model.Pagination +import org.openedx.core.domain.model.Progress +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.OfflineModeDialog +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.TextIcon +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes +import org.openedx.core.ui.theme.appTypography +import org.openedx.core.utils.TimeUtils +import org.openedx.dashboard.R +import org.openedx.foundation.extension.toImageLink +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.rememberWindowSize +import java.util.Date +import org.openedx.core.R as CoreR + +@Composable +fun DashboardGalleryView( + fragmentManager: FragmentManager, +) { + val windowSize = rememberWindowSize() + val viewModel: DashboardGalleryViewModel = koinViewModel { parametersOf(windowSize) } + val updating by viewModel.updating.collectAsState(false) + val uiMessage by viewModel.uiMessage.collectAsState(null) + val uiState by viewModel.uiState.collectAsState(DashboardGalleryUIState.Loading) + + val lifecycleState by LocalLifecycleOwner.current.lifecycle.currentStateFlow.collectAsState() + LaunchedEffect(lifecycleState) { + if (lifecycleState == Lifecycle.State.RESUMED) { + viewModel.updateCourses(isUpdating = false) + } + } + + DashboardGalleryView( + uiMessage = uiMessage, + uiState = uiState, + updating = updating, + apiHostUrl = viewModel.apiHostUrl, + hasInternetConnection = viewModel.hasInternetConnection, + onAction = { action -> + when (action) { + DashboardGalleryScreenAction.SwipeRefresh -> { + viewModel.updateCourses() + } + + DashboardGalleryScreenAction.ViewAll -> { + viewModel.navigateToAllEnrolledCourses(fragmentManager) + } + + DashboardGalleryScreenAction.Reload -> { + viewModel.getCourses() + } + + DashboardGalleryScreenAction.NavigateToDiscovery -> { + viewModel.navigateToDiscovery() + } + + is DashboardGalleryScreenAction.OpenCourse -> { + viewModel.navigateToCourseOutline( + fragmentManager = fragmentManager, + enrolledCourse = action.enrolledCourse + ) + } + + is DashboardGalleryScreenAction.NavigateToDates -> { + viewModel.navigateToCourseOutline( + fragmentManager = fragmentManager, + enrolledCourse = action.enrolledCourse, + openDates = true + ) + } + + is DashboardGalleryScreenAction.OpenBlock -> { + viewModel.navigateToCourseOutline( + fragmentManager = fragmentManager, + enrolledCourse = action.enrolledCourse, + resumeBlockId = action.blockId + ) + } + } + } + ) +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +private fun DashboardGalleryView( + uiMessage: UIMessage?, + uiState: DashboardGalleryUIState, + updating: Boolean, + apiHostUrl: String, + onAction: (DashboardGalleryScreenAction) -> Unit, + hasInternetConnection: Boolean +) { + val scaffoldState = rememberScaffoldState() + val pullRefreshState = rememberPullRefreshState( + refreshing = updating, + onRefresh = { onAction(DashboardGalleryScreenAction.SwipeRefresh) } + ) + var isInternetConnectionShown by rememberSaveable { + mutableStateOf(false) + } + + Scaffold( + scaffoldState = scaffoldState, + modifier = Modifier.fillMaxSize(), + backgroundColor = MaterialTheme.appColors.background + ) { paddingValues -> + + HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) + + Surface( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + color = MaterialTheme.appColors.background + ) { + Box( + Modifier.fillMaxSize() + ) { + Box( + Modifier + .fillMaxSize() + .pullRefresh(pullRefreshState) + .verticalScroll(rememberScrollState()), + ) { + when (uiState) { + is DashboardGalleryUIState.Loading -> { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + color = MaterialTheme.appColors.primary + ) + } + + is DashboardGalleryUIState.Courses -> { + UserCourses( + modifier = Modifier.fillMaxSize(), + userCourses = uiState.userCourses, + useRelativeDates = uiState.useRelativeDates, + apiHostUrl = apiHostUrl, + openCourse = { + onAction(DashboardGalleryScreenAction.OpenCourse(it)) + }, + onViewAllClick = { + onAction(DashboardGalleryScreenAction.ViewAll) + }, + navigateToDates = { + onAction(DashboardGalleryScreenAction.NavigateToDates(it)) + }, + resumeBlockId = { course, blockId -> + onAction(DashboardGalleryScreenAction.OpenBlock(course, blockId)) + } + ) + } + + is DashboardGalleryUIState.Empty -> { + NoCoursesInfo( + modifier = Modifier + .align(Alignment.Center) + ) + FindACourseButton( + modifier = Modifier + .align(Alignment.BottomCenter), + findACourseClick = { + onAction(DashboardGalleryScreenAction.NavigateToDiscovery) + } + ) + } + } + + PullRefreshIndicator( + updating, + pullRefreshState, + Modifier.align(Alignment.TopCenter) + ) + } + if (!isInternetConnectionShown && !hasInternetConnection) { + OfflineModeDialog( + Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + onDismissCLick = { + isInternetConnectionShown = true + }, + onReloadClick = { + isInternetConnectionShown = true + onAction(DashboardGalleryScreenAction.SwipeRefresh) + } + ) + } + } + } + } +} + +@Composable +private fun UserCourses( + modifier: Modifier = Modifier, + userCourses: CourseEnrollments, + apiHostUrl: String, + useRelativeDates: Boolean, + openCourse: (EnrolledCourse) -> Unit, + navigateToDates: (EnrolledCourse) -> Unit, + onViewAllClick: () -> Unit, + resumeBlockId: (enrolledCourse: EnrolledCourse, blockId: String) -> Unit, +) { + Column( + modifier = modifier + .padding(vertical = 12.dp) + ) { + val primaryCourse = userCourses.primary + if (primaryCourse != null) { + PrimaryCourseCard( + primaryCourse = primaryCourse, + apiHostUrl = apiHostUrl, + navigateToDates = navigateToDates, + resumeBlockId = resumeBlockId, + openCourse = openCourse, + useRelativeDates = useRelativeDates + ) + } + if (userCourses.enrollments.courses.isNotEmpty()) { + SecondaryCourses( + courses = userCourses.enrollments.courses, + hasNextPage = userCourses.enrollments.pagination.next.isNotEmpty(), + apiHostUrl = apiHostUrl, + onCourseClick = openCourse, + onViewAllClick = onViewAllClick + ) + } + } +} + +@Composable +private fun SecondaryCourses( + courses: List, + hasNextPage: Boolean, + apiHostUrl: String, + onCourseClick: (EnrolledCourse) -> Unit, + onViewAllClick: () -> Unit +) { + val windowSize = rememberWindowSize() + val itemsCount = if (windowSize.isTablet) 7 else 5 + val rows = if (windowSize.isTablet) 2 else 1 + val height = if (windowSize.isTablet) 322.dp else 152.dp + val items = courses.take(itemsCount) + Column( + modifier = Modifier + .fillMaxSize() + .padding(top = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + TextIcon( + modifier = Modifier.padding(horizontal = 18.dp), + text = stringResource(R.string.dashboard_view_all_with_count, courses.size + 1), + textStyle = MaterialTheme.appTypography.titleSmall, + icon = Icons.Default.ChevronRight, + color = MaterialTheme.appColors.textDark, + iconModifier = Modifier.size(22.dp), + onClick = onViewAllClick + ) + LazyHorizontalGrid( + modifier = Modifier + .fillMaxSize() + .height(height), + rows = GridCells.Fixed(rows), + contentPadding = PaddingValues(horizontal = 18.dp), + content = { + items(items) { + CourseListItem( + course = it, + apiHostUrl = apiHostUrl, + onCourseClick = onCourseClick + ) + } + if (hasNextPage) { + item { + ViewAllItem( + onViewAllClick = onViewAllClick + ) + } + } + } + ) + } +} + +@Composable +private fun ViewAllItem( + onViewAllClick: () -> Unit +) { + Card( + modifier = Modifier + .width(140.dp) + .height(152.dp) + .padding(4.dp) + .clickable( + onClickLabel = stringResource(id = R.string.dashboard_view_all), + onClick = { + onViewAllClick() + } + ), + backgroundColor = MaterialTheme.appColors.cardViewBackground, + shape = MaterialTheme.appShapes.courseImageShape, + elevation = 4.dp, + ) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + modifier = Modifier.size(48.dp), + painter = painterResource(id = CoreR.drawable.core_ic_book), + tint = MaterialTheme.appColors.textFieldBorder, + contentDescription = null + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource(id = R.string.dashboard_view_all), + style = MaterialTheme.appTypography.titleSmall, + color = MaterialTheme.appColors.textDark + ) + } + } +} + +@Composable +private fun CourseListItem( + course: EnrolledCourse, + apiHostUrl: String, + onCourseClick: (EnrolledCourse) -> Unit, +) { + Card( + modifier = Modifier + .width(140.dp) + .height(152.dp) + .padding(4.dp) + .clickable { + onCourseClick(course) + }, + backgroundColor = MaterialTheme.appColors.background, + shape = MaterialTheme.appShapes.courseImageShape, + elevation = 4.dp + ) { + Box { + Column { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(course.course.courseImage.toImageLink(apiHostUrl)) + .error(CoreR.drawable.core_no_image_course) + .placeholder(CoreR.drawable.core_no_image_course) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .height(90.dp) + ) + Text( + modifier = Modifier + .fillMaxHeight() + .padding(horizontal = 4.dp, vertical = 8.dp), + text = course.course.name, + style = MaterialTheme.appTypography.titleSmall, + color = MaterialTheme.appColors.textDark, + overflow = TextOverflow.Ellipsis, + maxLines = 2, + minLines = 2 + ) + } + if (!course.course.coursewareAccess?.errorCode.isNullOrEmpty()) { + Lock() + } + } + } +} + +@Composable +private fun AssignmentItem( + modifier: Modifier = Modifier, + painter: Painter, + title: String?, + info: String +) { + Row( + modifier = modifier + .fillMaxWidth() + .heightIn(min = 62.dp) + .padding(vertical = 12.dp, horizontal = 14.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painter, + tint = MaterialTheme.appColors.textDark, + contentDescription = null + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + val infoTextStyle = if (title.isNullOrEmpty()) { + MaterialTheme.appTypography.titleSmall + } else { + MaterialTheme.appTypography.labelSmall + } + Text( + text = info, + color = MaterialTheme.appColors.textDark, + style = infoTextStyle + ) + if (!title.isNullOrEmpty()) { + Text( + text = title, + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.titleSmall + ) + } + } + Icon( + modifier = Modifier.size(16.dp), + imageVector = Icons.AutoMirrored.Filled.ArrowForwardIos, + tint = MaterialTheme.appColors.textDark, + contentDescription = null + ) + } +} + +@Composable +private fun PrimaryCourseCard( + primaryCourse: EnrolledCourse, + apiHostUrl: String, + useRelativeDates: Boolean, + navigateToDates: (EnrolledCourse) -> Unit, + resumeBlockId: (enrolledCourse: EnrolledCourse, blockId: String) -> Unit, + openCourse: (EnrolledCourse) -> Unit, +) { + val context = LocalContext.current + Card( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth() + .padding(2.dp), + backgroundColor = MaterialTheme.appColors.background, + shape = MaterialTheme.appShapes.courseImageShape, + elevation = 4.dp + ) { + Column( + modifier = Modifier + .clickable { + openCourse(primaryCourse) + } + ) { + AsyncImage( + model = ImageRequest.Builder(context) + .data(primaryCourse.course.courseImage.toImageLink(apiHostUrl)) + .error(CoreR.drawable.core_no_image_course) + .placeholder(CoreR.drawable.core_no_image_course) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .height(140.dp) + ) + val progress: Float = try { + primaryCourse.progress.assignmentsCompleted.toFloat() / primaryCourse.progress.totalAssignmentsCount.toFloat() + } catch (_: ArithmeticException) { + 0f + } + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(8.dp), + progress = progress, + color = MaterialTheme.appColors.primary, + backgroundColor = MaterialTheme.appColors.divider + ) + PrimaryCourseTitle( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + .padding(top = 8.dp, bottom = 16.dp), + primaryCourse = primaryCourse + ) + val pastAssignments = primaryCourse.courseAssignments?.pastAssignments + if (!pastAssignments.isNullOrEmpty()) { + val nearestAssignment = pastAssignments.maxBy { it.date } + val title = if (pastAssignments.size == 1) nearestAssignment.title else null + Divider() + AssignmentItem( + modifier = Modifier.clickable { + if (pastAssignments.size == 1) { + resumeBlockId(primaryCourse, nearestAssignment.blockId) + } else { + navigateToDates(primaryCourse) + } + }, + painter = rememberVectorPainter(Icons.Default.Warning), + title = title, + info = pluralStringResource( + R.plurals.dashboard_past_due_assignment, + pastAssignments.size, + pastAssignments.size + ) + ) + } + val futureAssignments = primaryCourse.courseAssignments?.futureAssignments + if (!futureAssignments.isNullOrEmpty()) { + val nearestAssignment = futureAssignments.minBy { it.date } + val title = if (futureAssignments.size == 1) nearestAssignment.title else null + Divider() + AssignmentItem( + modifier = Modifier.clickable { + if (futureAssignments.size == 1) { + resumeBlockId(primaryCourse, nearestAssignment.blockId) + } else { + navigateToDates(primaryCourse) + } + }, + painter = painterResource(id = CoreR.drawable.ic_core_chapter_icon), + title = title, + info = stringResource( + R.string.dashboard_assignment_due, + nearestAssignment.assignmentType ?: "", + stringResource( + id = CoreR.string.core_date_format_assignment_due, + TimeUtils.formatToString(context, nearestAssignment.date, useRelativeDates) + ) + ) + ) + } + ResumeButton( + primaryCourse = primaryCourse, + onClick = { + if (primaryCourse.courseStatus == null) { + openCourse(primaryCourse) + } else { + resumeBlockId(primaryCourse, primaryCourse.courseStatus?.lastVisitedBlockId ?: "") + } + } + ) + } + } +} + +@Composable +private fun ResumeButton( + modifier: Modifier = Modifier, + primaryCourse: EnrolledCourse, + onClick: () -> Unit +) { + Row( + modifier = modifier + .fillMaxWidth() + .clickable { onClick() } + .heightIn(min = 60.dp) + .background(MaterialTheme.appColors.primary) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (primaryCourse.courseStatus == null) { + Icon( + imageVector = Icons.Default.School, + tint = MaterialTheme.appColors.primaryButtonText, + contentDescription = null + ) + Text( + modifier = Modifier.weight(1f), + text = stringResource(R.string.dashboard_start_course), + color = MaterialTheme.appColors.primaryButtonText, + style = MaterialTheme.appTypography.titleSmall + ) + } else { + Icon( + imageVector = Icons.Default.School, + tint = MaterialTheme.appColors.primaryButtonText, + contentDescription = null + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = stringResource(R.string.dashboard_resume_course), + color = MaterialTheme.appColors.primaryButtonText, + style = MaterialTheme.appTypography.labelSmall + ) + Text( + text = primaryCourse.courseStatus?.lastVisitedUnitDisplayName ?: "", + color = MaterialTheme.appColors.primaryButtonText, + style = MaterialTheme.appTypography.titleSmall + ) + } + } + Icon( + modifier = Modifier.size(16.dp), + imageVector = Icons.AutoMirrored.Filled.ArrowForwardIos, + tint = MaterialTheme.appColors.primaryButtonText, + contentDescription = null + ) + } +} + +@Composable +private fun PrimaryCourseTitle( + modifier: Modifier = Modifier, + primaryCourse: EnrolledCourse +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = primaryCourse.course.org, + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textFieldHint + ) + Text( + modifier = Modifier.fillMaxWidth(), + text = primaryCourse.course.name, + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textDark, + overflow = TextOverflow.Ellipsis, + maxLines = 3 + ) + Text( + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textFieldHint, + text = TimeUtils.getCourseFormattedDate( + LocalContext.current, + Date(), + primaryCourse.auditAccessExpires, + primaryCourse.course.start, + primaryCourse.course.end, + primaryCourse.course.startType, + primaryCourse.course.startDisplay + ) + ) + } +} + +@Composable +private fun FindACourseButton( + modifier: Modifier = Modifier, + findACourseClick: () -> Unit +) { + OpenEdXButton( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 20.dp), + onClick = { + findACourseClick() + } + ) { + Text( + color = MaterialTheme.appColors.primaryButtonText, + text = stringResource(id = R.string.dashboard_find_a_course) + ) + } +} + +@Composable +private fun NoCoursesInfo( + modifier: Modifier = Modifier +) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier.width(200.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + painter = painterResource(id = CoreR.drawable.core_ic_book), + tint = MaterialTheme.appColors.textFieldBorder, + contentDescription = null + ) + Spacer(Modifier.height(4.dp)) + Text( + modifier = Modifier + .testTag("txt_empty_state_title") + .fillMaxWidth(), + text = stringResource(id = R.string.dashboard_all_courses_empty_title), + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.titleMedium, + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(12.dp)) + Text( + modifier = Modifier + .testTag("txt_empty_state_description") + .fillMaxWidth(), + text = stringResource(id = R.string.dashboard_all_courses_empty_description), + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.labelMedium, + textAlign = TextAlign.Center + ) + } + } +} + +private val mockCourseDateBlock = CourseDateBlock( + title = "Homework 1: ABCD", + description = "After this date, course content will be archived", + date = TimeUtils.iso8601ToDate("2024-05-31T15:08:07Z")!!, + assignmentType = "Homework" +) +private val mockCourseAssignments = + CourseAssignments(listOf(mockCourseDateBlock), listOf(mockCourseDateBlock, mockCourseDateBlock)) +private val mockCourse = EnrolledCourse( + auditAccessExpires = Date(), + created = "created", + certificate = Certificate(""), + mode = "mode", + isActive = true, + progress = Progress.DEFAULT_PROGRESS, + courseStatus = CourseStatus("", emptyList(), "", "Unit name"), + courseAssignments = mockCourseAssignments, + course = EnrolledCourseData( + id = "id", + name = "Looooooooooooooooooooong Course name", + number = "", + org = "Org", + start = Date(), + startDisplay = "", + startType = "", + end = Date(), + dynamicUpgradeDeadline = "", + subscriptionId = "", + coursewareAccess = CoursewareAccess( + true, + "", + "", + "", + "", + "", + ), + media = null, + courseImage = "", + courseAbout = "", + courseSharingUtmParameters = CourseSharingUtmParameters("", ""), + courseUpdates = "", + courseHandouts = "", + discussionUrl = "", + videoOutline = "", + isSelfPaced = false + ) +) +private val mockPagination = Pagination(10, "", 4, "1") +private val mockDashboardCourseList = DashboardCourseList( + pagination = mockPagination, + courses = listOf(mockCourse, mockCourse, mockCourse, mockCourse, mockCourse, mockCourse) +) + +private val mockUserCourses = CourseEnrollments( + enrollments = mockDashboardCourseList, + configs = AppConfig(CourseDatesCalendarSync(true, true, true, true)), + primary = mockCourse +) + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ViewAllItemPreview() { + OpenEdXTheme { + ViewAllItem( + onViewAllClick = {} + ) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, device = Devices.NEXUS_9) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.NEXUS_9) +@Composable +private fun DashboardGalleryViewPreview() { + OpenEdXTheme { + DashboardGalleryView( + uiState = DashboardGalleryUIState.Courses(mockUserCourses, true), + apiHostUrl = "", + uiMessage = null, + updating = false, + hasInternetConnection = false, + onAction = {} + ) + } +} + +@Preview +@Composable +private fun NoCoursesInfoPreview() { + OpenEdXTheme { + NoCoursesInfo() + } +} diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt new file mode 100644 index 000000000..b40e662f3 --- /dev/null +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt @@ -0,0 +1,156 @@ +package org.openedx.courses.presentation + +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.openedx.core.R +import org.openedx.core.config.Config +import org.openedx.core.data.model.CourseEnrollments +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.EnrolledCourse +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.system.notifier.CourseDashboardUpdate +import org.openedx.core.system.notifier.DiscoveryNotifier +import org.openedx.core.system.notifier.NavigationToDiscovery +import org.openedx.dashboard.domain.interactor.DashboardInteractor +import org.openedx.dashboard.presentation.DashboardRouter +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.system.ResourceManager +import org.openedx.foundation.utils.FileUtil + +class DashboardGalleryViewModel( + private val config: Config, + private val interactor: DashboardInteractor, + private val resourceManager: ResourceManager, + private val discoveryNotifier: DiscoveryNotifier, + private val networkConnection: NetworkConnection, + private val fileUtil: FileUtil, + private val dashboardRouter: DashboardRouter, + private val corePreferences: CorePreferences, + private val windowSize: WindowSize, +) : BaseViewModel() { + + val apiHostUrl get() = config.getApiHostURL() + + private val _uiState = + MutableStateFlow(DashboardGalleryUIState.Loading) + val uiState: StateFlow + get() = _uiState.asStateFlow() + + private val _uiMessage = MutableSharedFlow() + val uiMessage: SharedFlow + get() = _uiMessage.asSharedFlow() + + private val _updating = MutableStateFlow(false) + val updating: StateFlow + get() = _updating.asStateFlow() + + val hasInternetConnection: Boolean + get() = networkConnection.isOnline() + + private var isLoading = false + + init { + collectDiscoveryNotifier() + getCourses() + } + + fun getCourses() { + viewModelScope.launch { + try { + if (networkConnection.isOnline()) { + isLoading = true + val pageSize = if (windowSize.isTablet) { + PAGE_SIZE_TABLET + } else { + PAGE_SIZE_PHONE + } + val response = interactor.getMainUserCourses(pageSize) + if (response.primary == null && response.enrollments.courses.isEmpty()) { + _uiState.value = DashboardGalleryUIState.Empty + } else { + _uiState.value = DashboardGalleryUIState.Courses( + response, + corePreferences.isRelativeDatesEnabled + ) + } + } else { + val courseEnrollments = fileUtil.getObjectFromFile() + if (courseEnrollments == null) { + _uiState.value = DashboardGalleryUIState.Empty + } else { + _uiState.value = + DashboardGalleryUIState.Courses( + courseEnrollments.mapToDomain(), + corePreferences.isRelativeDatesEnabled + ) + } + } + } catch (e: Exception) { + if (e.isInternetError()) { + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection))) + } else { + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error))) + } + } finally { + _updating.value = false + isLoading = false + } + } + } + + fun updateCourses(isUpdating: Boolean = true) { + if (isLoading) { + return + } + _updating.value = isUpdating + getCourses() + } + + fun navigateToDiscovery() { + viewModelScope.launch { discoveryNotifier.send(NavigationToDiscovery()) } + } + + fun navigateToAllEnrolledCourses(fragmentManager: FragmentManager) { + dashboardRouter.navigateToAllEnrolledCourses(fragmentManager) + } + + fun navigateToCourseOutline( + fragmentManager: FragmentManager, + enrolledCourse: EnrolledCourse, + openDates: Boolean = false, + resumeBlockId: String = "", + ) { + dashboardRouter.navigateToCourseOutline( + fm = fragmentManager, + courseId = enrolledCourse.course.id, + courseTitle = enrolledCourse.course.name, + openTab = if (openDates) CourseTab.DATES.name else CourseTab.HOME.name, + resumeBlockId = resumeBlockId + ) + } + + private fun collectDiscoveryNotifier() { + viewModelScope.launch { + discoveryNotifier.notifier.collect { + if (it is CourseDashboardUpdate) { + updateCourses() + } + } + } + } + + companion object { + private const val PAGE_SIZE_TABLET = 7 + private const val PAGE_SIZE_PHONE = 5 + } +} diff --git a/dashboard/src/main/java/org/openedx/dashboard/data/repository/DashboardRepository.kt b/dashboard/src/main/java/org/openedx/dashboard/data/repository/DashboardRepository.kt index c85390fa1..17c41e07d 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/data/repository/DashboardRepository.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/data/repository/DashboardRepository.kt @@ -2,14 +2,18 @@ package org.openedx.dashboard.data.repository import org.openedx.core.data.api.CourseApi import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.CourseEnrollments import org.openedx.core.domain.model.DashboardCourseList import org.openedx.core.domain.model.EnrolledCourse import org.openedx.dashboard.data.DashboardDao +import org.openedx.dashboard.domain.CourseStatusFilter +import org.openedx.foundation.utils.FileUtil class DashboardRepository( private val api: CourseApi, private val dao: DashboardDao, - private val preferencesManager: CorePreferences + private val preferencesManager: CorePreferences, + private val fileUtil: FileUtil, ) { suspend fun getEnrolledCourses(page: Int): DashboardCourseList { @@ -30,4 +34,31 @@ class DashboardRepository( val list = dao.readAllData() return list.map { it.mapToDomain() } } + + suspend fun getMainUserCourses(pageSize: Int): CourseEnrollments { + val result = api.getUserCourses( + username = preferencesManager.user?.username ?: "", + pageSize = pageSize + ) + preferencesManager.appConfig = result.configs.mapToDomain() + + fileUtil.saveObjectToFile(result) + return result.mapToDomain() + } + + suspend fun getAllUserCourses(page: Int, status: CourseStatusFilter?): DashboardCourseList { + val user = preferencesManager.user + val result = api.getUserCourses( + username = user?.username ?: "", + page = page, + status = status?.key, + fields = listOf("course_progress") + ) + preferencesManager.appConfig = result.configs.mapToDomain() + + dao.clearCachedData() + dao.insertEnrolledCourseEntity(*result.enrollments.results.map { it.mapToRoomEntity() } + .toTypedArray()) + return result.enrollments.mapToDomain() + } } diff --git a/dashboard/src/main/java/org/openedx/dashboard/domain/CourseStatusFilter.kt b/dashboard/src/main/java/org/openedx/dashboard/domain/CourseStatusFilter.kt new file mode 100644 index 000000000..79a19b89d --- /dev/null +++ b/dashboard/src/main/java/org/openedx/dashboard/domain/CourseStatusFilter.kt @@ -0,0 +1,18 @@ +package org.openedx.dashboard.domain + +import androidx.annotation.StringRes +import androidx.compose.ui.graphics.vector.ImageVector +import org.openedx.core.ui.TabItem +import org.openedx.dashboard.R + +enum class CourseStatusFilter( + val key: String, + @StringRes + override val labelResId: Int, + override val icon: ImageVector? = null, +) : TabItem { + ALL("all", R.string.dashboard_course_filter_all), + IN_PROGRESS("in_progress", R.string.dashboard_course_filter_in_progress), + COMPLETE("completed", R.string.dashboard_course_filter_completed), + EXPIRED("expired", R.string.dashboard_course_filter_expired) +} diff --git a/dashboard/src/main/java/org/openedx/dashboard/domain/interactor/DashboardInteractor.kt b/dashboard/src/main/java/org/openedx/dashboard/domain/interactor/DashboardInteractor.kt index a29c2cc7e..ac1870c7b 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/domain/interactor/DashboardInteractor.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/domain/interactor/DashboardInteractor.kt @@ -2,9 +2,10 @@ package org.openedx.dashboard.domain.interactor import org.openedx.core.domain.model.DashboardCourseList import org.openedx.dashboard.data.repository.DashboardRepository +import org.openedx.dashboard.domain.CourseStatusFilter class DashboardInteractor( - private val repository: DashboardRepository + private val repository: DashboardRepository, ) { suspend fun getEnrolledCourses(page: Int): DashboardCourseList { @@ -12,4 +13,16 @@ class DashboardInteractor( } suspend fun getEnrolledCoursesFromCache() = repository.getEnrolledCoursesFromCache() -} \ No newline at end of file + + suspend fun getMainUserCourses(pageSize: Int) = repository.getMainUserCourses(pageSize) + + suspend fun getAllUserCourses( + page: Int = 1, + status: CourseStatusFilter? = null, + ): DashboardCourseList { + return repository.getAllUserCourses( + page, + status + ) + } +} diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardAnalytics.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardAnalytics.kt index 6a69e7a65..cf7097a64 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardAnalytics.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardAnalytics.kt @@ -1,5 +1,21 @@ package org.openedx.dashboard.presentation interface DashboardAnalytics { + fun logScreenEvent(screenName: String, params: Map) fun dashboardCourseClickedEvent(courseId: String, courseName: String) } + +enum class DashboardAnalyticsEvent(val eventName: String, val biValue: String) { + MY_COURSES( + "MainDashboard:My Courses", + "edx.bi.app.main_dashboard.my_course" + ), + MY_PROGRAMS( + "MainDashboard:My Programs", + "edx.bi.app.main_dashboard.my_program" + ), +} + +enum class DashboardAnalyticsKey(val key: String) { + NAME("name"), +} diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardFragment.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt similarity index 88% rename from dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardFragment.kt rename to dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt index f6bc5c56a..2e7669bb1 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardFragment.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt @@ -24,7 +24,9 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Divider import androidx.compose.material.ExperimentalMaterialApi @@ -35,6 +37,7 @@ import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.filled.AccessTime import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState @@ -42,6 +45,7 @@ import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable @@ -71,36 +75,38 @@ import coil.request.ImageRequest import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.openedx.core.AppUpdateState -import org.openedx.core.UIMessage import org.openedx.core.domain.model.Certificate +import org.openedx.core.domain.model.CourseAssignments import org.openedx.core.domain.model.CourseSharingUtmParameters +import org.openedx.core.domain.model.CourseStatus import org.openedx.core.domain.model.CoursewareAccess import org.openedx.core.domain.model.EnrolledCourse import org.openedx.core.domain.model.EnrolledCourseData +import org.openedx.core.domain.model.Progress import org.openedx.core.presentation.global.app_upgrade.AppUpgradeRecommendedBox -import org.openedx.core.system.notifier.AppUpgradeEvent +import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OfflineModeDialog -import org.openedx.core.ui.Toolbar -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType import org.openedx.core.ui.displayCutoutForLandscape -import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.shouldLoadMore -import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography -import org.openedx.core.ui.windowSizeValue import org.openedx.core.utils.TimeUtils import org.openedx.dashboard.R +import org.openedx.foundation.extension.toImageLink +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue import java.util.Date import org.openedx.core.R as CoreR -class DashboardFragment : Fragment() { +class DashboardListFragment : Fragment() { - private val viewModel by viewModel() + private val viewModel by viewModel() private val router by inject() override fun onCreate(savedInstanceState: Bundle?) { @@ -123,7 +129,7 @@ class DashboardFragment : Fragment() { val canLoadMore by viewModel.canLoadMore.observeAsState(false) val appUpgradeEvent by viewModel.appUpgradeEvent.observeAsState() - MyCoursesScreen( + DashboardListView( windowSize = windowSize, viewModel.apiHostUrl, uiState!!, @@ -137,10 +143,9 @@ class DashboardFragment : Fragment() { onItemClick = { viewModel.dashboardCourseClickedEvent(it.course.id, it.course.name) router.navigateToCourseOutline( - requireParentFragment().parentFragmentManager, - it.course.id, - it.course.name, - it.mode + fm = requireActivity().supportFragmentManager, + courseId = it.course.id, + courseTitle = it.course.name, ) }, onSwipeRefresh = { @@ -155,9 +160,6 @@ class DashboardFragment : Fragment() { AppUpdateState.openPlayMarket(requireContext()) }, ), - onSettingsClick = { - router.navigateToSettings(requireActivity().supportFragmentManager) - } ) } } @@ -166,7 +168,7 @@ class DashboardFragment : Fragment() { @OptIn(ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class) @Composable -internal fun MyCoursesScreen( +internal fun DashboardListView( windowSize: WindowSize, apiHostUrl: String, state: DashboardUIState, @@ -177,7 +179,6 @@ internal fun MyCoursesScreen( onReloadClick: () -> Unit, onSwipeRefresh: () -> Unit, paginationCallback: () -> Unit, - onSettingsClick: () -> Unit, onItemClick: (EnrolledCourse) -> Unit, appUpgradeParameters: AppUpdateState.AppUpgradeParameters, ) { @@ -190,7 +191,7 @@ internal fun MyCoursesScreen( } val scrollState = rememberLazyListState() val firstVisibleIndex = remember { - mutableStateOf(scrollState.firstVisibleItemIndex) + mutableIntStateOf(scrollState.firstVisibleItemIndex) } Scaffold( @@ -241,15 +242,9 @@ internal fun MyCoursesScreen( Column( modifier = Modifier .padding(paddingValues) - .statusBarsInset() .displayCutoutForLandscape(), horizontalAlignment = Alignment.CenterHorizontally ) { - Toolbar( - label = stringResource(id = R.string.dashboard_title), - canShowSettingsIcon = true, - onSettingsClick = onSettingsClick - ) Surface( color = MaterialTheme.appColors.background, @@ -282,12 +277,6 @@ internal fun MyCoursesScreen( state = scrollState, contentPadding = contentPaddings, content = { - item() { - Column { - Header() - Spacer(modifier = Modifier.height(16.dp)) - } - } items(state.courses) { course -> CourseItem( apiHostUrl, @@ -318,7 +307,7 @@ internal fun MyCoursesScreen( is DashboardUIState.Empty -> { Box( modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center + contentAlignment = Alignment.Center, ) { Column( Modifier @@ -326,7 +315,6 @@ internal fun MyCoursesScreen( .then(contentWidth) .then(emptyStatePaddings) ) { - Header() EmptyState() } } @@ -378,7 +366,7 @@ private fun CourseItem( apiHostUrl: String, enrolledCourse: EnrolledCourse, windowSize: WindowSize, - onClick: (EnrolledCourse) -> Unit + onClick: (EnrolledCourse) -> Unit, ) { val imageWidth by remember(key1 = windowSize) { mutableStateOf( @@ -388,7 +376,6 @@ private fun CourseItem( ) ) } - val imageUrl = apiHostUrl.dropLast(1) + enrolledCourse.course.courseImage val context = LocalContext.current Surface( modifier = Modifier @@ -407,7 +394,7 @@ private fun CourseItem( ) { AsyncImage( model = ImageRequest.Builder(LocalContext.current) - .data(imageUrl) + .data(enrolledCourse.course.courseImage.toImageLink(apiHostUrl)) .error(CoreR.drawable.core_no_image_course) .placeholder(CoreR.drawable.core_no_image_course) .build(), @@ -488,33 +475,20 @@ private fun CourseItem( } } -@Composable -private fun Header() { - Text( - modifier = Modifier.testTag("txt_courses_title"), - text = stringResource(id = R.string.dashboard_courses), - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.displaySmall - ) - Text( - modifier = Modifier - .testTag("txt_courses_description") - .padding(top = 4.dp), - text = stringResource(id = R.string.dashboard_welcome_back), - color = MaterialTheme.appColors.textPrimaryVariant, - style = MaterialTheme.appTypography.titleSmall - ) -} - @Composable private fun EmptyState() { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, ) { Column( - Modifier.width(185.dp), - horizontalAlignment = Alignment.CenterHorizontally + Modifier + .fillMaxSize() + .weight(1f), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, ) { Icon( painter = painterResource(id = R.drawable.dashboard_ic_empty), @@ -532,6 +506,13 @@ private fun EmptyState() { textAlign = TextAlign.Center ) } + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.dashboard_pull_to_refresh), + color = MaterialTheme.appColors.textSecondary, + style = MaterialTheme.appTypography.labelSmall, + textAlign = TextAlign.Center + ) } } @@ -539,7 +520,7 @@ private fun EmptyState() { @Preview(uiMode = UI_MODE_NIGHT_YES) @Composable private fun CourseItemPreview() { - OpenEdXTheme() { + OpenEdXTheme { CourseItem( "http://localhost:8000", mockCourseEnrolled, @@ -551,9 +532,9 @@ private fun CourseItemPreview() { @Preview(uiMode = UI_MODE_NIGHT_NO) @Preview(uiMode = UI_MODE_NIGHT_YES) @Composable -private fun MyCoursesScreenDay() { +private fun DashboardListViewPreview() { OpenEdXTheme { - MyCoursesScreen( + DashboardListView( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), apiHostUrl = "http://localhost:8000", state = DashboardUIState.Courses( @@ -574,7 +555,6 @@ private fun MyCoursesScreenDay() { refreshing = false, canLoadMore = false, paginationCallback = {}, - onSettingsClick = {}, appUpgradeParameters = AppUpdateState.AppUpgradeParameters() ) } @@ -583,9 +563,9 @@ private fun MyCoursesScreenDay() { @Preview(uiMode = UI_MODE_NIGHT_NO, device = Devices.NEXUS_9) @Preview(uiMode = UI_MODE_NIGHT_YES, device = Devices.NEXUS_9) @Composable -private fun MyCoursesScreenTabletPreview() { +private fun DashboardListViewTabletPreview() { OpenEdXTheme { - MyCoursesScreen( + DashboardListView( windowSize = WindowSize(WindowType.Medium, WindowType.Medium), apiHostUrl = "http://localhost:8000", state = DashboardUIState.Courses( @@ -606,18 +586,44 @@ private fun MyCoursesScreenTabletPreview() { refreshing = false, canLoadMore = false, paginationCallback = {}, - onSettingsClick = {}, appUpgradeParameters = AppUpdateState.AppUpgradeParameters() ) } } + +@Preview(uiMode = UI_MODE_NIGHT_NO) +@Preview(uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun EmptyStatePreview() { + OpenEdXTheme { + DashboardListView( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + apiHostUrl = "http://localhost:8000", + state = DashboardUIState.Empty, + uiMessage = null, + onSwipeRefresh = {}, + onItemClick = {}, + onReloadClick = {}, + hasInternetConnection = true, + refreshing = false, + canLoadMore = false, + paginationCallback = {}, + appUpgradeParameters = AppUpdateState.AppUpgradeParameters() + ) + } +} + +private val mockCourseAssignments = CourseAssignments(null, emptyList()) private val mockCourseEnrolled = EnrolledCourse( auditAccessExpires = Date(), created = "created", certificate = Certificate(""), mode = "mode", isActive = true, + progress = Progress.DEFAULT_PROGRESS, + courseStatus = CourseStatus("", emptyList(), "", ""), + courseAssignments = mockCourseAssignments, course = EnrolledCourseData( id = "id", name = "name", diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardViewModel.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListViewModel.kt similarity index 89% rename from dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardViewModel.kt rename to dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListViewModel.kt index 0ec06a2c3..e04ddb258 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardViewModel.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListViewModel.kt @@ -5,30 +5,29 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.launch -import org.openedx.core.BaseViewModel import org.openedx.core.R -import org.openedx.core.SingleEventLiveData -import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.domain.model.EnrolledCourse -import org.openedx.core.extension.isInternetError -import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection -import org.openedx.core.system.notifier.AppUpgradeEvent -import org.openedx.core.system.notifier.AppUpgradeNotifier import org.openedx.core.system.notifier.CourseDashboardUpdate import org.openedx.core.system.notifier.DiscoveryNotifier +import org.openedx.core.system.notifier.app.AppNotifier +import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.dashboard.domain.interactor.DashboardInteractor +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.SingleEventLiveData +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager - -class DashboardViewModel( +class DashboardListViewModel( private val config: Config, private val networkConnection: NetworkConnection, private val interactor: DashboardInteractor, private val resourceManager: ResourceManager, private val discoveryNotifier: DiscoveryNotifier, private val analytics: DashboardAnalytics, - private val appUpgradeNotifier: AppUpgradeNotifier + private val appNotifier: AppNotifier ) : BaseViewModel() { private val coursesList = mutableListOf() @@ -83,6 +82,9 @@ class DashboardViewModel( } fun updateCourses() { + if (isLoading) { + return + } viewModelScope.launch { try { _updating.value = true @@ -168,8 +170,10 @@ class DashboardViewModel( private fun collectAppUpgradeEvent() { viewModelScope.launch { - appUpgradeNotifier.notifier.collect { event -> - _appUpgradeEvent.value = event + appNotifier.notifier.collect { event -> + if (event is AppUpgradeEvent) { + _appUpgradeEvent.value = event + } } } } diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardRouter.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardRouter.kt index b0b0740d3..d96744ff1 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardRouter.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardRouter.kt @@ -1,5 +1,6 @@ package org.openedx.dashboard.presentation +import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager interface DashboardRouter { @@ -8,8 +9,15 @@ interface DashboardRouter { fm: FragmentManager, courseId: String, courseTitle: String, - enrollmentMode: String, + openTab: String = "", + resumeBlockId: String = "" ) fun navigateToSettings(fm: FragmentManager) + + fun navigateToCourseSearch(fm: FragmentManager, querySearch: String) + + fun navigateToAllEnrolledCourses(fm: FragmentManager) + + fun getProgramFragment(): Fragment } diff --git a/dashboard/src/main/java/org/openedx/learn/LearnType.kt b/dashboard/src/main/java/org/openedx/learn/LearnType.kt new file mode 100644 index 000000000..08100ef35 --- /dev/null +++ b/dashboard/src/main/java/org/openedx/learn/LearnType.kt @@ -0,0 +1,9 @@ +package org.openedx.learn + +import androidx.annotation.StringRes +import org.openedx.dashboard.R + +enum class LearnType(@StringRes val title: Int) { + COURSES(R.string.dashboard_courses), + PROGRAMS(R.string.dashboard_programs) +} diff --git a/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt b/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt new file mode 100644 index 000000000..a0e304170 --- /dev/null +++ b/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt @@ -0,0 +1,312 @@ +package org.openedx.learn.presentation + +import android.os.Bundle +import android.view.View +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.ManageAccounts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.viewpager2.widget.ViewPager2 +import org.koin.androidx.compose.koinViewModel +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.openedx.core.adapter.NavigationFragmentAdapter +import org.openedx.core.presentation.global.viewBinding +import org.openedx.core.ui.crop +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.appTypography +import org.openedx.dashboard.R +import org.openedx.dashboard.databinding.FragmentLearnBinding +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue +import org.openedx.learn.LearnType +import org.openedx.core.R as CoreR + +class LearnFragment : Fragment(R.layout.fragment_learn) { + + private val binding by viewBinding(FragmentLearnBinding::bind) + private val viewModel by viewModel() + private lateinit var adapter: NavigationFragmentAdapter + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initViewPager() + val openTab = requireArguments().getString(ARG_OPEN_TAB, LearnTab.COURSES.name) + val defaultLearnType = if (openTab == LearnTab.PROGRAMS.name) { + LearnType.PROGRAMS + } else { + LearnType.COURSES + } + binding.header.setContent { + OpenEdXTheme { + Header( + fragmentManager = requireParentFragment().parentFragmentManager, + defaultLearnType = defaultLearnType, + viewPager = binding.viewPager + ) + } + } + } + + private fun initViewPager() { + binding.viewPager.orientation = ViewPager2.ORIENTATION_HORIZONTAL + binding.viewPager.offscreenPageLimit = 2 + + adapter = NavigationFragmentAdapter(this).apply { + addFragment(viewModel.getDashboardFragment) + addFragment(viewModel.getProgramFragment) + } + binding.viewPager.adapter = adapter + binding.viewPager.setUserInputEnabled(false) + + binding.viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { + override fun onPageSelected(position: Int) { + super.onPageSelected(position) + if (LearnType.COURSES.ordinal == position) { + viewModel.logMyCoursesTabClickedEvent() + } else { + viewModel.logMyProgramsTabClickedEvent() + } + } + }) + } + + companion object { + private const val ARG_OPEN_TAB = "open_tab" + fun newInstance( + openTab: String = LearnTab.COURSES.name + ): LearnFragment { + val fragment = LearnFragment() + fragment.arguments = bundleOf( + ARG_OPEN_TAB to openTab + ) + return fragment + } + } +} + +@Composable +private fun Header( + fragmentManager: FragmentManager, + defaultLearnType: LearnType, + viewPager: ViewPager2, +) { + val viewModel: LearnViewModel = koinViewModel() + val windowSize = rememberWindowSize() + val contentWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 650.dp), + compact = Modifier.fillMaxWidth(), + ) + ) + } + + Column( + modifier = Modifier + .background(MaterialTheme.appColors.background) + .statusBarsInset() + .displayCutoutForLandscape() + .then(contentWidth), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Title( + label = stringResource(id = R.string.dashboard_learn), + onSettingsClick = { + viewModel.onSettingsClick(fragmentManager) + } + ) + if (viewModel.isProgramTypeWebView) { + LearnDropdownMenu( + modifier = Modifier + .align(Alignment.Start) + .padding(horizontal = 16.dp), + defaultLearnType = defaultLearnType, + viewPager = viewPager + ) + } + } +} + +@Composable +private fun Title( + modifier: Modifier = Modifier, + label: String, + onSettingsClick: () -> Unit, +) { + Box( + modifier = modifier.fillMaxWidth() + ) { + Text( + modifier = Modifier + .align(Alignment.CenterStart) + .padding(start = 16.dp), + text = label, + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.headlineBold + ) + IconButton( + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = 12.dp), + onClick = { + onSettingsClick() + } + ) { + Icon( + imageVector = Icons.Default.ManageAccounts, + tint = MaterialTheme.appColors.textAccent, + contentDescription = stringResource(id = CoreR.string.core_accessibility_settings) + ) + } + } +} + +@Composable +private fun LearnDropdownMenu( + modifier: Modifier = Modifier, + defaultLearnType: LearnType, + viewPager: ViewPager2, +) { + var expanded by remember { mutableStateOf(false) } + var currentValue by remember { mutableStateOf(defaultLearnType) } + val iconRotation by animateFloatAsState( + targetValue = if (expanded) 180f else 0f, + label = "" + ) + + LaunchedEffect(currentValue) { + viewPager.setCurrentItem( + when (currentValue) { + LearnType.COURSES -> 0 + LearnType.PROGRAMS -> 1 + }, false + ) + } + + Column( + modifier = modifier + ) { + Row( + modifier = Modifier + .clickable { + expanded = true + }, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(id = currentValue.title), + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.titleSmall + ) + Icon( + modifier = Modifier.rotate(iconRotation), + imageVector = Icons.Default.ExpandMore, + tint = MaterialTheme.appColors.textDark, + contentDescription = null + ) + } + + MaterialTheme( + colors = MaterialTheme.colors.copy(surface = MaterialTheme.appColors.background), + shapes = MaterialTheme.shapes.copy( + medium = RoundedCornerShape( + bottomStart = 8.dp, + bottomEnd = 8.dp + ) + ) + ) { + DropdownMenu( + modifier = Modifier + .crop(vertical = 8.dp) + .widthIn(min = 182.dp), + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + for (learnType in LearnType.entries) { + val background: Color + val textColor: Color + if (currentValue == learnType) { + background = MaterialTheme.appColors.primary + textColor = MaterialTheme.appColors.primaryButtonText + } else { + background = Color.Transparent + textColor = MaterialTheme.appColors.textDark + } + DropdownMenuItem( + modifier = Modifier + .background(background), + onClick = { + currentValue = learnType + expanded = false + } + ) { + Text( + text = stringResource(id = learnType.title), + style = MaterialTheme.appTypography.titleSmall, + color = textColor + ) + } + } + } + } + } +} + +@Preview +@Composable +private fun HeaderPreview() { + OpenEdXTheme { + Title( + label = stringResource(id = R.string.dashboard_learn), + onSettingsClick = {} + ) + } +} + +@Preview +@Composable +private fun LearnDropdownMenuPreview() { + OpenEdXTheme { + val context = LocalContext.current + LearnDropdownMenu( + defaultLearnType = LearnType.COURSES, + viewPager = ViewPager2(context) + ) + } +} diff --git a/dashboard/src/main/java/org/openedx/learn/presentation/LearnTab.kt b/dashboard/src/main/java/org/openedx/learn/presentation/LearnTab.kt new file mode 100644 index 000000000..c7498298a --- /dev/null +++ b/dashboard/src/main/java/org/openedx/learn/presentation/LearnTab.kt @@ -0,0 +1,6 @@ +package org.openedx.learn.presentation + +enum class LearnTab { + COURSES, + PROGRAMS +} diff --git a/dashboard/src/main/java/org/openedx/learn/presentation/LearnViewModel.kt b/dashboard/src/main/java/org/openedx/learn/presentation/LearnViewModel.kt new file mode 100644 index 000000000..ee38caf75 --- /dev/null +++ b/dashboard/src/main/java/org/openedx/learn/presentation/LearnViewModel.kt @@ -0,0 +1,45 @@ +package org.openedx.learn.presentation + +import androidx.fragment.app.FragmentManager +import org.openedx.DashboardNavigator +import org.openedx.core.config.Config +import org.openedx.dashboard.presentation.DashboardAnalytics +import org.openedx.dashboard.presentation.DashboardAnalyticsEvent +import org.openedx.dashboard.presentation.DashboardAnalyticsKey +import org.openedx.dashboard.presentation.DashboardRouter +import org.openedx.foundation.presentation.BaseViewModel + +class LearnViewModel( + private val config: Config, + private val dashboardRouter: DashboardRouter, + private val analytics: DashboardAnalytics, +) : BaseViewModel() { + + private val dashboardType get() = config.getDashboardConfig().getType() + val isProgramTypeWebView get() = config.getProgramConfig().isViewTypeWebView() + + fun onSettingsClick(fragmentManager: FragmentManager) { + dashboardRouter.navigateToSettings(fragmentManager) + } + + val getDashboardFragment get() = DashboardNavigator(dashboardType).getDashboardFragment() + + val getProgramFragment get() = dashboardRouter.getProgramFragment() + + fun logMyCoursesTabClickedEvent() { + logScreenEvent(DashboardAnalyticsEvent.MY_COURSES) + } + + fun logMyProgramsTabClickedEvent() { + logScreenEvent(DashboardAnalyticsEvent.MY_PROGRAMS) + } + + private fun logScreenEvent(event: DashboardAnalyticsEvent) { + analytics.logScreenEvent( + screenName = event.eventName, + params = buildMap { + put(DashboardAnalyticsKey.NAME.key, event.biValue) + } + ) + } +} diff --git a/dashboard/src/main/res/layout/fragment_learn.xml b/dashboard/src/main/res/layout/fragment_learn.xml new file mode 100644 index 000000000..c6556b364 --- /dev/null +++ b/dashboard/src/main/res/layout/fragment_learn.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/dashboard/src/main/res/values-uk/strings.xml b/dashboard/src/main/res/values-uk/strings.xml deleted file mode 100644 index a7b3ef9d3..000000000 --- a/dashboard/src/main/res/values-uk/strings.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - Мої курси - Курси - Ласкаво просимо назад. Продовжуймо навчатися. - You are not enrolled in any courses yet. - - \ No newline at end of file diff --git a/dashboard/src/main/res/values/strings.xml b/dashboard/src/main/res/values/strings.xml index 583851adc..01979f21d 100644 --- a/dashboard/src/main/res/values/strings.xml +++ b/dashboard/src/main/res/values/strings.xml @@ -1,7 +1,28 @@ - - Dashboard + Courses - Welcome back. Let\'s keep learning. You are not enrolled in any courses yet. + Learn + Programs + Course %1$s + Start Course + Resume Course + View All Courses (%1$d) + View All + %1$s %2$s + All + In Progress + Completed + Expired + All Courses + No Courses + You are not currently enrolled in any courses, would you like to explore the course catalog? + Find a Course + No %1$s Courses + Swipe down to refresh + + + %1$d Past Due Assignment + %1$d Past Due Assignments + diff --git a/dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardViewModelTest.kt b/dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardListViewModelTest.kt similarity index 86% rename from dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardViewModelTest.kt rename to dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardListViewModelTest.kt index 6fdfdec22..1eb943cca 100644 --- a/dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardViewModelTest.kt +++ b/dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardListViewModelTest.kt @@ -25,20 +25,20 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule import org.openedx.core.R -import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.domain.model.DashboardCourseList import org.openedx.core.domain.model.Pagination -import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection -import org.openedx.core.system.notifier.AppUpgradeNotifier import org.openedx.core.system.notifier.CourseDashboardUpdate import org.openedx.core.system.notifier.DiscoveryNotifier +import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.dashboard.domain.interactor.DashboardInteractor +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException @OptIn(ExperimentalCoroutinesApi::class) -class DashboardViewModelTest { +class DashboardListViewModelTest { @get:Rule val testInstantTaskExecutorRule: TestRule = InstantTaskExecutorRule() @@ -51,7 +51,7 @@ class DashboardViewModelTest { private val networkConnection = mockk() private val discoveryNotifier = mockk() private val analytics = mockk() - private val appUpgradeNotifier = mockk() + private val appNotifier = mockk() private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" @@ -66,7 +66,7 @@ class DashboardViewModelTest { Dispatchers.setMain(dispatcher) every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong - every { appUpgradeNotifier.notifier } returns emptyFlow() + every { appNotifier.notifier } returns emptyFlow() every { config.getApiHostURL() } returns "http://localhost:8000" } @@ -77,14 +77,14 @@ class DashboardViewModelTest { @Test fun `getCourses no internet connection`() = runTest { - val viewModel = DashboardViewModel( + val viewModel = DashboardListViewModel( config, networkConnection, interactor, resourceManager, discoveryNotifier, analytics, - appUpgradeNotifier + appNotifier ) every { networkConnection.isOnline() } returns true coEvery { interactor.getEnrolledCourses(any()) } throws UnknownHostException() @@ -92,7 +92,7 @@ class DashboardViewModelTest { coVerify(exactly = 1) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assertEquals(noInternet, message?.message) @@ -101,14 +101,14 @@ class DashboardViewModelTest { @Test fun `getCourses unknown error`() = runTest { - val viewModel = DashboardViewModel( + val viewModel = DashboardListViewModel( config, networkConnection, interactor, resourceManager, discoveryNotifier, analytics, - appUpgradeNotifier + appNotifier ) every { networkConnection.isOnline() } returns true coEvery { interactor.getEnrolledCourses(any()) } throws Exception() @@ -116,7 +116,7 @@ class DashboardViewModelTest { coVerify(exactly = 1) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assertEquals(somethingWrong, message?.message) @@ -125,14 +125,14 @@ class DashboardViewModelTest { @Test fun `getCourses from network`() = runTest { - val viewModel = DashboardViewModel( + val viewModel = DashboardListViewModel( config, networkConnection, interactor, resourceManager, discoveryNotifier, analytics, - appUpgradeNotifier + appNotifier ) every { networkConnection.isOnline() } returns true coEvery { interactor.getEnrolledCourses(any()) } returns dashboardCourseList @@ -141,7 +141,7 @@ class DashboardViewModelTest { coVerify(exactly = 1) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } assert(viewModel.uiMessage.value == null) assert(viewModel.uiState.value is DashboardUIState.Courses) @@ -149,14 +149,14 @@ class DashboardViewModelTest { @Test fun `getCourses from network with next page`() = runTest { - val viewModel = DashboardViewModel( + val viewModel = DashboardListViewModel( config, networkConnection, interactor, resourceManager, discoveryNotifier, analytics, - appUpgradeNotifier + appNotifier ) every { networkConnection.isOnline() } returns true coEvery { interactor.getEnrolledCourses(any()) } returns dashboardCourseList.copy( @@ -173,7 +173,7 @@ class DashboardViewModelTest { coVerify(exactly = 1) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } assert(viewModel.uiMessage.value == null) assert(viewModel.uiState.value is DashboardUIState.Courses) @@ -183,21 +183,21 @@ class DashboardViewModelTest { fun `getCourses from cache`() = runTest { every { networkConnection.isOnline() } returns false coEvery { interactor.getEnrolledCoursesFromCache() } returns listOf(mockk()) - val viewModel = DashboardViewModel( + val viewModel = DashboardListViewModel( config, networkConnection, interactor, resourceManager, discoveryNotifier, analytics, - appUpgradeNotifier + appNotifier ) advanceUntilIdle() coVerify(exactly = 0) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 1) { interactor.getEnrolledCoursesFromCache() } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } assert(viewModel.uiMessage.value == null) assert(viewModel.uiState.value is DashboardUIState.Courses) @@ -207,14 +207,14 @@ class DashboardViewModelTest { fun `updateCourses no internet error`() = runTest { every { networkConnection.isOnline() } returns true coEvery { interactor.getEnrolledCourses(any()) } returns dashboardCourseList - val viewModel = DashboardViewModel( + val viewModel = DashboardListViewModel( config, networkConnection, interactor, resourceManager, discoveryNotifier, analytics, - appUpgradeNotifier + appNotifier ) coEvery { interactor.getEnrolledCourses(any()) } throws UnknownHostException() @@ -223,7 +223,7 @@ class DashboardViewModelTest { coVerify(exactly = 2) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assertEquals(noInternet, message?.message) @@ -235,14 +235,14 @@ class DashboardViewModelTest { fun `updateCourses unknown exception`() = runTest { every { networkConnection.isOnline() } returns true coEvery { interactor.getEnrolledCourses(any()) } returns dashboardCourseList - val viewModel = DashboardViewModel( + val viewModel = DashboardListViewModel( config, networkConnection, interactor, resourceManager, discoveryNotifier, analytics, - appUpgradeNotifier + appNotifier ) coEvery { interactor.getEnrolledCourses(any()) } throws Exception() @@ -251,7 +251,7 @@ class DashboardViewModelTest { coVerify(exactly = 2) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assertEquals(somethingWrong, message?.message) @@ -263,14 +263,14 @@ class DashboardViewModelTest { fun `updateCourses success`() = runTest { every { networkConnection.isOnline() } returns true coEvery { interactor.getEnrolledCourses(any()) } returns dashboardCourseList - val viewModel = DashboardViewModel( + val viewModel = DashboardListViewModel( config, networkConnection, interactor, resourceManager, discoveryNotifier, analytics, - appUpgradeNotifier + appNotifier ) viewModel.updateCourses() @@ -278,7 +278,7 @@ class DashboardViewModelTest { coVerify(exactly = 2) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } assert(viewModel.uiMessage.value == null) assert(viewModel.updating.value == false) @@ -296,14 +296,14 @@ class DashboardViewModelTest { "" ) ) - val viewModel = DashboardViewModel( + val viewModel = DashboardListViewModel( config, networkConnection, interactor, resourceManager, discoveryNotifier, analytics, - appUpgradeNotifier + appNotifier ) viewModel.updateCourses() @@ -311,7 +311,7 @@ class DashboardViewModelTest { coVerify(exactly = 2) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } assert(viewModel.uiMessage.value == null) assert(viewModel.updating.value == false) @@ -321,14 +321,14 @@ class DashboardViewModelTest { @Test fun `CourseDashboardUpdate notifier test`() = runTest { coEvery { discoveryNotifier.notifier } returns flow { emit(CourseDashboardUpdate()) } - val viewModel = DashboardViewModel( + val viewModel = DashboardListViewModel( config, networkConnection, interactor, resourceManager, discoveryNotifier, analytics, - appUpgradeNotifier + appNotifier ) val mockLifeCycleOwner: LifecycleOwner = mockk() @@ -339,7 +339,7 @@ class DashboardViewModelTest { advanceUntilIdle() coVerify(exactly = 1) { interactor.getEnrolledCourses(any()) } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } } } diff --git a/dashboard/src/test/java/org/openedx/dashboard/presentation/LearnViewModelTest.kt b/dashboard/src/test/java/org/openedx/dashboard/presentation/LearnViewModelTest.kt new file mode 100644 index 000000000..090dc7987 --- /dev/null +++ b/dashboard/src/test/java/org/openedx/dashboard/presentation/LearnViewModelTest.kt @@ -0,0 +1,80 @@ +package org.openedx.dashboard.presentation + +import androidx.fragment.app.FragmentManager +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.openedx.DashboardNavigator +import org.openedx.core.config.Config +import org.openedx.core.config.DashboardConfig +import org.openedx.learn.presentation.LearnViewModel + +class LearnViewModelTest { + + private val config = mockk() + private val dashboardRouter = mockk(relaxed = true) + private val analytics = mockk(relaxed = true) + private val fragmentManager = mockk() + + private val viewModel = LearnViewModel(config, dashboardRouter, analytics) + + @Test + fun `onSettingsClick calls navigateToSettings`() = runTest { + viewModel.onSettingsClick(fragmentManager) + verify { dashboardRouter.navigateToSettings(fragmentManager) } + } + + @Test + fun `getDashboardFragment returns correct fragment based on dashboardType`() = runTest { + DashboardConfig.DashboardType.entries.forEach { type -> + every { config.getDashboardConfig().getType() } returns type + val dashboardFragment = viewModel.getDashboardFragment + assertEquals(DashboardNavigator(type).getDashboardFragment()::class, dashboardFragment::class) + } + } + + + @Test + fun `getProgramFragment returns correct program fragment`() = runTest { + viewModel.getProgramFragment + verify { dashboardRouter.getProgramFragment() } + } + + @Test + fun `isProgramTypeWebView returns correct view type`() = runTest { + every { config.getProgramConfig().isViewTypeWebView() } returns true + assertTrue(viewModel.isProgramTypeWebView) + } + + @Test + fun `logMyCoursesTabClickedEvent logs correct analytics event`() = runTest { + viewModel.logMyCoursesTabClickedEvent() + + verify { + analytics.logScreenEvent( + screenName = DashboardAnalyticsEvent.MY_COURSES.eventName, + params = match { + it[DashboardAnalyticsKey.NAME.key] == DashboardAnalyticsEvent.MY_COURSES.biValue + } + ) + } + } + + @Test + fun `logMyProgramsTabClickedEvent logs correct analytics event`() = runTest { + viewModel.logMyProgramsTabClickedEvent() + + verify { + analytics.logScreenEvent( + screenName = DashboardAnalyticsEvent.MY_PROGRAMS.eventName, + params = match { + it[DashboardAnalyticsKey.NAME.key] == DashboardAnalyticsEvent.MY_PROGRAMS.biValue + } + ) + } + } +} diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index e1582bfcf..19e53ef73 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -25,22 +25,20 @@ DISCOVERY: PROGRAM: TYPE: 'native' WEBVIEW: - PROGRAM_URL: '' - PROGRAM_DETAIL_URL_TEMPLATE: '' + BASE_URL: '' + PROGRAM_DETAIL_TEMPLATE: '' + +DASHBOARD: + TYPE: 'gallery' FIREBASE: ENABLED: false - ANALYTICS_SOURCE: '' # segment | none CLOUD_MESSAGING_ENABLED: false PROJECT_NUMBER: '' PROJECT_ID: '' APPLICATION_ID: '' #App ID field from the Firebase console or mobilesdk_app_id from the google-services.json file. API_KEY: '' -SEGMENT_IO: - ENABLED: false - SEGMENT_IO_WRITE_KEY: '' - BRAZE: ENABLED: false PUSH_NOTIFICATIONS_ENABLED: false @@ -69,13 +67,18 @@ BRANCH: #Platform names PLATFORM_NAME: "OpenEdX" PLATFORM_FULL_NAME: "OpenEdX" +#App sourceSets dir +THEME_DIRECTORY: "openedx" #tokenType enum accepts JWT and BEARER only TOKEN_TYPE: "JWT" #feature flag for activating What’s New feature WHATS_NEW_ENABLED: false #feature flag enable Social Login buttons SOCIAL_AUTH_ENABLED: false +#feature flag to enable registration from app +REGISTRATION_ENABLED: true #Course navigation feature flags -COURSE_NESTED_LIST_ENABLED: false -COURSE_UNIT_PROGRESS_ENABLED: false - +UI_COMPONENTS: + COURSE_DROPDOWN_NAVIGATION_ENABLED: false + COURSE_UNIT_PROGRESS_ENABLED: false + COURSE_DOWNLOAD_QUEUE_SCREEN: false diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml index f7afc7bed..19e53ef73 100644 --- a/default_config/prod/config.yaml +++ b/default_config/prod/config.yaml @@ -25,22 +25,20 @@ DISCOVERY: PROGRAM: TYPE: 'native' WEBVIEW: - PROGRAM_URL: '' - PROGRAM_DETAIL_URL_TEMPLATE: '' + BASE_URL: '' + PROGRAM_DETAIL_TEMPLATE: '' + +DASHBOARD: + TYPE: 'gallery' FIREBASE: ENABLED: false - ANALYTICS_SOURCE: '' # segment | none CLOUD_MESSAGING_ENABLED: false PROJECT_NUMBER: '' PROJECT_ID: '' APPLICATION_ID: '' #App ID field from the Firebase console or mobilesdk_app_id from the google-services.json file. API_KEY: '' -SEGMENT_IO: - ENABLED: false - SEGMENT_IO_WRITE_KEY: '' - BRAZE: ENABLED: false PUSH_NOTIFICATIONS_ENABLED: false @@ -69,12 +67,18 @@ BRANCH: #Platform names PLATFORM_NAME: "OpenEdX" PLATFORM_FULL_NAME: "OpenEdX" +#App sourceSets dir +THEME_DIRECTORY: "openedx" #tokenType enum accepts JWT and BEARER only TOKEN_TYPE: "JWT" #feature flag for activating What’s New feature WHATS_NEW_ENABLED: false #feature flag enable Social Login buttons SOCIAL_AUTH_ENABLED: false +#feature flag to enable registration from app +REGISTRATION_ENABLED: true #Course navigation feature flags -COURSE_NESTED_LIST_ENABLED: false -COURSE_UNIT_PROGRESS_ENABLED: false +UI_COMPONENTS: + COURSE_DROPDOWN_NAVIGATION_ENABLED: false + COURSE_UNIT_PROGRESS_ENABLED: false + COURSE_DOWNLOAD_QUEUE_SCREEN: false diff --git a/default_config/stage/config.yaml b/default_config/stage/config.yaml index f7afc7bed..19e53ef73 100644 --- a/default_config/stage/config.yaml +++ b/default_config/stage/config.yaml @@ -25,22 +25,20 @@ DISCOVERY: PROGRAM: TYPE: 'native' WEBVIEW: - PROGRAM_URL: '' - PROGRAM_DETAIL_URL_TEMPLATE: '' + BASE_URL: '' + PROGRAM_DETAIL_TEMPLATE: '' + +DASHBOARD: + TYPE: 'gallery' FIREBASE: ENABLED: false - ANALYTICS_SOURCE: '' # segment | none CLOUD_MESSAGING_ENABLED: false PROJECT_NUMBER: '' PROJECT_ID: '' APPLICATION_ID: '' #App ID field from the Firebase console or mobilesdk_app_id from the google-services.json file. API_KEY: '' -SEGMENT_IO: - ENABLED: false - SEGMENT_IO_WRITE_KEY: '' - BRAZE: ENABLED: false PUSH_NOTIFICATIONS_ENABLED: false @@ -69,12 +67,18 @@ BRANCH: #Platform names PLATFORM_NAME: "OpenEdX" PLATFORM_FULL_NAME: "OpenEdX" +#App sourceSets dir +THEME_DIRECTORY: "openedx" #tokenType enum accepts JWT and BEARER only TOKEN_TYPE: "JWT" #feature flag for activating What’s New feature WHATS_NEW_ENABLED: false #feature flag enable Social Login buttons SOCIAL_AUTH_ENABLED: false +#feature flag to enable registration from app +REGISTRATION_ENABLED: true #Course navigation feature flags -COURSE_NESTED_LIST_ENABLED: false -COURSE_UNIT_PROGRESS_ENABLED: false +UI_COMPONENTS: + COURSE_DROPDOWN_NAVIGATION_ENABLED: false + COURSE_UNIT_PROGRESS_ENABLED: false + COURSE_DOWNLOAD_QUEUE_SCREEN: false diff --git a/discovery/build.gradle b/discovery/build.gradle index 881d8c05a..d9c4419fc 100644 --- a/discovery/build.gradle +++ b/discovery/build.gradle @@ -2,7 +2,8 @@ plugins { id 'com.android.library' id 'org.jetbrains.kotlin.android' id 'kotlin-parcelize' - id 'kotlin-kapt' + id 'com.google.devtools.ksp' + id "org.jetbrains.kotlin.plugin.compose" } android { @@ -31,15 +32,13 @@ android { } kotlinOptions { jvmTarget = JavaVersion.VERSION_17 + freeCompilerArgs = List.of("-Xstring-concat=inline") } buildFeatures { viewBinding true compose true } - composeOptions { - kotlinCompilerExtensionVersion = "$compose_compiler_version" - } flavorDimensions += "env" productFlavors { @@ -58,17 +57,14 @@ android { dependencies { implementation project(path: ':core') - kapt "androidx.room:room-compiler:$room_version" + ksp "androidx.room:room-compiler:$room_version" implementation 'androidx.activity:activity-compose:1.8.1' - androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' - androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version" - debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version" + androidTestImplementation 'androidx.test.ext:junit:1.2.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' testImplementation "junit:junit:$junit_version" testImplementation "io.mockk:mockk:$mockk_version" testImplementation "io.mockk:mockk-android:$mockk_version" - testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version" testImplementation "androidx.arch.core:core-testing:$android_arch_version" } diff --git a/discovery/proguard-rules.pro b/discovery/proguard-rules.pro index 481bb4348..cdb308aa0 100644 --- a/discovery/proguard-rules.pro +++ b/discovery/proguard-rules.pro @@ -1,21 +1,7 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +# Prevent shrinking, optimization, and obfuscation of the library when consumed by other modules. +# This ensures that all classes and methods remain available for use by the consumer of the library. +# Disabling these steps at the library level is important because the main app module will handle +# shrinking, optimization, and obfuscation for the entire application, including this library. +-dontshrink +-dontoptimize +-dontobfuscate diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryAnalytics.kt b/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryAnalytics.kt index 4540a0d7f..23994a3fb 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryAnalytics.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryAnalytics.kt @@ -5,6 +5,7 @@ interface DiscoveryAnalytics { fun discoveryCourseSearchEvent(label: String, coursesCount: Int) fun discoveryCourseClickedEvent(courseId: String, courseName: String) fun logEvent(event: String, params: Map) + fun logScreenEvent(screenName: String, params: Map) } enum class DiscoveryAnalyticsEvent(val eventName: String, val biValue: String) { diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryRouter.kt b/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryRouter.kt index e1c4baa74..2e67af44a 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryRouter.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryRouter.kt @@ -8,7 +8,6 @@ interface DiscoveryRouter { fm: FragmentManager, courseId: String, courseTitle: String, - enrollmentMode: String ) fun navigateToLogistration(fm: FragmentManager, courseId: String?) diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt index ee99a5bb3..5efb7a5b0 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt @@ -59,30 +59,30 @@ import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.openedx.core.AppUpdateState import org.openedx.core.AppUpdateState.wasUpdateDialogClosed -import org.openedx.core.UIMessage import org.openedx.core.domain.model.Media import org.openedx.core.presentation.dialog.appupgrade.AppUpgradeDialogFragment import org.openedx.core.presentation.global.app_upgrade.AppUpgradeRecommendedBox -import org.openedx.core.system.notifier.AppUpgradeEvent +import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.core.ui.AuthButtonsPanel import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OfflineModeDialog import org.openedx.core.ui.StaticSearchBar import org.openedx.core.ui.Toolbar -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType import org.openedx.core.ui.displayCutoutForLandscape -import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.shouldLoadMore import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography -import org.openedx.core.ui.windowSizeValue import org.openedx.discovery.R import org.openedx.discovery.domain.model.Course import org.openedx.discovery.presentation.ui.DiscoveryCourseItem +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue class NativeDiscoveryFragment : Fragment() { @@ -117,6 +117,7 @@ class NativeDiscoveryFragment : Fragment() { hasInternetConnection = viewModel.hasInternetConnection, canShowBackButton = viewModel.canShowBackButton, isUserLoggedIn = viewModel.isUserLoggedIn, + isRegistrationEnabled = viewModel.isRegistrationEnabled, appUpgradeParameters = AppUpdateState.AppUpgradeParameters( appUpgradeEvent = appUpgradeEvent, wasUpdateDialogClosed = wasUpdateDialogClosed, @@ -209,6 +210,7 @@ internal fun DiscoveryScreen( hasInternetConnection: Boolean, canShowBackButton: Boolean, isUserLoggedIn: Boolean, + isRegistrationEnabled: Boolean, appUpgradeParameters: AppUpdateState.AppUpgradeParameters, onSearchClick: () -> Unit, onSwipeRefresh: () -> Unit, @@ -252,7 +254,8 @@ internal fun DiscoveryScreen( ) { AuthButtonsPanel( onRegisterClick = onRegisterClick, - onSignInClick = onSignInClick + onSignInClick = onSignInClick, + showRegisterButton = isRegistrationEnabled ) } } @@ -517,6 +520,7 @@ private fun DiscoveryScreenPreview() { refreshing = false, hasInternetConnection = true, isUserLoggedIn = false, + isRegistrationEnabled = true, appUpgradeParameters = AppUpdateState.AppUpgradeParameters(), onSignInClick = {}, onRegisterClick = {}, @@ -558,6 +562,7 @@ private fun DiscoveryScreenTabletPreview() { refreshing = false, hasInternetConnection = true, isUserLoggedIn = true, + isRegistrationEnabled = true, appUpgradeParameters = AppUpdateState.AppUpgradeParameters(), onSignInClick = {}, onRegisterClick = {}, diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryViewModel.kt b/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryViewModel.kt index 271e05535..923846e8a 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryViewModel.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryViewModel.kt @@ -6,19 +6,19 @@ import androidx.lifecycle.viewModelScope import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.launch -import org.openedx.core.BaseViewModel import org.openedx.core.R -import org.openedx.core.SingleEventLiveData -import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences -import org.openedx.core.extension.isInternetError -import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection -import org.openedx.core.system.notifier.AppUpgradeEvent -import org.openedx.core.system.notifier.AppUpgradeNotifier +import org.openedx.core.system.notifier.app.AppNotifier +import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.discovery.domain.interactor.DiscoveryInteractor import org.openedx.discovery.domain.model.Course +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.SingleEventLiveData +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager class NativeDiscoveryViewModel( private val config: Config, @@ -26,13 +26,14 @@ class NativeDiscoveryViewModel( private val interactor: DiscoveryInteractor, private val resourceManager: ResourceManager, private val analytics: DiscoveryAnalytics, - private val appUpgradeNotifier: AppUpgradeNotifier, + private val appNotifier: AppNotifier, private val corePreferences: CorePreferences, ) : BaseViewModel() { val apiHostUrl get() = config.getApiHostURL() val isUserLoggedIn get() = corePreferences.user != null val canShowBackButton get() = config.isPreLoginExperienceEnabled() && !isUserLoggedIn + val isRegistrationEnabled: Boolean get() = config.isRegistrationEnabled() private val _uiState = MutableLiveData(DiscoveryUIState.Loading) val uiState: LiveData @@ -160,14 +161,13 @@ class NativeDiscoveryViewModel( @OptIn(FlowPreview::class) private fun collectAppUpgradeEvent() { viewModelScope.launch { - appUpgradeNotifier.notifier + appNotifier.notifier .debounce(100) .collect { event -> when (event) { is AppUpgradeEvent.UpgradeRecommendedEvent -> { _appUpgradeEvent.value = event } - is AppUpgradeEvent.UpgradeRequiredEvent -> { _appUpgradeEvent.value = AppUpgradeEvent.UpgradeRequiredEvent } diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryFragment.kt index 6696e765b..72ce6126c 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryFragment.kt @@ -24,6 +24,7 @@ import androidx.compose.material.Surface import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -52,20 +53,23 @@ import androidx.lifecycle.LifecycleOwner import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.core.presentation.dialog.alert.ActionDialogFragment +import org.openedx.core.presentation.global.ErrorType +import org.openedx.core.presentation.global.webview.WebViewUIAction +import org.openedx.core.presentation.global.webview.WebViewUIState import org.openedx.core.ui.AuthButtonsPanel -import org.openedx.core.ui.ConnectionErrorView +import org.openedx.core.ui.FullScreenErrorView import org.openedx.core.ui.Toolbar -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType import org.openedx.core.ui.displayCutoutForLandscape -import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors -import org.openedx.core.ui.windowSizeValue import org.openedx.discovery.R import org.openedx.discovery.presentation.catalog.CatalogWebViewScreen import org.openedx.discovery.presentation.catalog.WebViewLink +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue import org.openedx.core.R as CoreR class WebViewDiscoveryFragment : Fragment() { @@ -83,17 +87,33 @@ class WebViewDiscoveryFragment : Fragment() { setContent { OpenEdXTheme { val windowSize = rememberWindowSize() + val uiState by viewModel.uiState.collectAsState() var hasInternetConnection by remember { mutableStateOf(viewModel.hasInternetConnection) } WebViewDiscoveryScreen( windowSize = windowSize, + uiState = uiState, isPreLogin = viewModel.isPreLogin, contentUrl = viewModel.discoveryUrl, uriScheme = viewModel.uriScheme, + isRegistrationEnabled = viewModel.isRegistrationEnabled, hasInternetConnection = hasInternetConnection, - checkInternetConnection = { - hasInternetConnection = viewModel.hasInternetConnection + onWebViewUIAction = { action -> + when (action) { + WebViewUIAction.WEB_PAGE_LOADED -> { + viewModel.onWebPageLoaded() + } + + WebViewUIAction.WEB_PAGE_ERROR -> { + viewModel.onWebPageLoadError() + } + + WebViewUIAction.RELOAD_WEB_PAGE -> { + hasInternetConnection = viewModel.hasInternetConnection + viewModel.onWebPageLoading() + } + } }, onWebPageUpdated = { url -> viewModel.updateDiscoveryUrl(url) @@ -170,11 +190,13 @@ class WebViewDiscoveryFragment : Fragment() { @SuppressLint("SetJavaScriptEnabled") private fun WebViewDiscoveryScreen( windowSize: WindowSize, + uiState: WebViewUIState, isPreLogin: Boolean, contentUrl: String, uriScheme: String, + isRegistrationEnabled: Boolean, hasInternetConnection: Boolean, - checkInternetConnection: () -> Unit, + onWebViewUIAction: (WebViewUIAction) -> Unit, onWebPageUpdated: (String) -> Unit, onUriClick: (String, WebViewLink.Authority) -> Unit, onRegisterClick: () -> Unit, @@ -184,7 +206,6 @@ private fun WebViewDiscoveryScreen( ) { val scaffoldState = rememberScaffoldState() val configuration = LocalConfiguration.current - var isLoading by remember { mutableStateOf(true) } Scaffold( scaffoldState = scaffoldState, @@ -206,7 +227,8 @@ private fun WebViewDiscoveryScreen( ) { AuthButtonsPanel( onRegisterClick = onRegisterClick, - onSignInClick = onSignInClick + onSignInClick = onSignInClick, + showRegisterButton = isRegistrationEnabled ) } } @@ -248,25 +270,32 @@ private fun WebViewDiscoveryScreen( .background(Color.White), contentAlignment = Alignment.TopCenter ) { - if (hasInternetConnection) { - DiscoveryWebView( - contentUrl = contentUrl, - uriScheme = uriScheme, - onWebPageLoaded = { isLoading = false }, - onWebPageUpdated = onWebPageUpdated, - onUriClick = onUriClick, - ) - } else { - ConnectionErrorView( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight() - .background(MaterialTheme.appColors.background) - ) { - checkInternetConnection() + if ((uiState is WebViewUIState.Error).not()) { + if (hasInternetConnection) { + DiscoveryWebView( + contentUrl = contentUrl, + uriScheme = uriScheme, + onWebPageLoaded = { + if ((uiState is WebViewUIState.Error).not()) { + onWebViewUIAction(WebViewUIAction.WEB_PAGE_LOADED) + } + }, + onWebPageUpdated = onWebPageUpdated, + onUriClick = onUriClick, + onWebPageLoadError = { + onWebViewUIAction(WebViewUIAction.WEB_PAGE_ERROR) + } + ) + } else { + onWebViewUIAction(WebViewUIAction.WEB_PAGE_ERROR) } } - if (isLoading && hasInternetConnection) { + if (uiState is WebViewUIState.Error) { + FullScreenErrorView(errorType = uiState.errorType) { + onWebViewUIAction(WebViewUIAction.RELOAD_WEB_PAGE) + } + } + if (uiState is WebViewUIState.Loading && hasInternetConnection) { Box( modifier = Modifier .fillMaxSize() @@ -290,6 +319,7 @@ private fun DiscoveryWebView( onWebPageLoaded: () -> Unit, onWebPageUpdated: (String) -> Unit, onUriClick: (String, WebViewLink.Authority) -> Unit, + onWebPageLoadError: () -> Unit ) { val webView = CatalogWebViewScreen( url = contentUrl, @@ -297,6 +327,7 @@ private fun DiscoveryWebView( onWebPageLoaded = onWebPageLoaded, onWebPageUpdated = onWebPageUpdated, onUriClick = onUriClick, + onWebPageLoadError = onWebPageLoadError ) AndroidView( @@ -360,17 +391,19 @@ private fun WebViewDiscoveryScreenPreview() { OpenEdXTheme { WebViewDiscoveryScreen( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - contentUrl = "https://www.example.com/", + uiState = WebViewUIState.Error(ErrorType.CONNECTION_ERROR), isPreLogin = false, + contentUrl = "https://www.example.com/", uriScheme = "", + isRegistrationEnabled = true, hasInternetConnection = false, - checkInternetConnection = {}, + onWebViewUIAction = {}, onWebPageUpdated = {}, onUriClick = { _, _ -> }, onRegisterClick = {}, onSignInClick = {}, onSettingsClick = {}, - onBackClick = {} + onBackClick = {}, ) } } diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryViewModel.kt b/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryViewModel.kt index f86eef2b8..2cb7afd69 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryViewModel.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryViewModel.kt @@ -1,11 +1,16 @@ package org.openedx.discovery.presentation import androidx.fragment.app.FragmentManager -import org.openedx.core.BaseViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.presentation.global.ErrorType +import org.openedx.core.presentation.global.webview.WebViewUIState import org.openedx.core.system.connection.NetworkConnection -import org.openedx.core.utils.UrlUtils +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.utils.UrlUtils class WebViewDiscoveryViewModel( private val querySearch: String, @@ -16,11 +21,14 @@ class WebViewDiscoveryViewModel( private val analytics: DiscoveryAnalytics, ) : BaseViewModel() { + private val _uiState = MutableStateFlow(WebViewUIState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() val uriScheme: String get() = config.getUriScheme() private val webViewConfig get() = config.getDiscoveryConfig().webViewConfig val isPreLogin get() = config.isPreLoginExperienceEnabled() && corePreferences.user == null + val isRegistrationEnabled: Boolean get() = config.isRegistrationEnabled() private var _discoveryUrl = webViewConfig.baseUrl val discoveryUrl: String @@ -37,6 +45,19 @@ class WebViewDiscoveryViewModel( val hasInternetConnection: Boolean get() = networkConnection.isOnline() + fun onWebPageLoading() { + _uiState.value = WebViewUIState.Loading + } + + fun onWebPageLoaded() { + _uiState.value = WebViewUIState.Loaded + } + + fun onWebPageLoadError() { + _uiState.value = + WebViewUIState.Error(if (networkConnection.isOnline()) ErrorType.UNKNOWN_ERROR else ErrorType.CONNECTION_ERROR) + } + fun updateDiscoveryUrl(url: String) { if (url.isNotEmpty()) { _discoveryUrl = url @@ -77,7 +98,7 @@ class WebViewDiscoveryViewModel( event: DiscoveryAnalyticsEvent, courseId: String, ) { - analytics.logEvent( + analytics.logScreenEvent( event.eventName, buildMap { put(DiscoveryAnalyticsKey.NAME.key, event.biValue) diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/catalog/CatalogWebView.kt b/discovery/src/main/java/org/openedx/discovery/presentation/catalog/CatalogWebView.kt index 42531f8a0..aaac503a3 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/catalog/CatalogWebView.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/catalog/CatalogWebView.kt @@ -1,11 +1,15 @@ package org.openedx.discovery.presentation.catalog import android.annotation.SuppressLint +import android.webkit.WebResourceError import android.webkit.WebResourceRequest import android.webkit.WebView +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext +import org.openedx.foundation.extension.applyDarkModeIfEnabled +import org.openedx.core.extension.equalsHost import org.openedx.discovery.presentation.catalog.WebViewLink.Authority as linkAuthority @SuppressLint("SetJavaScriptEnabled", "ComposableNaming") @@ -18,9 +22,10 @@ fun CatalogWebViewScreen( refreshSessionCookie: () -> Unit = {}, onWebPageUpdated: (String) -> Unit = {}, onUriClick: (String, linkAuthority) -> Unit, + onWebPageLoadError: () -> Unit ): WebView { val context = LocalContext.current - + val isDarkTheme = isSystemInDarkTheme() return remember { WebView(context).apply { webViewClient = object : DefaultWebViewClient( @@ -79,6 +84,17 @@ fun CatalogWebViewScreen( else -> false } } + + override fun onReceivedError( + view: WebView, + request: WebResourceRequest, + error: WebResourceError + ) { + if (view.url.equalsHost(request.url.host)) { + onWebPageLoadError() + } + super.onReceivedError(view, request, error) + } } with(settings) { @@ -93,6 +109,7 @@ fun CatalogWebViewScreen( isHorizontalScrollBarEnabled = false loadUrl(url) + applyDarkModeIfEnabled(isDarkTheme) } } } diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/catalog/DefaultWebViewClient.kt b/discovery/src/main/java/org/openedx/discovery/presentation/catalog/DefaultWebViewClient.kt index 9cf94ecda..a039cab2f 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/catalog/DefaultWebViewClient.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/catalog/DefaultWebViewClient.kt @@ -7,8 +7,8 @@ import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse import android.webkit.WebView import android.webkit.WebViewClient -import org.openedx.core.extension.isEmailValid import org.openedx.core.utils.EmailUtil +import org.openedx.foundation.extension.isEmailValid open class DefaultWebViewClient( val context: Context, diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/catalog/WebViewLink.kt b/discovery/src/main/java/org/openedx/discovery/presentation/catalog/WebViewLink.kt index a467707ce..a482fc581 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/catalog/WebViewLink.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/catalog/WebViewLink.kt @@ -1,7 +1,7 @@ package org.openedx.discovery.presentation.catalog import android.net.Uri -import org.openedx.core.extension.getQueryParams +import org.openedx.foundation.extension.getQueryParams /** * To parse and store links that we need within a WebView. diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt index 813994307..056ce8bae 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt @@ -14,6 +14,7 @@ import android.webkit.WebResourceRequest import android.webkit.WebView import android.webkit.WebViewClient import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -78,30 +79,31 @@ import androidx.fragment.app.Fragment import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf -import org.openedx.core.UIMessage import org.openedx.core.domain.model.Media -import org.openedx.core.extension.isEmailValid import org.openedx.core.ui.AuthButtonsPanel import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OfflineModeDialog import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.Toolbar -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.isPreview -import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography -import org.openedx.core.ui.windowSizeValue import org.openedx.core.utils.EmailUtil import org.openedx.discovery.R import org.openedx.discovery.domain.model.Course import org.openedx.discovery.presentation.DiscoveryRouter import org.openedx.discovery.presentation.ui.ImageHeader import org.openedx.discovery.presentation.ui.WarningLabel +import org.openedx.foundation.extension.applyDarkModeIfEnabled +import org.openedx.foundation.extension.isEmailValid +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue import java.nio.charset.StandardCharsets import java.util.Date import org.openedx.core.R as CoreR @@ -140,6 +142,7 @@ class CourseDetailsFragment : Fragment() { ), hasInternetConnection = viewModel.hasInternetConnection, isUserLoggedIn = viewModel.isUserLoggedIn, + isRegistrationEnabled = viewModel.isRegistrationEnabled, onReloadClick = { viewModel.getCourseDetail() }, @@ -162,7 +165,6 @@ class CourseDetailsFragment : Fragment() { requireActivity().supportFragmentManager, currentState.course.courseId, currentState.course.name, - "", ) } @@ -209,6 +211,7 @@ internal fun CourseDetailsScreen( htmlBody: String, hasInternetConnection: Boolean, isUserLoggedIn: Boolean, + isRegistrationEnabled: Boolean, onReloadClick: () -> Unit, onBackClick: () -> Unit, onButtonClick: () -> Unit, @@ -236,7 +239,8 @@ internal fun CourseDetailsScreen( Box(modifier = Modifier.padding(horizontal = 16.dp, vertical = 32.dp)) { AuthButtonsPanel( onRegisterClick = onRegisterClick, - onSignInClick = onSignInClick + onSignInClick = onSignInClick, + showRegisterButton = isRegistrationEnabled ) } } @@ -625,6 +629,7 @@ private fun CourseDescription( onWebPageLoaded: () -> Unit ) { val context = LocalContext.current + val isDarkTheme = isSystemInDarkTheme() AndroidView(modifier = Modifier.then(modifier), factory = { WebView(context).apply { webViewClient = object : WebViewClient() { @@ -674,6 +679,7 @@ private fun CourseDescription( StandardCharsets.UTF_8.name(), null ) + applyDarkModeIfEnabled(isDarkTheme) } }) } @@ -690,6 +696,7 @@ private fun CourseDetailNativeContentPreview() { apiHostUrl = "http://localhost:8000", hasInternetConnection = false, isUserLoggedIn = true, + isRegistrationEnabled = true, htmlBody = "Preview text", onReloadClick = {}, onBackClick = {}, @@ -712,6 +719,7 @@ private fun CourseDetailNativeContentTabletPreview() { apiHostUrl = "http://localhost:8000", hasInternetConnection = false, isUserLoggedIn = true, + isRegistrationEnabled = true, htmlBody = "Preview text", onReloadClick = {}, onBackClick = {}, diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModel.kt b/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModel.kt index c68dd1c47..b512ea99e 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModel.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModel.kt @@ -4,22 +4,23 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.launch -import org.openedx.core.BaseViewModel import org.openedx.core.R -import org.openedx.core.SingleEventLiveData -import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences -import org.openedx.core.extension.isInternetError -import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseDashboardUpdate import org.openedx.core.system.notifier.DiscoveryNotifier +import org.openedx.core.worker.CalendarSyncScheduler import org.openedx.discovery.domain.interactor.DiscoveryInteractor import org.openedx.discovery.domain.model.Course import org.openedx.discovery.presentation.DiscoveryAnalytics import org.openedx.discovery.presentation.DiscoveryAnalyticsEvent import org.openedx.discovery.presentation.DiscoveryAnalyticsKey +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.SingleEventLiveData +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager class CourseDetailsViewModel( val courseId: String, @@ -30,9 +31,11 @@ class CourseDetailsViewModel( private val resourceManager: ResourceManager, private val notifier: DiscoveryNotifier, private val analytics: DiscoveryAnalytics, + private val calendarSyncScheduler: CalendarSyncScheduler, ) : BaseViewModel() { val apiHostUrl get() = config.getApiHostURL() val isUserLoggedIn get() = corePreferences.user != null + val isRegistrationEnabled: Boolean get() = config.isRegistrationEnabled() private val _uiState = MutableLiveData(CourseDetailsUIState.Loading) val uiState: LiveData @@ -92,6 +95,7 @@ class CourseDetailsViewModel( if (courseData is CourseDetailsUIState.CourseData) { _uiState.value = courseData.copy(course = course) courseEnrollSuccessEvent(id, title) + calendarSyncScheduler.requestImmediateSync(id) notifier.send(CourseDashboardUpdate()) } } catch (e: Exception) { diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoFragment.kt index b3b3275eb..3c6cb6c31 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoFragment.kt @@ -42,25 +42,27 @@ import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf -import org.openedx.core.UIMessage import org.openedx.core.presentation.dialog.alert.ActionDialogFragment import org.openedx.core.presentation.dialog.alert.InfoDialogFragment +import org.openedx.core.presentation.global.webview.WebViewUIAction +import org.openedx.core.presentation.global.webview.WebViewUIState import org.openedx.core.ui.AuthButtonsPanel -import org.openedx.core.ui.ConnectionErrorView +import org.openedx.core.ui.FullScreenErrorView import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.Toolbar -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType import org.openedx.core.ui.displayCutoutForLandscape -import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors -import org.openedx.core.ui.windowSizeValue import org.openedx.discovery.R import org.openedx.discovery.presentation.DiscoveryAnalyticsScreen import org.openedx.discovery.presentation.catalog.CatalogWebViewScreen import org.openedx.discovery.presentation.catalog.WebViewLink +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue import java.util.concurrent.atomic.AtomicReference import org.openedx.core.R as CoreR import org.openedx.discovery.presentation.catalog.WebViewLink.Authority as linkAuthority @@ -85,6 +87,7 @@ class CourseInfoFragment : Fragment() { val uiMessage by viewModel.uiMessage.collectAsState(initial = null) val showAlert by viewModel.showAlert.collectAsState(initial = false) val uiState by viewModel.uiState.collectAsState() + val webViewState by viewModel.webViewState.collectAsState() val windowSize = rememberWindowSize() var hasInternetConnection by remember { mutableStateOf(viewModel.hasInternetConnection) @@ -105,25 +108,42 @@ class CourseInfoFragment : Fragment() { } } - LaunchedEffect(uiState.enrollmentSuccess.get()) { - if (uiState.enrollmentSuccess.get().isNotEmpty()) { + LaunchedEffect((uiState as CourseInfoUIState.CourseInfo).enrollmentSuccess.get()) { + if ((uiState as CourseInfoUIState.CourseInfo).enrollmentSuccess.get() + .isNotEmpty() + ) { viewModel.onSuccessfulCourseEnrollment( fragmentManager = requireActivity().supportFragmentManager, - courseId = uiState.enrollmentSuccess.get(), + courseId = (uiState as CourseInfoUIState.CourseInfo).enrollmentSuccess.get(), ) // Clear after navigation - uiState.enrollmentSuccess.set("") + (uiState as CourseInfoUIState.CourseInfo).enrollmentSuccess.set("") } } CourseInfoScreen( windowSize = windowSize, uiState = uiState, + webViewUIState = webViewState, uiMessage = uiMessage, uriScheme = viewModel.uriScheme, hasInternetConnection = hasInternetConnection, - checkInternetConnection = { - hasInternetConnection = viewModel.hasInternetConnection + isRegistrationEnabled = viewModel.isRegistrationEnabled, + onWebViewUIAction = { action -> + when (action) { + WebViewUIAction.WEB_PAGE_LOADED -> { + viewModel.onWebPageLoaded() + } + + WebViewUIAction.WEB_PAGE_ERROR -> { + viewModel.onWebPageError() + } + + WebViewUIAction.RELOAD_WEB_PAGE -> { + hasInternetConnection = viewModel.hasInternetConnection + viewModel.onWebPageLoading() + } + } }, onRegisterClick = { viewModel.navigateToSignUp( @@ -179,7 +199,7 @@ class CourseInfoFragment : Fragment() { linkAuthority.ENROLL -> { viewModel.courseEnrollClickedEvent(param) - if (uiState.isPreLogin) { + if ((uiState as CourseInfoUIState.CourseInfo).isPreLogin) { viewModel.navigateToSignUp( fragmentManager = requireActivity().supportFragmentManager, courseId = viewModel.pathId, @@ -220,10 +240,12 @@ class CourseInfoFragment : Fragment() { private fun CourseInfoScreen( windowSize: WindowSize, uiState: CourseInfoUIState, + webViewUIState: WebViewUIState, uiMessage: UIMessage?, uriScheme: String, + isRegistrationEnabled: Boolean, hasInternetConnection: Boolean, - checkInternetConnection: () -> Unit, + onWebViewUIAction: (WebViewUIAction) -> Unit, onRegisterClick: () -> Unit, onSignInClick: () -> Unit, onBackClick: () -> Unit, @@ -231,7 +253,6 @@ private fun CourseInfoScreen( ) { val scaffoldState = rememberScaffoldState() val configuration = LocalConfiguration.current - var isLoading by remember { mutableStateOf(true) } HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) @@ -240,7 +261,7 @@ private fun CourseInfoScreen( modifier = Modifier.fillMaxSize(), backgroundColor = MaterialTheme.appColors.background, bottomBar = { - if (uiState.isPreLogin) { + if ((uiState as CourseInfoUIState.CourseInfo).isPreLogin) { Box( modifier = Modifier .padding( @@ -250,7 +271,8 @@ private fun CourseInfoScreen( ) { AuthButtonsPanel( onRegisterClick = onRegisterClick, - onSignInClick = onSignInClick + onSignInClick = onSignInClick, + showRegisterButton = isRegistrationEnabled ) } } @@ -291,24 +313,27 @@ private fun CourseInfoScreen( .navigationBarsPadding(), contentAlignment = Alignment.TopCenter ) { - if (hasInternetConnection) { - CourseInfoWebView( - contentUrl = uiState.initialUrl, - uriScheme = uriScheme, - onWebPageLoaded = { isLoading = false }, - onUriClick = onUriClick, - ) - } else { - ConnectionErrorView( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight() - .background(MaterialTheme.appColors.background) - ) { - checkInternetConnection() + if ((webViewUIState is WebViewUIState.Error).not()) { + if (hasInternetConnection) { + CourseInfoWebView( + contentUrl = (uiState as CourseInfoUIState.CourseInfo).initialUrl, + uriScheme = uriScheme, + onWebPageLoaded = { onWebViewUIAction(WebViewUIAction.WEB_PAGE_LOADED) }, + onUriClick = onUriClick, + onWebPageLoadError = { + onWebViewUIAction(WebViewUIAction.WEB_PAGE_ERROR) + } + ) + } else { + onWebViewUIAction(WebViewUIAction.WEB_PAGE_ERROR) } } - if (isLoading && hasInternetConnection) { + if (webViewUIState is WebViewUIState.Error) { + FullScreenErrorView(errorType = webViewUIState.errorType) { + onWebViewUIAction(WebViewUIAction.RELOAD_WEB_PAGE) + } + } + if (webViewUIState is WebViewUIState.Loading && hasInternetConnection) { Box( modifier = Modifier .fillMaxSize() @@ -331,6 +356,7 @@ private fun CourseInfoWebView( uriScheme: String, onWebPageLoaded: () -> Unit, onUriClick: (String, linkAuthority) -> Unit, + onWebPageLoadError: () -> Unit ) { val webView = CatalogWebViewScreen( @@ -339,6 +365,7 @@ private fun CourseInfoWebView( isAllLinksExternal = true, onWebPageLoaded = onWebPageLoaded, onUriClick = onUriClick, + onWebPageLoadError = onWebPageLoadError ) AndroidView( @@ -347,9 +374,6 @@ private fun CourseInfoWebView( factory = { webView }, - update = { - webView.loadUrl(contentUrl) - } ) } @@ -360,19 +384,21 @@ fun CourseInfoScreenPreview() { OpenEdXTheme { CourseInfoScreen( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - uiState = CourseInfoUIState( + uiState = CourseInfoUIState.CourseInfo( initialUrl = "https://www.example.com/", isPreLogin = false, enrollmentSuccess = AtomicReference("") ), uiMessage = null, uriScheme = "", + isRegistrationEnabled = true, hasInternetConnection = false, - checkInternetConnection = {}, + onWebViewUIAction = {}, onRegisterClick = {}, onSignInClick = {}, onBackClick = {}, onUriClick = { _, _ -> }, + webViewUIState = WebViewUIState.Loading, ) } } diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoUIState.kt b/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoUIState.kt index ffabf1daf..cd28abd2b 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoUIState.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoUIState.kt @@ -2,8 +2,10 @@ package org.openedx.discovery.presentation.info import java.util.concurrent.atomic.AtomicReference -internal data class CourseInfoUIState( - val initialUrl: String = "", - val isPreLogin: Boolean = false, - val enrollmentSuccess: AtomicReference = AtomicReference("") -) +sealed class CourseInfoUIState { + data class CourseInfo( + val initialUrl: String = "", + val isPreLogin: Boolean = false, + val enrollmentSuccess: AtomicReference = AtomicReference("") + ) : CourseInfoUIState() +} diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoViewModel.kt b/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoViewModel.kt index 636cb9275..fd88591ca 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoViewModel.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoViewModel.kt @@ -8,16 +8,15 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.openedx.core.BaseViewModel -import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences -import org.openedx.core.extension.isInternetError import org.openedx.core.presentation.CoreAnalyticsKey -import org.openedx.core.system.ResourceManager +import org.openedx.core.presentation.global.ErrorType +import org.openedx.core.presentation.global.webview.WebViewUIState import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseDashboardUpdate import org.openedx.core.system.notifier.DiscoveryNotifier @@ -28,6 +27,10 @@ import org.openedx.discovery.presentation.DiscoveryAnalyticsEvent import org.openedx.discovery.presentation.DiscoveryAnalyticsKey import org.openedx.discovery.presentation.DiscoveryRouter import org.openedx.discovery.presentation.catalog.WebViewLink +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import java.util.concurrent.atomic.AtomicReference import org.openedx.core.R as CoreR @@ -46,13 +49,17 @@ class CourseInfoViewModel( private val _uiState = MutableStateFlow( - CourseInfoUIState( + CourseInfoUIState.CourseInfo( initialUrl = getInitialUrl(), isPreLogin = config.isPreLoginExperienceEnabled() && corePreferences.user == null ) ) internal val uiState: StateFlow = _uiState + private val _webViewUIState = MutableStateFlow(WebViewUIState.Loading) + val webViewState + get() = _webViewUIState.asStateFlow() + private val _uiMessage = MutableSharedFlow() val uiMessage: SharedFlow get() = _uiMessage.asSharedFlow() @@ -64,6 +71,8 @@ class CourseInfoViewModel( val hasInternetConnection: Boolean get() = networkConnection.isOnline() + val isRegistrationEnabled: Boolean get() = config.isRegistrationEnabled() + val uriScheme: String get() = config.getUriScheme() private val webViewConfig get() = config.getDiscoveryConfig().webViewConfig @@ -122,7 +131,6 @@ class CourseInfoViewModel( fm = fragmentManager, courseId = courseId, courseTitle = "", - enrollmentMode = "", ) } } @@ -146,11 +154,11 @@ class CourseInfoViewModel( } fun courseInfoClickedEvent(courseId: String) { - logEvent(DiscoveryAnalyticsEvent.COURSE_INFO, courseId) + logScreenEvent(DiscoveryAnalyticsEvent.COURSE_INFO, courseId) } fun programInfoClickedEvent(courseId: String) { - logEvent(DiscoveryAnalyticsEvent.PROGRAM_INFO, courseId) + logScreenEvent(DiscoveryAnalyticsEvent.PROGRAM_INFO, courseId) } fun courseEnrollClickedEvent(courseId: String) { @@ -165,15 +173,39 @@ class CourseInfoViewModel( event: DiscoveryAnalyticsEvent, courseId: String, ) { - analytics.logEvent( - event.eventName, - buildMap { - put(DiscoveryAnalyticsKey.NAME.key, event.biValue) - put(DiscoveryAnalyticsKey.COURSE_ID.key, courseId) - put(DiscoveryAnalyticsKey.CATEGORY.key, CoreAnalyticsKey.DISCOVERY.key) - put(DiscoveryAnalyticsKey.CONVERSION.key, courseId) - } - ) + analytics.logEvent(event.eventName, buildEventDataMap(event, courseId)) + } + + private fun logScreenEvent( + event: DiscoveryAnalyticsEvent, + courseId: String, + ) { + analytics.logScreenEvent(event.eventName, buildEventDataMap(event, courseId)) + } + + private fun buildEventDataMap( + event: DiscoveryAnalyticsEvent, + courseId: String, + ): Map { + return buildMap { + put(DiscoveryAnalyticsKey.NAME.key, event.biValue) + put(DiscoveryAnalyticsKey.COURSE_ID.key, courseId) + put(DiscoveryAnalyticsKey.CATEGORY.key, CoreAnalyticsKey.DISCOVERY.key) + put(DiscoveryAnalyticsKey.CONVERSION.key, courseId) + } + } + + fun onWebPageLoaded() { + _webViewUIState.value = WebViewUIState.Loaded + } + + fun onWebPageError() { + _webViewUIState.value = + WebViewUIState.Error(if (networkConnection.isOnline()) ErrorType.UNKNOWN_ERROR else ErrorType.CONNECTION_ERROR) + } + + fun onWebPageLoading() { + _webViewUIState.value = WebViewUIState.Loading } companion object { diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramFragment.kt index 4e97efe18..07e59dc11 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramFragment.kt @@ -23,6 +23,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi @@ -41,35 +42,43 @@ import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.zIndex import androidx.core.os.bundleOf import androidx.fragment.app.Fragment +import kotlinx.coroutines.launch +import org.koin.androidx.compose.koinViewModel import org.koin.androidx.viewmodel.ext.android.viewModel -import org.openedx.core.extension.toastMessage +import org.openedx.core.extension.loadUrl import org.openedx.core.presentation.dialog.alert.ActionDialogFragment import org.openedx.core.presentation.dialog.alert.InfoDialogFragment -import org.openedx.core.ui.ConnectionErrorView +import org.openedx.core.presentation.global.webview.WebViewUIAction +import org.openedx.core.system.AppCookieManager +import org.openedx.core.ui.FullScreenErrorView import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.Toolbar -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType import org.openedx.core.ui.displayCutoutForLandscape -import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors -import org.openedx.core.ui.windowSizeValue import org.openedx.discovery.R import org.openedx.discovery.presentation.DiscoveryAnalyticsScreen import org.openedx.discovery.presentation.catalog.CatalogWebViewScreen import org.openedx.discovery.presentation.catalog.WebViewLink +import org.openedx.foundation.extension.takeIfNotEmpty +import org.openedx.foundation.extension.toastMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue import org.openedx.core.R as coreR import org.openedx.discovery.presentation.catalog.WebViewLink.Authority as linkAuthority -class ProgramFragment(private val myPrograms: Boolean = false) : Fragment() { +class ProgramFragment : Fragment() { private val viewModel by viewModel() + private var isNestedFragment = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - if (myPrograms.not()) { + isNestedFragment = arguments?.getBoolean(ARG_NESTED_FRAGMENT, false) ?: false + if (isNestedFragment.not()) { lifecycle.addObserver(viewModel) } } @@ -77,7 +86,7 @@ class ProgramFragment(private val myPrograms: Boolean = false) : Fragment() { override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? + savedInstanceState: Bundle?, ) = ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { @@ -88,7 +97,7 @@ class ProgramFragment(private val myPrograms: Boolean = false) : Fragment() { mutableStateOf(viewModel.hasInternetConnection) } - if (myPrograms.not()) { + if (isNestedFragment.not()) { DisposableEffect(uiState is ProgramUIState.CourseEnrolled) { if (uiState is ProgramUIState.CourseEnrolled) { @@ -119,14 +128,28 @@ class ProgramFragment(private val myPrograms: Boolean = false) : Fragment() { windowSize = windowSize, uiState = uiState, contentUrl = getInitialUrl(), + cookieManager = viewModel.cookieManager, canShowBackBtn = arguments?.getString(ARG_PATH_ID, "") ?.isNotEmpty() == true, + isNestedFragment = isNestedFragment, uriScheme = viewModel.uriScheme, hasInternetConnection = hasInternetConnection, - checkInternetConnection = { - hasInternetConnection = viewModel.hasInternetConnection + onWebViewUIAction = { action -> + when (action) { + WebViewUIAction.WEB_PAGE_LOADED -> { + viewModel.showLoading(false) + } + + WebViewUIAction.WEB_PAGE_ERROR -> { + viewModel.onPageLoadError() + } + + WebViewUIAction.RELOAD_WEB_PAGE -> { + hasInternetConnection = viewModel.hasInternetConnection + viewModel.showLoading(true) + } + } }, - onWebPageLoaded = { viewModel.showLoading(false) }, onBackClick = { requireActivity().supportFragmentManager.popBackStackImmediate() }, @@ -147,7 +170,8 @@ class ProgramFragment(private val myPrograms: Boolean = false) : Fragment() { } linkAuthority.PROGRAM_INFO, - linkAuthority.COURSE_INFO -> { + linkAuthority.COURSE_INFO, + -> { viewModel.onViewCourseClick( fragmentManager = requireActivity().supportFragmentManager, courseId = param, @@ -181,34 +205,33 @@ class ProgramFragment(private val myPrograms: Boolean = false) : Fragment() { }, onSettingsClick = { viewModel.navigateToSettings(requireActivity().supportFragmentManager) - }, - refreshSessionCookie = { - viewModel.refreshCookie() - }, + } ) } } } - private fun getInitialUrl(): String { - return arguments?.let { args -> - val pathId = args.getString(ARG_PATH_ID) ?: "" - viewModel.programConfig.programDetailUrlTemplate.replace("{$ARG_PATH_ID}", pathId) + val pathId = arguments?.getString(ARG_PATH_ID, "") + return pathId?.takeIfNotEmpty()?.let { + viewModel.programConfig.programDetailUrlTemplate.replace("{$ARG_PATH_ID}", it) } ?: viewModel.programConfig.programUrl } companion object { private const val ARG_PATH_ID = "path_id" + private const val ARG_NESTED_FRAGMENT = "nested_fragment" fun newInstance( - pathId: String, + pathId: String = "", + isNestedFragment: Boolean = false, ): ProgramFragment { - val fragment = ProgramFragment(false) - fragment.arguments = bundleOf( - ARG_PATH_ID to pathId, - ) - return fragment + return ProgramFragment().apply { + arguments = bundleOf( + ARG_PATH_ID to pathId, + ARG_NESTED_FRAGMENT to isNestedFragment + ) + } } } } @@ -219,19 +242,19 @@ private fun ProgramInfoScreen( windowSize: WindowSize, uiState: ProgramUIState?, contentUrl: String, + cookieManager: AppCookieManager, uriScheme: String, canShowBackBtn: Boolean, + isNestedFragment: Boolean, hasInternetConnection: Boolean, - checkInternetConnection: () -> Unit, - onWebPageLoaded: () -> Unit, + onWebViewUIAction: (WebViewUIAction) -> Unit, onSettingsClick: () -> Unit, onBackClick: () -> Unit, onUriClick: (String, WebViewLink.Authority) -> Unit, - refreshSessionCookie: () -> Unit = {}, ) { val scaffoldState = rememberScaffoldState() val configuration = LocalConfiguration.current - val isLoading = uiState is ProgramUIState.Loading + val coroutineScope = rememberCoroutineScope() when (uiState) { is ProgramUIState.UiMessage -> { @@ -247,7 +270,7 @@ private fun ProgramInfoScreen( .fillMaxSize() .semantics { testTagsAsResourceId = true }, backgroundColor = MaterialTheme.appColors.background - ) { + ) { paddingValues -> val modifierScreenWidth by remember(key1 = windowSize) { mutableStateOf( windowSize.windowSizeValue( @@ -261,21 +284,29 @@ private fun ProgramInfoScreen( ) } + val statusBarPadding = if (isNestedFragment) { + Modifier + } else { + Modifier.statusBarsInset() + } + Column( modifier = Modifier .fillMaxSize() - .padding(it) - .statusBarsInset() + .padding(paddingValues) + .then(statusBarPadding) .displayCutoutForLandscape(), horizontalAlignment = Alignment.CenterHorizontally, ) { - Toolbar( - label = stringResource(id = R.string.discovery_programs), - canShowBackBtn = canShowBackBtn, - canShowSettingsIcon = !canShowBackBtn, - onBackClick = onBackClick, - onSettingsClick = onSettingsClick - ) + if (!isNestedFragment) { + Toolbar( + label = stringResource(id = R.string.discovery_programs), + canShowBackBtn = canShowBackBtn, + canShowSettingsIcon = !canShowBackBtn, + onBackClick = onBackClick, + onSettingsClick = onSettingsClick + ) + } Surface { Box( @@ -284,37 +315,44 @@ private fun ProgramInfoScreen( .background(Color.White), contentAlignment = Alignment.TopCenter ) { - if (hasInternetConnection) { - val webView = CatalogWebViewScreen( - url = contentUrl, - uriScheme = uriScheme, - isAllLinksExternal = true, - onWebPageLoaded = onWebPageLoaded, - refreshSessionCookie = refreshSessionCookie, - onUriClick = onUriClick, - ) + if ((uiState is ProgramUIState.Error).not()) { + if (hasInternetConnection) { + val webView = CatalogWebViewScreen( + url = contentUrl, + uriScheme = uriScheme, + isAllLinksExternal = true, + onWebPageLoaded = { onWebViewUIAction(WebViewUIAction.WEB_PAGE_LOADED) }, + refreshSessionCookie = { + coroutineScope.launch { + cookieManager.tryToRefreshSessionCookie() + } + }, + onUriClick = onUriClick, + onWebPageLoadError = { onWebViewUIAction(WebViewUIAction.WEB_PAGE_ERROR) } + ) - AndroidView( - modifier = Modifier - .background(MaterialTheme.appColors.background), - factory = { - webView - }, - update = { - webView.loadUrl(contentUrl) - } - ) - } else { - ConnectionErrorView( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight() - .background(MaterialTheme.appColors.background) - ) { - checkInternetConnection() + AndroidView( + modifier = Modifier + .background(MaterialTheme.appColors.background), + factory = { + webView + }, + update = { + webView.loadUrl(contentUrl, coroutineScope, cookieManager) + } + ) + } else { + onWebViewUIAction(WebViewUIAction.WEB_PAGE_ERROR) + } + } + + if (uiState is ProgramUIState.Error) { + FullScreenErrorView(errorType = uiState.errorType) { + onWebViewUIAction(WebViewUIAction.RELOAD_WEB_PAGE) } } - if (isLoading && hasInternetConnection) { + + if (uiState == ProgramUIState.Loading && hasInternetConnection) { Box( modifier = Modifier .fillMaxSize() @@ -339,12 +377,13 @@ fun MyProgramsPreview() { windowSize = WindowSize(WindowType.Compact, WindowType.Compact), uiState = ProgramUIState.Loading, contentUrl = "https://www.example.com/", + cookieManager = koinViewModel().cookieManager, uriScheme = "", canShowBackBtn = false, + isNestedFragment = false, hasInternetConnection = false, - checkInternetConnection = {}, + onWebViewUIAction = {}, onBackClick = {}, - onWebPageLoaded = {}, onSettingsClick = {}, onUriClick = { _, _ -> }, ) diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramUIState.kt b/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramUIState.kt index fa7f395d7..b4ad7341d 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramUIState.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramUIState.kt @@ -1,10 +1,12 @@ package org.openedx.discovery.presentation.program -import org.openedx.core.UIMessage +import org.openedx.foundation.presentation.UIMessage +import org.openedx.core.presentation.global.ErrorType sealed class ProgramUIState { data object Loading : ProgramUIState() data object Loaded : ProgramUIState() + data class Error(val errorType: ErrorType) : ProgramUIState() class CourseEnrolled(val courseId: String, val isEnrolled: Boolean) : ProgramUIState() diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramViewModel.kt b/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramViewModel.kt index 68bbdc6be..bacc9b3a1 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramViewModel.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramViewModel.kt @@ -2,24 +2,24 @@ package org.openedx.discovery.presentation.program import androidx.fragment.app.FragmentManager import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import org.openedx.core.BaseViewModel import org.openedx.core.R -import org.openedx.core.UIMessage import org.openedx.core.config.Config -import org.openedx.core.extension.isInternetError +import org.openedx.core.presentation.global.ErrorType import org.openedx.core.system.AppCookieManager -import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseDashboardUpdate import org.openedx.core.system.notifier.DiscoveryNotifier import org.openedx.core.system.notifier.NavigationToDiscovery import org.openedx.discovery.domain.interactor.DiscoveryInteractor import org.openedx.discovery.presentation.DiscoveryRouter +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager class ProgramViewModel( private val config: Config, @@ -34,14 +34,12 @@ class ProgramViewModel( val programConfig get() = config.getProgramConfig().webViewConfig + val cookieManager get() = edxCookieManager + val hasInternetConnection: Boolean get() = networkConnection.isOnline() - private val _uiState = MutableSharedFlow( - replay = 0, - extraBufferCapacity = 1, - onBufferOverflow = BufferOverflow.DROP_OLDEST - ) - val uiState: SharedFlow get() = _uiState.asSharedFlow() + private val _uiState = MutableStateFlow(ProgramUIState.Loading) + val uiState: StateFlow get() = _uiState.asStateFlow() fun showLoading(isLoading: Boolean) { viewModelScope.launch { @@ -92,9 +90,11 @@ class ProgramViewModel( fm = fragmentManager, courseId = courseId, courseTitle = "", - enrollmentMode = "" ) } + viewModelScope.launch { + _uiState.emit(ProgramUIState.Loaded) + } } fun navigateToDiscovery() { @@ -105,7 +105,9 @@ class ProgramViewModel( router.navigateToSettings(fragmentManager) } - fun refreshCookie() { - viewModelScope.launch { edxCookieManager.tryToRefreshSessionCookie() } + fun onPageLoadError() { + viewModelScope.launch { + _uiState.emit(ProgramUIState.Error(if (networkConnection.isOnline()) ErrorType.UNKNOWN_ERROR else ErrorType.CONNECTION_ERROR)) + } } } diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/search/CourseSearchFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/search/CourseSearchFragment.kt index e13e6f0bb..fc2af30a6 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/search/CourseSearchFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/search/CourseSearchFragment.kt @@ -63,24 +63,24 @@ import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel -import org.openedx.core.UIMessage import org.openedx.core.domain.model.Media import org.openedx.core.ui.AuthButtonsPanel import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.SearchBar -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType -import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.shouldLoadMore import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography -import org.openedx.core.ui.windowSizeValue import org.openedx.discovery.domain.model.Course import org.openedx.discovery.presentation.DiscoveryRouter import org.openedx.discovery.presentation.ui.DiscoveryCourseItem +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue import org.openedx.discovery.R as discoveryR class CourseSearchFragment : Fragment() { @@ -118,6 +118,7 @@ class CourseSearchFragment : Fragment() { refreshing = refreshing, querySearch = querySearch, isUserLoggedIn = viewModel.isUserLoggedIn, + isRegistrationEnabled = viewModel.isRegistrationEnabled, onBackClick = { requireActivity().supportFragmentManager.popBackStack() }, @@ -171,6 +172,7 @@ private fun CourseSearchScreen( refreshing: Boolean, querySearch: String, isUserLoggedIn: Boolean, + isRegistrationEnabled: Boolean, onBackClick: () -> Unit, onSearchTextChanged: (String) -> Unit, onSwipeRefresh: () -> Unit, @@ -222,7 +224,8 @@ private fun CourseSearchScreen( ) { AuthButtonsPanel( onRegisterClick = onRegisterClick, - onSignInClick = onSignInClick + onSignInClick = onSignInClick, + showRegisterButton = isRegistrationEnabled ) } } @@ -433,6 +436,7 @@ fun CourseSearchScreenPreview() { refreshing = false, querySearch = "", isUserLoggedIn = true, + isRegistrationEnabled = true, onBackClick = {}, onSearchTextChanged = {}, onSwipeRefresh = {}, @@ -458,6 +462,7 @@ fun CourseSearchScreenTabletPreview() { refreshing = false, querySearch = "", isUserLoggedIn = false, + isRegistrationEnabled = true, onBackClick = {}, onSearchTextChanged = {}, onSwipeRefresh = {}, diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/search/CourseSearchViewModel.kt b/discovery/src/main/java/org/openedx/discovery/presentation/search/CourseSearchViewModel.kt index ea6c5ba35..d1ae276d8 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/search/CourseSearchViewModel.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/search/CourseSearchViewModel.kt @@ -8,17 +8,17 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.launch -import org.openedx.core.BaseViewModel import org.openedx.core.R -import org.openedx.core.SingleEventLiveData -import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences -import org.openedx.core.extension.isInternetError -import org.openedx.core.system.ResourceManager import org.openedx.discovery.domain.interactor.DiscoveryInteractor import org.openedx.discovery.domain.model.Course import org.openedx.discovery.presentation.DiscoveryAnalytics +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.SingleEventLiveData +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager class CourseSearchViewModel( private val config: Config, @@ -30,6 +30,7 @@ class CourseSearchViewModel( val apiHostUrl get() = config.getApiHostURL() val isUserLoggedIn get() = corePreferences.user != null + val isRegistrationEnabled: Boolean get() = config.isRegistrationEnabled() private val _uiState = MutableLiveData(CourseSearchUIState.Courses(emptyList(), 0)) diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/ui/DiscoveryUI.kt b/discovery/src/main/java/org/openedx/discovery/presentation/ui/DiscoveryUI.kt index e1b6645ea..5413543c9 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/ui/DiscoveryUI.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/ui/DiscoveryUI.kt @@ -39,19 +39,18 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import coil.request.ImageRequest -import org.openedx.core.extension.isLinkValid -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography -import org.openedx.core.ui.windowSizeValue import org.openedx.discovery.R import org.openedx.discovery.domain.model.Course +import org.openedx.foundation.extension.toImageLink +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue import org.openedx.core.R as CoreR - @Composable fun ImageHeader( modifier: Modifier, @@ -67,15 +66,10 @@ fun ImageHeader( } else { ContentScale.Crop } - val imageUrl = if (courseImage?.isLinkValid() == true) { - courseImage - } else { - apiHostUrl.dropLast(1) + courseImage - } Box(modifier = modifier, contentAlignment = Alignment.Center) { AsyncImage( model = ImageRequest.Builder(LocalContext.current) - .data(imageUrl) + .data(courseImage?.toImageLink(apiHostUrl)) .error(CoreR.drawable.core_no_image_course) .placeholder(CoreR.drawable.core_no_image_course) .build(), @@ -108,7 +102,6 @@ fun DiscoveryCourseItem( ) } - val imageUrl = apiHostUrl.dropLast(1) + course.media.courseImage?.uri Surface( modifier = Modifier .testTag("btn_course_card") @@ -126,7 +119,7 @@ fun DiscoveryCourseItem( ) { AsyncImage( model = ImageRequest.Builder(LocalContext.current) - .data(imageUrl) + .data(course.media.courseImage?.uri?.toImageLink(apiHostUrl) ?: "") .error(org.openedx.core.R.drawable.core_no_image_course) .placeholder(org.openedx.core.R.drawable.core_no_image_course) .build(), diff --git a/discovery/src/main/res/values-uk/strings.xml b/discovery/src/main/res/values-uk/strings.xml deleted file mode 100644 index f25c4ef5c..000000000 --- a/discovery/src/main/res/values-uk/strings.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - Нові курси - Знайти нові курси - Давайте знайдемо щось нове для вас - Результати пошуку - Почніть вводити, щоб знайти курс - Деталі курсу - Записатися зараз - Переглянути курс - Ви не можете записатися на цей курс, оскільки термін запису вже минув. - - - Знайдено %s курс за вашим запитом - Знайдено %s курси за вашим запитом - Знайдено %s курсів за вашим запитом - Знайдено %s курсів за вашим запитом - - - - Відтворити відео - diff --git a/discovery/src/main/res/values/strings.xml b/discovery/src/main/res/values/strings.xml index 5a02b65cf..1d2d9c44b 100644 --- a/discovery/src/main/res/values/strings.xml +++ b/discovery/src/main/res/values/strings.xml @@ -16,11 +16,7 @@ Programs - Found %s courses on your request Found %s course on your request - Found %s courses on your request - Found %s courses on your request - Found %s courses on your request Found %s courses on your request diff --git a/discovery/src/test/java/org/openedx/discovery/presentation/NativeDiscoveryViewModelTest.kt b/discovery/src/test/java/org/openedx/discovery/presentation/NativeDiscoveryViewModelTest.kt index 898a227c3..83550dc42 100644 --- a/discovery/src/test/java/org/openedx/discovery/presentation/NativeDiscoveryViewModelTest.kt +++ b/discovery/src/test/java/org/openedx/discovery/presentation/NativeDiscoveryViewModelTest.kt @@ -21,15 +21,15 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule import org.openedx.core.R -import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Pagination -import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection -import org.openedx.core.system.notifier.AppUpgradeNotifier +import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.discovery.domain.interactor.DiscoveryInteractor import org.openedx.discovery.domain.model.CourseList +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException @OptIn(ExperimentalCoroutinesApi::class) @@ -46,7 +46,7 @@ class NativeDiscoveryViewModelTest { private val interactor = mockk() private val networkConnection = mockk() private val analytics = mockk() - private val appUpgradeNotifier = mockk() + private val appNotifier = mockk() private val corePreferences = mockk() private val noInternet = "Slow or no internet connection" @@ -57,7 +57,7 @@ class NativeDiscoveryViewModelTest { Dispatchers.setMain(dispatcher) every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong - every { appUpgradeNotifier.notifier } returns emptyFlow() + every { appNotifier.notifier } returns emptyFlow() every { corePreferences.user } returns null every { config.getApiHostURL() } returns "http://localhost:8000" every { config.isPreLoginExperienceEnabled() } returns false @@ -76,7 +76,7 @@ class NativeDiscoveryViewModelTest { interactor, resourceManager, analytics, - appUpgradeNotifier, + appNotifier, corePreferences ) every { networkConnection.isOnline() } returns true @@ -85,7 +85,7 @@ class NativeDiscoveryViewModelTest { coVerify(exactly = 1) { interactor.getCoursesList(any(), any(), any()) } coVerify(exactly = 0) { interactor.getCoursesListFromCache() } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assertEquals(noInternet, message?.message) @@ -101,7 +101,7 @@ class NativeDiscoveryViewModelTest { interactor, resourceManager, analytics, - appUpgradeNotifier, + appNotifier, corePreferences ) every { networkConnection.isOnline() } returns true @@ -125,7 +125,7 @@ class NativeDiscoveryViewModelTest { interactor, resourceManager, analytics, - appUpgradeNotifier, + appNotifier, corePreferences ) every { networkConnection.isOnline() } returns false @@ -148,7 +148,7 @@ class NativeDiscoveryViewModelTest { interactor, resourceManager, analytics, - appUpgradeNotifier, + appNotifier, corePreferences ) every { networkConnection.isOnline() } returns true @@ -178,7 +178,7 @@ class NativeDiscoveryViewModelTest { interactor, resourceManager, analytics, - appUpgradeNotifier, + appNotifier, corePreferences ) every { networkConnection.isOnline() } returns true @@ -209,7 +209,7 @@ class NativeDiscoveryViewModelTest { interactor, resourceManager, analytics, - appUpgradeNotifier, + appNotifier, corePreferences ) every { networkConnection.isOnline() } returns true @@ -234,7 +234,7 @@ class NativeDiscoveryViewModelTest { interactor, resourceManager, analytics, - appUpgradeNotifier, + appNotifier, corePreferences ) every { networkConnection.isOnline() } returns true @@ -259,7 +259,7 @@ class NativeDiscoveryViewModelTest { interactor, resourceManager, analytics, - appUpgradeNotifier, + appNotifier, corePreferences ) every { networkConnection.isOnline() } returns true @@ -290,7 +290,7 @@ class NativeDiscoveryViewModelTest { interactor, resourceManager, analytics, - appUpgradeNotifier, + appNotifier, corePreferences ) every { networkConnection.isOnline() } returns true diff --git a/discovery/src/test/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModelTest.kt b/discovery/src/test/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModelTest.kt index 712a122ab..3e0f4906f 100644 --- a/discovery/src/test/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModelTest.kt +++ b/discovery/src/test/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModelTest.kt @@ -22,18 +22,19 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule import org.openedx.core.R -import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Media -import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseDashboardUpdate import org.openedx.core.system.notifier.DiscoveryNotifier +import org.openedx.core.worker.CalendarSyncScheduler import org.openedx.discovery.domain.interactor.DiscoveryInteractor import org.openedx.discovery.domain.model.Course import org.openedx.discovery.presentation.DiscoveryAnalytics import org.openedx.discovery.presentation.DiscoveryAnalyticsEvent +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException @OptIn(ExperimentalCoroutinesApi::class) @@ -51,6 +52,7 @@ class CourseDetailsViewModelTest { private val networkConnection = mockk() private val notifier = spyk() private val analytics = mockk() + private val calendarSyncScheduler = mockk() private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" @@ -85,6 +87,7 @@ class CourseDetailsViewModelTest { every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong every { config.getApiHostURL() } returns "http://localhost:8000" + every { calendarSyncScheduler.requestImmediateSync(any()) } returns Unit } @After @@ -102,7 +105,8 @@ class CourseDetailsViewModelTest { interactor, resourceManager, notifier, - analytics + analytics, + calendarSyncScheduler, ) every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseDetails(any()) } throws UnknownHostException() @@ -126,7 +130,8 @@ class CourseDetailsViewModelTest { interactor, resourceManager, notifier, - analytics + analytics, + calendarSyncScheduler, ) every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseDetails(any()) } throws Exception() @@ -150,7 +155,8 @@ class CourseDetailsViewModelTest { interactor, resourceManager, notifier, - analytics + analytics, + calendarSyncScheduler, ) every { config.isPreLoginExperienceEnabled() } returns false every { preferencesManager.user } returns null @@ -175,7 +181,8 @@ class CourseDetailsViewModelTest { interactor, resourceManager, notifier, - analytics + analytics, + calendarSyncScheduler, ) every { config.isPreLoginExperienceEnabled() } returns false every { preferencesManager.user } returns null @@ -201,7 +208,8 @@ class CourseDetailsViewModelTest { interactor, resourceManager, notifier, - analytics + analytics, + calendarSyncScheduler, ) every { config.isPreLoginExperienceEnabled() } returns false every { preferencesManager.user } returns null @@ -232,7 +240,8 @@ class CourseDetailsViewModelTest { interactor, resourceManager, notifier, - analytics + analytics, + calendarSyncScheduler, ) every { config.isPreLoginExperienceEnabled() } returns false every { preferencesManager.user } returns null @@ -274,7 +283,8 @@ class CourseDetailsViewModelTest { interactor, resourceManager, notifier, - analytics + analytics, + calendarSyncScheduler, ) every { config.isPreLoginExperienceEnabled() } returns false every { preferencesManager.user } returns null @@ -328,7 +338,8 @@ class CourseDetailsViewModelTest { interactor, resourceManager, notifier, - analytics + analytics, + calendarSyncScheduler, ) val overview = viewModel.getCourseAboutBody(ULong.MAX_VALUE, ULong.MIN_VALUE) val count = overview.contains("black") @@ -345,7 +356,8 @@ class CourseDetailsViewModelTest { interactor, resourceManager, notifier, - analytics + analytics, + calendarSyncScheduler, ) val overview = viewModel.getCourseAboutBody(ULong.MAX_VALUE, ULong.MAX_VALUE) val count = overview.contains("black") diff --git a/discovery/src/test/java/org/openedx/discovery/presentation/search/CourseSearchViewModelTest.kt b/discovery/src/test/java/org/openedx/discovery/presentation/search/CourseSearchViewModelTest.kt index 40e44e73c..8b5a1bab4 100644 --- a/discovery/src/test/java/org/openedx/discovery/presentation/search/CourseSearchViewModelTest.kt +++ b/discovery/src/test/java/org/openedx/discovery/presentation/search/CourseSearchViewModelTest.kt @@ -20,16 +20,16 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule import org.openedx.core.R -import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Media import org.openedx.core.domain.model.Pagination -import org.openedx.core.system.ResourceManager import org.openedx.discovery.domain.interactor.DiscoveryInteractor import org.openedx.discovery.domain.model.Course import org.openedx.discovery.domain.model.CourseList import org.openedx.discovery.presentation.DiscoveryAnalytics +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException @OptIn(ExperimentalCoroutinesApi::class) diff --git a/discussion/build.gradle b/discussion/build.gradle index 77d393d7a..5442a57b2 100644 --- a/discussion/build.gradle +++ b/discussion/build.gradle @@ -2,6 +2,7 @@ plugins { id 'com.android.library' id 'org.jetbrains.kotlin.android' id 'kotlin-parcelize' + id "org.jetbrains.kotlin.plugin.compose" } android { @@ -28,15 +29,13 @@ android { } kotlinOptions { jvmTarget = JavaVersion.VERSION_17 + freeCompilerArgs = List.of("-Xstring-concat=inline") } buildFeatures { viewBinding true compose true } - composeOptions { - kotlinCompilerExtensionVersion = "$compose_compiler_version" - } flavorDimensions += "env" productFlavors { @@ -55,11 +54,10 @@ android { dependencies { implementation project(path: ':core') - androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + androidTestImplementation 'androidx.test.ext:junit:1.2.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' testImplementation "junit:junit:$junit_version" testImplementation "io.mockk:mockk:$mockk_version" testImplementation "io.mockk:mockk-android:$mockk_version" - testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version" testImplementation "androidx.arch.core:core-testing:$android_arch_version" } \ No newline at end of file diff --git a/discussion/proguard-rules.pro b/discussion/proguard-rules.pro index 481bb4348..cdb308aa0 100644 --- a/discussion/proguard-rules.pro +++ b/discussion/proguard-rules.pro @@ -1,21 +1,7 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +# Prevent shrinking, optimization, and obfuscation of the library when consumed by other modules. +# This ensures that all classes and methods remain available for use by the consumer of the library. +# Disabling these steps at the library level is important because the main app module will handle +# shrinking, optimization, and obfuscation for the entire application, including this library. +-dontshrink +-dontoptimize +-dontobfuscate diff --git a/discussion/src/main/java/org/openedx/discussion/data/api/DiscussionApi.kt b/discussion/src/main/java/org/openedx/discussion/data/api/DiscussionApi.kt index ebc911425..4d0343d69 100644 --- a/discussion/src/main/java/org/openedx/discussion/data/api/DiscussionApi.kt +++ b/discussion/src/main/java/org/openedx/discussion/data/api/DiscussionApi.kt @@ -1,10 +1,12 @@ package org.openedx.discussion.data.api +import org.json.JSONObject import org.openedx.core.data.model.BlocksCompletionBody import org.openedx.discussion.data.model.request.* import org.openedx.discussion.data.model.response.CommentResult import org.openedx.discussion.data.model.response.CommentsResponse import org.openedx.discussion.data.model.response.ThreadsResponse +import org.openedx.discussion.data.model.response.ThreadsResponse.Thread import org.openedx.discussion.data.model.response.TopicsResponse import retrofit2.http.* @@ -26,6 +28,14 @@ interface DiscussionApi { @Query("requested_fields") requestedFields: List = listOf("profile_image") ): ThreadsResponse + @GET("/api/discussion/v1/threads/{thread_id}") + suspend fun getCourseThread( + @Path("thread_id") threadId: String, + @Query("course_id") courseId: String, + @Query("topic_id") topicId: String, + @Query("requested_fields") requestedFields: List = listOf("profile_image") + ): Thread + @GET("/api/discussion/v1/threads/") suspend fun searchThreads( @Query("course_id") courseId: String, @@ -41,6 +51,12 @@ interface DiscussionApi { @Query("requested_fields") requestedFields: List = listOf("profile_image") ): CommentsResponse + @Headers("Content-type: application/merge-patch+json") + @PATCH("/api/discussion/v1/comments/{response_id}/") + suspend fun getResponse( + @Path("response_id") responseId: String + ): CommentResult + @GET("/api/discussion/v1/comments/") suspend fun getThreadQuestionComments( @Query("thread_id") threadId: String, diff --git a/discussion/src/main/java/org/openedx/discussion/data/repository/DiscussionRepository.kt b/discussion/src/main/java/org/openedx/discussion/data/repository/DiscussionRepository.kt index 4ca6cde8d..ac07087cd 100644 --- a/discussion/src/main/java/org/openedx/discussion/data/repository/DiscussionRepository.kt +++ b/discussion/src/main/java/org/openedx/discussion/data/repository/DiscussionRepository.kt @@ -2,7 +2,6 @@ package org.openedx.discussion.data.repository import org.openedx.core.data.model.BlocksCompletionBody import org.openedx.core.data.storage.CorePreferences -import org.openedx.core.system.ResourceManager import org.openedx.discussion.R import org.openedx.discussion.data.api.DiscussionApi import org.openedx.discussion.data.model.request.CommentBody @@ -12,8 +11,10 @@ import org.openedx.discussion.data.model.request.ReportBody import org.openedx.discussion.data.model.request.ThreadBody import org.openedx.discussion.data.model.request.VoteBody import org.openedx.discussion.domain.model.CommentsData +import org.openedx.discussion.domain.model.DiscussionComment import org.openedx.discussion.domain.model.ThreadsData import org.openedx.discussion.domain.model.Topic +import org.openedx.foundation.system.ResourceManager class DiscussionRepository( private val api: DiscussionApi, @@ -58,6 +59,14 @@ class DiscussionRepository( return api.getCourseThreads(courseId, following, topicId, orderBy, view, page).mapToDomain() } + suspend fun getCourseThread( + threadId: String, + courseId: String, + topicId: String + ): org.openedx.discussion.domain.model.Thread { + return api.getCourseThread(threadId, courseId, topicId).mapToDomain() + } + suspend fun searchThread( courseId: String, query: String, @@ -73,6 +82,12 @@ class DiscussionRepository( return api.getThreadComments(threadId, page).mapToDomain() } + suspend fun getResponse( + responseId: String + ): DiscussionComment { + return api.getResponse(responseId).mapToDomain() + } + suspend fun getThreadQuestionComments( threadId: String, endorsed: Boolean, @@ -142,4 +157,4 @@ class DiscussionRepository( return api.markBlocksCompletion(blocksCompletionBody) } -} \ No newline at end of file +} diff --git a/discussion/src/main/java/org/openedx/discussion/domain/interactor/DiscussionInteractor.kt b/discussion/src/main/java/org/openedx/discussion/domain/interactor/DiscussionInteractor.kt index 7225cc443..90960011c 100644 --- a/discussion/src/main/java/org/openedx/discussion/domain/interactor/DiscussionInteractor.kt +++ b/discussion/src/main/java/org/openedx/discussion/domain/interactor/DiscussionInteractor.kt @@ -1,6 +1,8 @@ package org.openedx.discussion.domain.interactor import org.openedx.discussion.data.repository.DiscussionRepository +import org.openedx.discussion.domain.model.CommentsData +import org.openedx.discussion.domain.model.DiscussionComment class DiscussionInteractor( private val repository: DiscussionRepository @@ -31,12 +33,18 @@ class DiscussionInteractor( ) = repository.getCourseThreads(courseId, null, topicId, orderBy, view, page) + suspend fun getThread(threadId: String, courseId: String, topicId: String) = + repository.getCourseThread(threadId, courseId, topicId) + suspend fun searchThread(courseId: String, query: String, page: Int) = repository.searchThread(courseId, query, page) suspend fun getThreadComments(threadId: String, page: Int) = repository.getThreadComments(threadId, page) + suspend fun getResponse(responseId: String): DiscussionComment = + repository.getResponse(responseId) + suspend fun getThreadQuestionComments(threadId: String, endorsed: Boolean, page: Int) = repository.getThreadQuestionComments(threadId, endorsed, page) @@ -87,5 +95,6 @@ class DiscussionInteractor( follow: Boolean ) = repository.createThread(topicId, courseId, type, title, rawBody, follow) - suspend fun markBlocksCompletion(courseId: String, blocksId: List) = repository.markBlocksCompletion(courseId, blocksId) -} \ No newline at end of file + suspend fun markBlocksCompletion(courseId: String, blocksId: List) = + repository.markBlocksCompletion(courseId, blocksId) +} diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/comments/DiscussionCommentsFragment.kt b/discussion/src/main/java/org/openedx/discussion/presentation/comments/DiscussionCommentsFragment.kt index 02f950bb6..2c7f03bd0 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/comments/DiscussionCommentsFragment.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/comments/DiscussionCommentsFragment.kt @@ -9,18 +9,45 @@ import android.view.ViewGroup import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.* +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Divider +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.TextFieldDefaults import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState -import androidx.compose.runtime.* +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier @@ -41,24 +68,32 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.core.os.bundleOf import androidx.fragment.app.Fragment -import org.openedx.core.UIMessage +import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf import org.openedx.core.domain.model.ProfileImage import org.openedx.core.extension.TextConverter -import org.openedx.core.extension.parcelable -import org.openedx.core.ui.* +import org.openedx.core.ui.BackBtn +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.shouldLoadMore +import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography +import org.openedx.discussion.R import org.openedx.discussion.domain.model.DiscussionComment import org.openedx.discussion.domain.model.DiscussionType import org.openedx.discussion.presentation.DiscussionRouter import org.openedx.discussion.presentation.ui.CommentItem import org.openedx.discussion.presentation.ui.ThreadMainItem -import org.koin.android.ext.android.inject -import org.koin.androidx.viewmodel.ext.android.viewModel -import org.koin.core.parameter.parametersOf -import org.openedx.discussion.R +import org.openedx.foundation.extension.parcelable +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue class DiscussionCommentsFragment : Fragment() { diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/comments/DiscussionCommentsViewModel.kt b/discussion/src/main/java/org/openedx/discussion/presentation/comments/DiscussionCommentsViewModel.kt index bcf54a92e..33e858b6b 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/comments/DiscussionCommentsViewModel.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/comments/DiscussionCommentsViewModel.kt @@ -4,12 +4,8 @@ import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope -import org.openedx.core.BaseViewModel +import kotlinx.coroutines.launch import org.openedx.core.R -import org.openedx.core.SingleEventLiveData -import org.openedx.core.UIMessage -import org.openedx.core.extension.isInternetError -import org.openedx.core.system.ResourceManager import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.domain.model.DiscussionComment import org.openedx.discussion.domain.model.DiscussionType @@ -17,7 +13,11 @@ import org.openedx.discussion.system.notifier.DiscussionCommentAdded import org.openedx.discussion.system.notifier.DiscussionCommentDataChanged import org.openedx.discussion.system.notifier.DiscussionNotifier import org.openedx.discussion.system.notifier.DiscussionThreadDataChanged -import kotlinx.coroutines.launch +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.SingleEventLiveData +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager class DiscussionCommentsViewModel( private val interactor: DiscussionInteractor, diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/responses/DiscussionResponsesFragment.kt b/discussion/src/main/java/org/openedx/discussion/presentation/responses/DiscussionResponsesFragment.kt index b3d5a0d82..ebf0fb1b9 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/responses/DiscussionResponsesFragment.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/responses/DiscussionResponsesFragment.kt @@ -9,18 +9,48 @@ import android.view.ViewGroup import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.* +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Divider +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.TextFieldDefaults import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState -import androidx.compose.runtime.* +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier @@ -41,22 +71,30 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.core.os.bundleOf import androidx.fragment.app.Fragment +import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf -import org.koin.android.ext.android.inject -import org.openedx.core.UIMessage import org.openedx.core.domain.model.ProfileImage import org.openedx.core.extension.TextConverter -import org.openedx.core.extension.parcelable -import org.openedx.core.ui.* +import org.openedx.core.ui.BackBtn +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.shouldLoadMore +import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography import org.openedx.discussion.domain.model.DiscussionComment +import org.openedx.discussion.presentation.DiscussionRouter import org.openedx.discussion.presentation.comments.DiscussionCommentsFragment import org.openedx.discussion.presentation.ui.CommentMainItem -import org.openedx.discussion.presentation.DiscussionRouter +import org.openedx.foundation.extension.parcelable +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue import org.openedx.discussion.R as discussionR class DiscussionResponsesFragment : Fragment() { diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/responses/DiscussionResponsesViewModel.kt b/discussion/src/main/java/org/openedx/discussion/presentation/responses/DiscussionResponsesViewModel.kt index 3f9b75e60..ed8390c44 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/responses/DiscussionResponsesViewModel.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/responses/DiscussionResponsesViewModel.kt @@ -3,17 +3,17 @@ package org.openedx.discussion.presentation.responses import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope -import org.openedx.core.BaseViewModel +import kotlinx.coroutines.launch import org.openedx.core.R -import org.openedx.core.SingleEventLiveData -import org.openedx.core.UIMessage -import org.openedx.core.extension.isInternetError -import org.openedx.core.system.ResourceManager import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.domain.model.DiscussionComment import org.openedx.discussion.system.notifier.DiscussionCommentDataChanged import org.openedx.discussion.system.notifier.DiscussionNotifier -import kotlinx.coroutines.launch +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.SingleEventLiveData +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager class DiscussionResponsesViewModel( private val interactor: DiscussionInteractor, diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadFragment.kt b/discussion/src/main/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadFragment.kt index 05fceeffc..76c645e37 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadFragment.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadFragment.kt @@ -4,19 +4,41 @@ import android.content.res.Configuration import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.* +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Divider +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.Text import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState -import androidx.compose.runtime.* +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalFocusManager @@ -34,20 +56,27 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import androidx.core.os.bundleOf import androidx.fragment.app.Fragment +import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf import org.openedx.core.R -import org.openedx.core.UIMessage import org.openedx.core.extension.TextConverter -import org.openedx.core.ui.* +import org.openedx.core.ui.BackBtn +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.SearchBar +import org.openedx.core.ui.shouldLoadMore +import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography import org.openedx.discussion.domain.model.DiscussionType import org.openedx.discussion.presentation.DiscussionRouter import org.openedx.discussion.presentation.ui.ThreadItem -import org.koin.android.ext.android.inject -import org.koin.androidx.viewmodel.ext.android.viewModel -import org.koin.core.parameter.parametersOf - +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue import org.openedx.discussion.R as discussionR class DiscussionSearchThreadFragment : Fragment() { @@ -121,7 +150,7 @@ class DiscussionSearchThreadFragment : Fragment() { } -@OptIn(ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class) +@OptIn(ExperimentalMaterialApi::class) @Composable private fun DiscussionSearchThreadScreen( windowSize: WindowSize, diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadViewModel.kt b/discussion/src/main/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadViewModel.kt index a0c5c5c62..c101ae7b9 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadViewModel.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadViewModel.kt @@ -15,15 +15,15 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch -import org.openedx.core.BaseViewModel import org.openedx.core.R -import org.openedx.core.SingleEventLiveData -import org.openedx.core.UIMessage -import org.openedx.core.extension.isInternetError -import org.openedx.core.system.ResourceManager import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.system.notifier.DiscussionNotifier import org.openedx.discussion.system.notifier.DiscussionThreadDataChanged +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.SingleEventLiveData +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager class DiscussionSearchThreadViewModel( private val interactor: DiscussionInteractor, diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadFragment.kt b/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadFragment.kt index 416140f1e..82ad75a17 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadFragment.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadFragment.kt @@ -73,25 +73,25 @@ import androidx.fragment.app.Fragment import kotlinx.coroutines.launch import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf -import org.openedx.core.UIMessage import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.OpenEdXOutlinedTextField import org.openedx.core.ui.SheetContent -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.isImeVisibleState import org.openedx.core.ui.noRippleClickable -import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography -import org.openedx.core.ui.windowSizeValue import org.openedx.discussion.domain.model.DiscussionType +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue import org.openedx.discussion.R as discussionR class DiscussionAddThreadFragment : Fragment() { @@ -395,7 +395,7 @@ private fun DiscussionAddThreadScreen( ), isSingleLine = false, withRequiredMark = true, - imeAction = ImeAction.Done, + imeAction = ImeAction.Default, keyboardActions = { focusManager -> focusManager.clearFocus() keyboardController?.hide() diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadViewModel.kt b/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadViewModel.kt index 278f6b4f0..3ff75bd9b 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadViewModel.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadViewModel.kt @@ -3,16 +3,16 @@ package org.openedx.discussion.presentation.threads import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope -import org.openedx.core.BaseViewModel +import kotlinx.coroutines.launch import org.openedx.core.R -import org.openedx.core.SingleEventLiveData -import org.openedx.core.UIMessage -import org.openedx.core.extension.isInternetError -import org.openedx.core.system.ResourceManager import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.system.notifier.DiscussionNotifier import org.openedx.discussion.system.notifier.DiscussionThreadAdded -import kotlinx.coroutines.launch +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.SingleEventLiveData +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager class DiscussionAddThreadViewModel( private val interactor: DiscussionInteractor, diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsFragment.kt b/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsFragment.kt index 99bf4f26e..35884fec0 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsFragment.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsFragment.kt @@ -6,20 +6,50 @@ import android.view.LayoutInflater import android.view.ViewGroup import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material.* +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Divider +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.ModalBottomSheetLayout +import androidx.compose.material.ModalBottomSheetValue +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.Text import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState -import androidx.compose.runtime.* +import androidx.compose.material.rememberModalBottomSheetState +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -44,13 +74,29 @@ import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.core.FragmentViewType -import org.openedx.core.UIMessage import org.openedx.core.extension.TextConverter -import org.openedx.core.ui.* -import org.openedx.core.ui.theme.* +import org.openedx.core.ui.BackBtn +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.IconText +import org.openedx.core.ui.OpenEdXOutlinedButton +import org.openedx.core.ui.SheetContent +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.isImeVisibleState +import org.openedx.core.ui.noRippleClickable +import org.openedx.core.ui.shouldLoadMore +import org.openedx.core.ui.statusBarsInset +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes +import org.openedx.core.ui.theme.appTypography import org.openedx.discussion.domain.model.DiscussionType import org.openedx.discussion.presentation.DiscussionRouter import org.openedx.discussion.presentation.ui.ThreadItem +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue import org.openedx.discussion.R as discussionR class DiscussionThreadsFragment : Fragment() { @@ -393,7 +439,7 @@ private fun DiscussionThreadsScreen( text = filterType.first, painter = painterResource(id = discussionR.drawable.discussion_ic_filter), textStyle = MaterialTheme.appTypography.labelMedium, - color = MaterialTheme.appColors.textAccent, + color = MaterialTheme.appColors.textPrimary, onClick = { currentSelectedList = FilterType.type expandedList = listOf( @@ -423,7 +469,7 @@ private fun DiscussionThreadsScreen( text = sortType.first, painter = painterResource(id = discussionR.drawable.discussion_ic_sort), textStyle = MaterialTheme.appTypography.labelMedium, - color = MaterialTheme.appColors.textAccent, + color = MaterialTheme.appColors.textPrimary, onClick = { currentSelectedList = SortType.type expandedList = listOf( @@ -475,7 +521,7 @@ private fun DiscussionThreadsScreen( Modifier .size(40.dp) .clip(CircleShape) - .background(MaterialTheme.appColors.primary) + .background(MaterialTheme.appColors.secondaryButtonBackground) .clickable { onCreatePostClick() }, @@ -485,7 +531,7 @@ private fun DiscussionThreadsScreen( modifier = Modifier.size(16.dp), painter = painterResource(id = discussionR.drawable.discussion_ic_add_comment), contentDescription = stringResource(id = discussionR.string.discussion_add_comment), - tint = MaterialTheme.appColors.buttonText + tint = MaterialTheme.appColors.primaryButtonText ) } } diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsViewModel.kt b/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsViewModel.kt index 2dbbd9af6..944152606 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsViewModel.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsViewModel.kt @@ -5,17 +5,17 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.launch -import org.openedx.core.BaseViewModel import org.openedx.core.R -import org.openedx.core.SingleEventLiveData -import org.openedx.core.UIMessage -import org.openedx.core.extension.isInternetError -import org.openedx.core.system.ResourceManager import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.presentation.topics.DiscussionTopicsViewModel import org.openedx.discussion.system.notifier.DiscussionNotifier import org.openedx.discussion.system.notifier.DiscussionThreadAdded import org.openedx.discussion.system.notifier.DiscussionThreadDataChanged +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.SingleEventLiveData +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager class DiscussionThreadsViewModel( private val interactor: DiscussionInteractor, diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsScreen.kt b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsScreen.kt index 2797ed1a6..75bbe2eaa 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsScreen.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsScreen.kt @@ -37,28 +37,29 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentManager -import org.koin.androidx.compose.koinViewModel import org.openedx.core.FragmentViewType -import org.openedx.core.UIMessage +import org.openedx.core.NoContentScreenType import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.NoContentScreen import org.openedx.core.ui.StaticSearchBar -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography -import org.openedx.core.ui.windowSizeValue +import org.openedx.discussion.R import org.openedx.discussion.domain.model.Topic import org.openedx.discussion.presentation.ui.ThreadItemCategory import org.openedx.discussion.presentation.ui.TopicItem -import org.openedx.discussion.R as discussionR +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.windowSizeValue @Composable fun DiscussionTopicsScreen( - discussionTopicsViewModel: DiscussionTopicsViewModel = koinViewModel(), + discussionTopicsViewModel: DiscussionTopicsViewModel, windowSize: WindowSize, fragmentManager: FragmentManager ) { @@ -157,15 +158,17 @@ private fun DiscussionTopicsUI( contentAlignment = Alignment.TopCenter ) { Column(screenWidth) { - StaticSearchBar( - modifier = Modifier - .height(48.dp) - .then(searchTabWidth) - .padding(horizontal = contentPaddings) - .fillMaxWidth(), - text = stringResource(id = discussionR.string.discussion_search_all_posts), - onClick = onSearchClick - ) + if ((uiState is DiscussionTopicsUIState.Error).not()) { + StaticSearchBar( + modifier = Modifier + .height(48.dp) + .then(searchTabWidth) + .padding(horizontal = contentPaddings) + .fillMaxWidth(), + text = stringResource(id = R.string.discussion_search_all_posts), + onClick = onSearchClick + ) + } Surface( modifier = Modifier.padding(top = 10.dp), color = MaterialTheme.appColors.background, @@ -188,7 +191,7 @@ private fun DiscussionTopicsUI( item { Text( modifier = Modifier, - text = stringResource(id = discussionR.string.discussion_main_categories), + text = stringResource(id = R.string.discussion_main_categories), style = MaterialTheme.appTypography.titleMedium, color = MaterialTheme.appColors.textPrimaryVariant ) @@ -199,8 +202,8 @@ private fun DiscussionTopicsUI( horizontalArrangement = Arrangement.spacedBy(14.dp) ) { ThreadItemCategory( - name = stringResource(id = discussionR.string.discussion_all_posts), - painterResource = painterResource(id = discussionR.drawable.discussion_all_posts), + name = stringResource(id = R.string.discussion_all_posts), + painterResource = painterResource(id = R.drawable.discussion_all_posts), modifier = Modifier .weight(1f) .height(categoriesHeight), @@ -208,12 +211,12 @@ private fun DiscussionTopicsUI( onItemClick( DiscussionTopicsViewModel.ALL_POSTS, "", - context.getString(discussionR.string.discussion_all_posts) + context.getString(R.string.discussion_all_posts) ) }) ThreadItemCategory( - name = stringResource(id = discussionR.string.discussion_posts_following), - painterResource = painterResource(id = discussionR.drawable.discussion_star), + name = stringResource(id = R.string.discussion_posts_following), + painterResource = painterResource(id = R.drawable.discussion_star), modifier = Modifier .weight(1f) .height(categoriesHeight), @@ -221,7 +224,7 @@ private fun DiscussionTopicsUI( onItemClick( DiscussionTopicsViewModel.FOLLOWING_POSTS, "", - context.getString(discussionR.string.discussion_posts_following) + context.getString(R.string.discussion_posts_following) ) }) } @@ -253,6 +256,9 @@ private fun DiscussionTopicsUI( } DiscussionTopicsUIState.Loading -> {} + else -> { + NoContentScreen(noContentScreenType = NoContentScreenType.COURSE_DISCUSSIONS) + } } } } @@ -279,6 +285,23 @@ private fun DiscussionTopicsScreenPreview() { } } +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "NEXUS_5_Light", device = Devices.NEXUS_5, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "NEXUS_5_Dark", device = Devices.NEXUS_5, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ErrorDiscussionTopicsScreenPreview() { + OpenEdXTheme { + DiscussionTopicsUI( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + uiState = DiscussionTopicsUIState.Error, + uiMessage = null, + onItemClick = { _, _, _ -> }, + onSearchClick = {} + ) + } +} + @Preview(name = "NEXUS_9_Light", device = Devices.NEXUS_9, uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(name = "NEXUS_9_Dark", device = Devices.NEXUS_9, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsUIState.kt b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsUIState.kt index c57f55e9b..f1becc420 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsUIState.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsUIState.kt @@ -5,5 +5,6 @@ import org.openedx.discussion.domain.model.Topic sealed class DiscussionTopicsUIState { data class Topics(val data: List) : DiscussionTopicsUIState() - object Loading : DiscussionTopicsUIState() -} \ No newline at end of file + data object Loading : DiscussionTopicsUIState() + data object Error : DiscussionTopicsUIState() +} diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt index 5bdd90d70..16572da7c 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt @@ -7,21 +7,21 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch -import org.openedx.core.BaseViewModel import org.openedx.core.R -import org.openedx.core.UIMessage -import org.openedx.core.extension.isInternetError -import org.openedx.core.presentation.course.CourseContainerTab -import org.openedx.core.system.ResourceManager -import org.openedx.core.system.notifier.CourseDataReady import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier -import org.openedx.core.system.notifier.CourseRefresh +import org.openedx.core.system.notifier.RefreshDiscussions import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.presentation.DiscussionAnalytics import org.openedx.discussion.presentation.DiscussionRouter +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager class DiscussionTopicsViewModel( + val courseId: String, + private val courseTitle: String, private val interactor: DiscussionInteractor, private val resourceManager: ResourceManager, private val analytics: DiscussionAnalytics, @@ -29,9 +29,6 @@ class DiscussionTopicsViewModel( val discussionRouter: DiscussionRouter, ) : BaseViewModel() { - var courseId: String = "" - var courseName: String = "" - private val _uiState = MutableLiveData() val uiState: LiveData get() = _uiState @@ -42,18 +39,23 @@ class DiscussionTopicsViewModel( init { collectCourseNotifier() + + getCourseTopic() } private fun getCourseTopic() { viewModelScope.launch { try { val response = interactor.getCourseTopics(courseId) - _uiState.value = DiscussionTopicsUIState.Topics(response) + if (response.isEmpty().not()) { + _uiState.value = DiscussionTopicsUIState.Topics(response) + } else { + _uiState.value = DiscussionTopicsUIState.Error + } } catch (e: Exception) { + _uiState.value = DiscussionTopicsUIState.Error if (e.isInternetError()) { _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection))) - } else { - _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error))) } } finally { courseNotifier.send(CourseLoading(false)) @@ -64,15 +66,15 @@ class DiscussionTopicsViewModel( fun discussionClickedEvent(action: String, data: String, title: String) { when (action) { ALL_POSTS -> { - analytics.discussionAllPostsClickedEvent(courseId, courseName) + analytics.discussionAllPostsClickedEvent(courseId, courseTitle) } FOLLOWING_POSTS -> { - analytics.discussionFollowingClickedEvent(courseId, courseName) + analytics.discussionFollowingClickedEvent(courseId, courseTitle) } TOPIC -> { - analytics.discussionTopicClickedEvent(courseId, courseName, data, title) + analytics.discussionTopicClickedEvent(courseId, courseTitle, data, title) } } } @@ -81,17 +83,7 @@ class DiscussionTopicsViewModel( viewModelScope.launch { courseNotifier.notifier.collect { event -> when (event) { - is CourseDataReady -> { - courseId = event.courseStructure.id - courseName = event.courseStructure.name - getCourseTopic() - } - - is CourseRefresh -> { - if (event.courseContainerTab == CourseContainerTab.DISCUSSIONS) { - getCourseTopic() - } - } + is RefreshDiscussions -> getCourseTopic() } } } diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/ui/DiscussionUI.kt b/discussion/src/main/java/org/openedx/discussion/presentation/ui/DiscussionUI.kt index 7d2242850..cd87e0498 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/ui/DiscussionUI.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/ui/DiscussionUI.kt @@ -587,11 +587,10 @@ fun ThreadItem( thread.commentCount - 1 ), painter = painterResource(id = R.drawable.discussion_ic_responses), - color = MaterialTheme.appColors.textAccent, + color = MaterialTheme.appColors.textPrimary, textStyle = MaterialTheme.appTypography.labelLarge ) } - } diff --git a/discussion/src/main/res/values-uk/strings.xml b/discussion/src/main/res/values-uk/strings.xml deleted file mode 100644 index 212932f49..000000000 --- a/discussion/src/main/res/values-uk/strings.xml +++ /dev/null @@ -1,81 +0,0 @@ - - - Обговорення - Всі публікації - Непрочитані - Без відповіді - Публікації, за якими стежу - Уточнити: - Остання активність - Найбільша активність - Найбільше голосів - Створити дискусію - Додати відповідь - Останнє повідомлення: %1$s - Стежити - Поскаржитися - Відмінити скаргу - Додати коментар - Коментар - Коментар успішно додано - Обговорення - Питання - Заголовок - Слідкувати за цією дискусією - Слідкувати за цим питанням - Опублікувати обговорення - Опублікувати питання - Загальні - Шукати в усіх повідомленнях - Головні категорії - Виберіть тип публікації - Тема - Результати пошуку - Почніть вводити, щоб знайти тему - anonymous - Ще немає обговорень - Натисніть кнопку нижче, щоб створити перше обговорення. - - - %1$d голос - %1$d голоси - %1$d голосів - %1$d голосів - - - - %1$d коментар - %1$d коментарі - %1$d коментарів - %1$d коментарів - - - - %1$d пропущений допис - %1$d пропущені дописи - %1$d пропущених дописів - %1$d пропущених дописів - - - - %1$d відповідь - %1$d відповіді - %1$d відповідей - %1$d відповідей - - - - %1$d Відповідь - %1$d Відповіді - %1$d Відповідей - %1$d Відповідей - - - - Знайдено %s запис - Знайдено %s записи - Знайдено %s записів - Знайдено %s записів - - - \ No newline at end of file diff --git a/discussion/src/main/res/values/strings.xml b/discussion/src/main/res/values/strings.xml index 2527da01f..a9b11d04d 100644 --- a/discussion/src/main/res/values/strings.xml +++ b/discussion/src/main/res/values/strings.xml @@ -39,56 +39,32 @@ - %1$d votes %1$d vote - %1$d votes - %1$d votes - %1$d votes %1$d votes - %1$d Comments %1$d Comment - %1$d Comments - %1$d Comments - %1$d Comments %1$d Comments - %1$d Missed posts %1$d Missed post - %1$d Missed posts - %1$d Missed posts - %1$d Missed posts %1$d Missed posts - %1$d responses %1$d response - %1$d responses - %1$d responses - %1$d responses %1$d responses - %1$d Responses %1$d Response - %1$d Responses - %1$d Responses - %1$d Responses %1$d Responses - Found %s posts Found %s post - Found %s posts - Found %s posts - Found %s posts Found %s posts diff --git a/discussion/src/test/java/org/openedx/discussion/presentation/comments/DiscussionCommentsViewModelTest.kt b/discussion/src/test/java/org/openedx/discussion/presentation/comments/DiscussionCommentsViewModelTest.kt index 8e55f7cd2..933a3bd5b 100644 --- a/discussion/src/test/java/org/openedx/discussion/presentation/comments/DiscussionCommentsViewModelTest.kt +++ b/discussion/src/test/java/org/openedx/discussion/presentation/comments/DiscussionCommentsViewModelTest.kt @@ -4,25 +4,40 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry +import io.mockk.clearAllMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule import org.openedx.core.R -import org.openedx.core.UIMessage +import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Pagination import org.openedx.core.extension.TextConverter -import org.openedx.core.system.ResourceManager import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.domain.model.CommentsData import org.openedx.discussion.domain.model.DiscussionComment import org.openedx.discussion.domain.model.DiscussionType -import org.openedx.discussion.system.notifier.* -import io.mockk.* -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.test.* -import org.junit.* -import org.junit.rules.TestRule -import org.openedx.core.data.storage.CorePreferences +import org.openedx.discussion.system.notifier.DiscussionCommentAdded +import org.openedx.discussion.system.notifier.DiscussionCommentDataChanged +import org.openedx.discussion.system.notifier.DiscussionNotifier +import org.openedx.discussion.system.notifier.DiscussionThreadDataChanged +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException @OptIn(ExperimentalCoroutinesApi::class) diff --git a/discussion/src/test/java/org/openedx/discussion/presentation/responses/DiscussionResponsesViewModelTest.kt b/discussion/src/test/java/org/openedx/discussion/presentation/responses/DiscussionResponsesViewModelTest.kt index 61fa44df7..e3e0aa8ca 100644 --- a/discussion/src/test/java/org/openedx/discussion/presentation/responses/DiscussionResponsesViewModelTest.kt +++ b/discussion/src/test/java/org/openedx/discussion/presentation/responses/DiscussionResponsesViewModelTest.kt @@ -1,24 +1,35 @@ package org.openedx.discussion.presentation.responses import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import io.mockk.clearAllMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule import org.openedx.core.R -import org.openedx.core.UIMessage +import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Pagination import org.openedx.core.extension.LinkedImageText -import org.openedx.core.system.ResourceManager import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.domain.model.CommentsData import org.openedx.discussion.domain.model.DiscussionComment import org.openedx.discussion.domain.model.DiscussionType import org.openedx.discussion.system.notifier.DiscussionNotifier -import io.mockk.* -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.* -import org.junit.* -import org.junit.Assert.* -import org.junit.rules.TestRule -import org.openedx.core.data.storage.CorePreferences +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException @OptIn(ExperimentalCoroutinesApi::class) diff --git a/discussion/src/test/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadViewModelTest.kt b/discussion/src/test/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadViewModelTest.kt index 57d35df20..8cf079a35 100644 --- a/discussion/src/test/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadViewModelTest.kt +++ b/discussion/src/test/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadViewModelTest.kt @@ -4,16 +4,6 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry -import org.openedx.core.R -import org.openedx.core.UIMessage -import org.openedx.core.domain.model.Pagination -import org.openedx.core.extension.TextConverter -import org.openedx.core.system.ResourceManager -import org.openedx.discussion.domain.interactor.DiscussionInteractor -import org.openedx.discussion.domain.model.DiscussionType -import org.openedx.discussion.domain.model.ThreadsData -import org.openedx.discussion.system.notifier.DiscussionNotifier -import org.openedx.discussion.system.notifier.DiscussionThreadDataChanged import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -22,13 +12,26 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.test.* +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain import org.junit.After -import org.junit.Assert.* import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule +import org.openedx.core.R +import org.openedx.core.domain.model.Pagination +import org.openedx.core.extension.TextConverter +import org.openedx.discussion.domain.interactor.DiscussionInteractor +import org.openedx.discussion.domain.model.DiscussionType +import org.openedx.discussion.domain.model.ThreadsData +import org.openedx.discussion.system.notifier.DiscussionNotifier +import org.openedx.discussion.system.notifier.DiscussionThreadDataChanged +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException @OptIn(ExperimentalCoroutinesApi::class) diff --git a/discussion/src/test/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadViewModelTest.kt b/discussion/src/test/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadViewModelTest.kt index 6944e33c4..27c74ae00 100644 --- a/discussion/src/test/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadViewModelTest.kt +++ b/discussion/src/test/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadViewModelTest.kt @@ -1,11 +1,27 @@ package org.openedx.discussion.presentation.threads import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import io.mockk.clearAllMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule import org.openedx.core.R -import org.openedx.core.UIMessage +import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.ProfileImage import org.openedx.core.extension.TextConverter -import org.openedx.core.system.ResourceManager import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.domain.model.DiscussionComment import org.openedx.discussion.domain.model.DiscussionProfile @@ -13,16 +29,8 @@ import org.openedx.discussion.domain.model.DiscussionType import org.openedx.discussion.domain.model.Topic import org.openedx.discussion.system.notifier.DiscussionNotifier import org.openedx.discussion.system.notifier.DiscussionThreadAdded -import io.mockk.* -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.* -import org.junit.After -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.rules.TestRule -import org.openedx.core.data.storage.CorePreferences +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException @OptIn(ExperimentalCoroutinesApi::class) diff --git a/discussion/src/test/java/org/openedx/discussion/presentation/threads/DiscussionThreadsViewModelTest.kt b/discussion/src/test/java/org/openedx/discussion/presentation/threads/DiscussionThreadsViewModelTest.kt index 92e5cd2fa..435981520 100644 --- a/discussion/src/test/java/org/openedx/discussion/presentation/threads/DiscussionThreadsViewModelTest.kt +++ b/discussion/src/test/java/org/openedx/discussion/presentation/threads/DiscussionThreadsViewModelTest.kt @@ -25,10 +25,8 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule import org.openedx.core.R -import org.openedx.core.UIMessage import org.openedx.core.domain.model.Pagination import org.openedx.core.extension.TextConverter -import org.openedx.core.system.ResourceManager import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.domain.model.DiscussionType import org.openedx.discussion.domain.model.ThreadsData @@ -36,6 +34,8 @@ import org.openedx.discussion.presentation.topics.DiscussionTopicsViewModel import org.openedx.discussion.system.notifier.DiscussionNotifier import org.openedx.discussion.system.notifier.DiscussionThreadAdded import org.openedx.discussion.system.notifier.DiscussionThreadDataChanged +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException @OptIn(ExperimentalCoroutinesApi::class) diff --git a/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt b/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt index fcff13a30..676595929 100644 --- a/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt +++ b/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt @@ -23,22 +23,16 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule -import org.openedx.core.BlockType import org.openedx.core.R -import org.openedx.core.UIMessage -import org.openedx.core.domain.model.Block -import org.openedx.core.domain.model.BlockCounts -import org.openedx.core.domain.model.CourseStructure -import org.openedx.core.domain.model.CoursewareAccess -import org.openedx.core.system.ResourceManager -import org.openedx.core.system.notifier.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.domain.model.Topic import org.openedx.discussion.presentation.DiscussionAnalytics import org.openedx.discussion.presentation.DiscussionRouter +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException -import java.util.Date @OptIn(ExperimentalCoroutinesApi::class) class DiscussionTopicsViewModelTest { @@ -55,88 +49,19 @@ class DiscussionTopicsViewModelTest { 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 + + private val mockTopic = Topic( + id = "", + name = "All Topics", + threadListUrl = "", + children = emptyList() ) @Before fun setUp() { Dispatchers.setMain(dispatcher) every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet - every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong - every { courseNotifier.notifier } returns flowOf(CourseDataReady(courseStructure)) + every { courseNotifier.notifier } returns flowOf(CourseLoading(false)) coEvery { courseNotifier.send(any()) } returns Unit } @@ -147,7 +72,7 @@ class DiscussionTopicsViewModelTest { @Test fun `getCourseTopics no internet exception`() = runTest(UnconfinedTestDispatcher()) { - val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, courseNotifier, router) + val viewModel = DiscussionTopicsViewModel("id", "", interactor, resourceManager, analytics, courseNotifier, router) coEvery { interactor.getCourseTopics(any()) } throws UnknownHostException() val message = async { @@ -164,7 +89,7 @@ class DiscussionTopicsViewModelTest { @Test fun `getCourseTopics unknown exception`() = runTest(UnconfinedTestDispatcher()) { - val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, courseNotifier, router) + val viewModel = DiscussionTopicsViewModel("id", "", interactor, resourceManager, analytics, courseNotifier, router) coEvery { interactor.getCourseTopics(any()) } throws Exception() val message = async { @@ -176,14 +101,15 @@ class DiscussionTopicsViewModelTest { coVerify(exactly = 1) { interactor.getCourseTopics(any()) } - assertEquals(somethingWrong, message.await()?.message) + assert(message.await()?.message.isNullOrEmpty()) + assert(viewModel.uiState.value is DiscussionTopicsUIState.Error) } @Test fun `getCourseTopics success`() = runTest(UnconfinedTestDispatcher()) { - val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, courseNotifier, router) + val viewModel = DiscussionTopicsViewModel("id", "", interactor, resourceManager, analytics, courseNotifier, router) - coEvery { interactor.getCourseTopics(any()) } returns mockk() + coEvery { interactor.getCourseTopics(any()) } returns listOf(mockTopic, mockTopic) advanceUntilIdle() val message = async { withTimeoutOrNull(5000) { @@ -198,7 +124,7 @@ class DiscussionTopicsViewModelTest { @Test fun `updateCourseTopics no internet exception`() = runTest(UnconfinedTestDispatcher()) { - val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, courseNotifier, router) + val viewModel = DiscussionTopicsViewModel("id", "", interactor, resourceManager, analytics, courseNotifier, router) coEvery { interactor.getCourseTopics(any()) } throws UnknownHostException() val message = async { @@ -215,7 +141,7 @@ class DiscussionTopicsViewModelTest { @Test fun `updateCourseTopics unknown exception`() = runTest(UnconfinedTestDispatcher()) { - val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, courseNotifier, router) + val viewModel = DiscussionTopicsViewModel("id", "", interactor, resourceManager, analytics, courseNotifier, router) coEvery { interactor.getCourseTopics(any()) } throws Exception() val message = async { @@ -227,14 +153,15 @@ class DiscussionTopicsViewModelTest { coVerify(exactly = 1) { interactor.getCourseTopics(any()) } - assertEquals(somethingWrong, message.await()?.message) + assert(message.await()?.message.isNullOrEmpty()) + assert(viewModel.uiState.value is DiscussionTopicsUIState.Error) } @Test fun `updateCourseTopics success`() = runTest(UnconfinedTestDispatcher()) { - val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, courseNotifier, router) + val viewModel = DiscussionTopicsViewModel("id", "", interactor, resourceManager, analytics, courseNotifier, router) - coEvery { interactor.getCourseTopics(any()) } returns mockk() + coEvery { interactor.getCourseTopics(any()) } returns listOf(mockTopic, mockTopic) val message = async { withTimeoutOrNull(5000) { viewModel.uiMessage.first() as? UIMessage.SnackBarMessage diff --git a/gradle.properties b/gradle.properties index cf0008ddc..d0a098a0d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -22,3 +22,4 @@ kotlin.code.style=official # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true android.nonFinalResIds=false +android.enableR8.fullMode=true diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 35c31a92e..ec34fd6a7 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Fri May 03 13:24:00 EEST 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/i18n_scripts/requirements.txt b/i18n_scripts/requirements.txt new file mode 100644 index 000000000..918b88814 --- /dev/null +++ b/i18n_scripts/requirements.txt @@ -0,0 +1,2 @@ +openedx-atlas==0.6.1 +lxml==5.2.2 diff --git a/i18n_scripts/translation.py b/i18n_scripts/translation.py new file mode 100644 index 000000000..a21aa9eb5 --- /dev/null +++ b/i18n_scripts/translation.py @@ -0,0 +1,431 @@ +#!/usr/bin/env python3 +""" +# Translation Management Script + +This script is designed to manage translations for a project by performing two operations: +1) Getting the English translations from all modules. +2) Splitting translations into separate files for each module and language into a single file. + +More detailed specifications are described in the docs/0002-atlas-translations-management.rst design doc. +""" +import argparse +import os +import re +import sys +from lxml import etree + + +def parse_arguments(): + """ + This function is the argument parser for this script. + The script takes only one of the two arguments --split or --combine. + Additionally, the --replace-underscore argument can only be used with --split. + """ + parser = argparse.ArgumentParser(description='Split or Combine translations.') + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument('--split', action='store_true', + help='Split translations into separate files for each module and language.') + group.add_argument('--combine', action='store_true', + help='Combine the English translations from all modules into a single file.') + parser.add_argument('--replace-underscore', action='store_true', + help='Replace underscores with "-r" in language directories (only with --split).') + return parser.parse_args() + + +def append_element_and_comment(element, previous_element, root): + """ + Appends the given element to the root XML element, preserving the previous element's comment if exists. + + Args: + element (etree.Element): The XML element to append. + previous_element (etree.Element or None): The previous XML element before the current one. + root (etree.Element): The root XML element to append the new element to. + + Returns: + None + """ + try: + # If there was a comment before the current element, add it first. + if isinstance(previous_element, etree._Comment): + previous_element.tail = '\n\t' + root.append(previous_element) + + # Indent all elements with one tab. + element.tail = '\n\t' + root.append(element) + + except Exception as e: + print(f"Error appending element and comment: {e}", file=sys.stderr) + raise + + +def get_translation_file_path(modules_dir, module_name, lang_dir, create_dirs=False): + """ + Retrieves the path of the translation file for a specified module and language directory. + + Parameters: + modules_dir (str): The path to the base directory containing all the modules. + module_name (str): The name of the module for which the translation path is being retrieved. + lang_dir (str): The name of the language directory within the module's directory. + create_dirs (bool): If True, creates the parent directories if they do not exist. Defaults to False. + + Returns: + str: The path to the module's translation file (Localizable.strings). + """ + try: + lang_dir_path = os.path.join(modules_dir, module_name, 'src', 'main', 'res', lang_dir, 'strings.xml') + if create_dirs: + os.makedirs(os.path.dirname(lang_dir_path), exist_ok=True) + return lang_dir_path + except Exception as e: + print(f"Error creating directory path: {e}", file=sys.stderr) + raise + + +def write_translation_file(modules_dir, root, module, lang_dir): + """ + Writes the XML root element to a strings.xml file in the specified language directory. + + Args: + modules_dir (str): The root directory of the project. + root (etree.Element): The root XML element to be written. + module (str): The name of the module. + lang_dir (str): The language directory to write the XML file to. + + Returns: + None + """ + try: + translation_file_path = get_translation_file_path(modules_dir, module, lang_dir, create_dirs=True) + tree = etree.ElementTree(root) + tree.write(translation_file_path, encoding='utf-8', xml_declaration=True) + except Exception as e: + print(f"Error writing translations to file.\n Module: {module}\n Error: {e}", file=sys.stderr) + raise + + +def get_modules_to_translate(modules_dir): + """ + Retrieve the names of modules that have translation files for a specified language. + + Parameters: + modules_dir (str): The path to the directory containing all the modules. + + Returns: + list of str: A list of module names that have translation files for the specified language. + """ + try: + modules_list = [ + directory for directory in os.listdir(modules_dir) + if ( + os.path.isdir(os.path.join(modules_dir, directory)) + and os.path.isfile(get_translation_file_path(modules_dir, directory, 'values')) + and directory != 'i18n' + ) + ] + return modules_list + except FileNotFoundError as e: + print(f"Directory not found: {e}", file=sys.stderr) + raise + except PermissionError as e: + print(f"Permission denied: {e}", file=sys.stderr) + raise + + +def process_module_translations(module_root, combined_root, module): + """ + Process translations from a module and append them to the combined translations. + + Parameters: + module_root (etree.Element): The root element of the module's translations. + combined_root (etree.Element): The combined translations root element. + module (str): The name of the module. + + Returns: + etree.Element: The updated combined translations root element. + """ + previous_element = None + for idx, element in enumerate(module_root.getchildren(), start=1): + try: + try: + translatable = element.attrib.get('translatable', True) + except KeyError as e: + print(f"Error processing element #{idx} from module {module}: " + f"Missing key 'translatable' in element attributes: {e}", file=sys.stderr) + raise + except Exception as e: + print(f"Error processing element #{idx} from module {module}: " + f"Unexpected error accessing 'translatable' attribute: {e}", file=sys.stderr) + raise + + if ( + translatable and translatable != 'false' # Check for the translatable property. + and element.tag in ['string', 'string-array', 'plurals'] # Only those types are read by transifex. + and (not element.nsmap + or element.nsmap and not element.attrib.get('{%s}ignore' % element.nsmap["tools"])) + ): + try: + element.attrib['name'] = '.'.join([module, element.attrib.get('name')]) + except KeyError as e: + print(f"Error setting attribute 'name' for element #{idx} from module {module}: Missing key 'name':" + f" {e}", file=sys.stderr) + raise + except Exception as e: + print(f"Error setting attribute 'name' for element #{idx} from module {module}: Unexpected error:" + f" {e}", file=sys.stderr) + raise + + try: + append_element_and_comment(element, previous_element, combined_root) + except Exception as e: + print(f"Error appending element #{idx} and comment from module {module}: {e}", file=sys.stderr) + raise + + # To check for comments in the next round. + previous_element = element + + except Exception as e: + print(f"Error processing element #{idx} from module {module}: {e}", file=sys.stderr) + raise + + return combined_root + + +def combine_translations(modules_dir): + """ + Combine translations from all specified modules into a single XML element. + + Parameters: + modules_dir (str): The directory containing the modules. + + Returns: + etree.Element: An XML element representing the combined translations. + """ + try: + combined_root = etree.Element('resources') + combined_root.text = '\n\t' + + modules = get_modules_to_translate(modules_dir) + for module in modules: + try: + translation_file = get_translation_file_path(modules_dir, module, 'values') + module_translations_tree = etree.parse(translation_file) + module_root = module_translations_tree.getroot() + combined_root = process_module_translations(module_root, combined_root, module) + + # Put a new line after each module translations. + if len(combined_root): + combined_root[-1].tail = '\n\n\t' + + except etree.XMLSyntaxError as e: + print(f"Error parsing XML file {translation_file}: {e}", file=sys.stderr) + raise + except FileNotFoundError as e: + print(f"Translation file not found: {e}", file=sys.stderr) + raise + except Exception as e: + print(f"Error processing module '{module}': {e}", file=sys.stderr) + raise + + # Unindent the resources closing tag. + if len(combined_root): + combined_root[-1].tail = '\n' + return combined_root + + except Exception as e: + print(f"Error combining translations: {e}", file=sys.stderr) + raise + + +def combine_translation_files(modules_dir=None): + """ + Combine translation files from different modules into a single file. + """ + try: + if not modules_dir: + modules_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + combined_root_element = combine_translations(modules_dir) + write_translation_file(modules_dir, combined_root_element, 'i18n', 'values') + except Exception as e: + print(f"Error combining translation files: {e}", file=sys.stderr) + raise + + +def get_languages_dirs(modules_dir): + """ + Retrieve directories containing language files for translation. + + Args: + modules_dir (str): The directory containing all the modules. + + Returns: + list: A list of directories containing language files for translation. Each directory represents + a specific language and starts with the 'values-' extension. + + Example: + Input: + get_languages_dirs('/path/to/modules') + Output: + ['values-ar', 'values-uk', ...] + """ + try: + lang_parent_dir = os.path.join(modules_dir, 'i18n', 'src', 'main', 'res') + languages_dirs = [ + directory for directory in os.listdir(lang_parent_dir) + if ( + directory.startswith('values-') + and 'strings.xml' in os.listdir(os.path.join(lang_parent_dir, directory)) + ) + ] + return languages_dirs + except FileNotFoundError as e: + print(f"Directory not found: {e}", file=sys.stderr) + raise + except PermissionError as e: + print(f"Permission denied: {e}", file=sys.stderr) + raise + + +def separate_translation_to_modules(modules_dir, lang_dir): + """ + Separates translations from a translation file into modules. + + Args: + modules_dir (str): The directory containing all the modules. + lang_dir (str): The directory containing the translation file being split. + + Returns: + dict: A dictionary containing the translations separated by module. + { + 'module_1_name': etree.Element('resources')_1. + 'module_2_name': etree.Element('resources')_2. + ... + } + """ + translations_roots = {} + try: + # Parse the translation file + file_path = get_translation_file_path(modules_dir, 'i18n', lang_dir) + module_translations_tree = etree.parse(file_path) + root = module_translations_tree.getroot() + previous_entry = None + + # Iterate through translation entries, with index starting from 1 for readablity + for i, translation_entry in enumerate(root.getchildren(), start=1): + try: + if not isinstance(translation_entry, etree._Comment): + # Split the key to extract the module name + module_name, key_remainder = translation_entry.attrib['name'].split('.', maxsplit=1) + translation_entry.attrib['name'] = key_remainder + + # Create a dictionary entry for the module if it doesn't exist + if module_name not in translations_roots: + translations_roots[module_name] = etree.Element('resources') + translations_roots[module_name].text = '\n\t' + + # Append the translation entry to the corresponding module + append_element_and_comment(translation_entry, previous_entry, translations_roots[module_name]) + + previous_entry = translation_entry + + except KeyError as e: + print(f"Error processing entry #{i}: Missing key in translation entry: {e}", file=sys.stderr) + raise + except ValueError as e: + print(f"Error processing entry #{i}: Error splitting module name: {e}", file=sys.stderr) + raise + except Exception as e: + print(f"Error processing entry #{i}: {e}", file=sys.stderr) + raise + + return translations_roots + + except FileNotFoundError as e: + print(f"Error: Translation file not found: {e}", file=sys.stderr) + raise + except etree.XMLSyntaxError as e: + print(f"Error: XML syntax error in translation file: {e}", file=sys.stderr) + raise + except Exception as e: + print(f"Error: In \"separate_translation_to_modules\" an unexpected error occurred: {e}", file=sys.stderr) + raise + + +def split_translation_files(modules_dir=None): + """ + Splits translation files into separate files for each module and language. + + Args: + modules_dir (str, optional): The directory containing all the modules. Defaults to None. + + """ + try: + # Set the modules directory if not provided + if not modules_dir: + modules_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + # Get the directories containing language files + languages_dirs = get_languages_dirs(modules_dir) + + # Iterate through each language directory + for lang_dir in languages_dirs: + translations = separate_translation_to_modules(modules_dir, lang_dir) + # Iterate through each module and write its translations to a file + for module, root in translations.items(): + # Unindent the resources closing tag + root[-1].tail = '\n' + # Write the translation file for the module and language + write_translation_file(modules_dir, root, module, lang_dir) + + except Exception as e: + print(f"Error: In \"split_translation_files\" an unexpected error occurred: {e}", file=sys.stderr) + raise + + +def replace_underscores(modules_dir=None): + try: + if not modules_dir: + modules_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + languages_dirs = get_languages_dirs(modules_dir) + + for lang_dir in languages_dirs: + try: + pattern = r'(values-\w\w)_' + if re.search(pattern, lang_dir): + replacement = r'\1-r' + new_name = re.sub(pattern, replacement, lang_dir, 1) + lang_old_path = os.path.dirname(get_translation_file_path(modules_dir, 'i18n', lang_dir)) + lang_new_path = os.path.dirname(get_translation_file_path(modules_dir, 'i18n', new_name)) + + os.rename(lang_old_path, lang_new_path) + print(f"Renamed {lang_old_path} to {lang_new_path}") + + except FileNotFoundError as e: + print(f"Error: The file or directory {lang_old_path} does not exist: {e}", file=sys.stderr) + raise + except PermissionError as e: + print(f"Error: Permission denied while renaming {lang_old_path}: {e}", file=sys.stderr) + raise + except Exception as e: + print(f"Error: An unexpected error occurred while renaming {lang_old_path} to {lang_new_path}: {e}", + file=sys.stderr) + raise + + except Exception as e: + print(f"Error: An unexpected error occurred in rename_translations_files: {e}", file=sys.stderr) + raise + + +def main(): + args = parse_arguments() + if args.split: + if args.replace_underscore: + replace_underscores() + split_translation_files() + elif args.combine: + combine_translation_files() + + +if __name__ == "__main__": + main() diff --git a/profile/build.gradle b/profile/build.gradle index 1c3c6f301..a1b894421 100644 --- a/profile/build.gradle +++ b/profile/build.gradle @@ -2,6 +2,7 @@ plugins { id 'com.android.library' id 'org.jetbrains.kotlin.android' id 'kotlin-parcelize' + id "org.jetbrains.kotlin.plugin.compose" } android { @@ -29,15 +30,13 @@ android { } kotlinOptions { jvmTarget = JavaVersion.VERSION_17 + freeCompilerArgs = List.of("-Xstring-concat=inline") } buildFeatures { viewBinding true compose true } - composeOptions { - kotlinCompilerExtensionVersion = "$compose_compiler_version" - } flavorDimensions += "env" productFlavors { @@ -56,11 +55,10 @@ android { dependencies { implementation project(path: ":core") - androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + androidTestImplementation 'androidx.test.ext:junit:1.2.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' testImplementation "junit:junit:$junit_version" testImplementation "io.mockk:mockk:$mockk_version" testImplementation "io.mockk:mockk-android:$mockk_version" - testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version" testImplementation "androidx.arch.core:core-testing:$android_arch_version" } \ No newline at end of file diff --git a/profile/proguard-rules.pro b/profile/proguard-rules.pro index 481bb4348..cdb308aa0 100644 --- a/profile/proguard-rules.pro +++ b/profile/proguard-rules.pro @@ -1,21 +1,7 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +# Prevent shrinking, optimization, and obfuscation of the library when consumed by other modules. +# This ensures that all classes and methods remain available for use by the consumer of the library. +# Disabling these steps at the library level is important because the main app module will handle +# shrinking, optimization, and obfuscation for the entire application, including this library. +-dontshrink +-dontoptimize +-dontobfuscate diff --git a/profile/src/main/java/org/openedx/profile/data/repository/ProfileRepository.kt b/profile/src/main/java/org/openedx/profile/data/repository/ProfileRepository.kt index ce5580a45..561d73c05 100644 --- a/profile/src/main/java/org/openedx/profile/data/repository/ProfileRepository.kt +++ b/profile/src/main/java/org/openedx/profile/data/repository/ProfileRepository.kt @@ -1,9 +1,9 @@ package org.openedx.profile.data.repository -import androidx.room.RoomDatabase import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody.Companion.asRequestBody import org.openedx.core.ApiConstants +import org.openedx.core.DatabaseManager import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.profile.data.api.ProfileApi @@ -14,9 +14,9 @@ import java.io.File class ProfileRepository( private val config: Config, private val api: ProfileApi, - private val room: RoomDatabase, private val profilePreferences: ProfilePreferences, private val corePreferences: CorePreferences, + private val databaseManager: DatabaseManager ) { suspend fun getAccount(): Account { @@ -61,8 +61,8 @@ class ProfileRepository( ApiConstants.TOKEN_TYPE_REFRESH ) } finally { - corePreferences.clear() - room.clearAllTables() + corePreferences.clearCorePreferences() + databaseManager.clearTables() } } } diff --git a/profile/src/main/java/org/openedx/profile/presentation/ProfileAnalytics.kt b/profile/src/main/java/org/openedx/profile/presentation/ProfileAnalytics.kt index 2422ba505..684fc309e 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/ProfileAnalytics.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/ProfileAnalytics.kt @@ -2,9 +2,14 @@ package org.openedx.profile.presentation interface ProfileAnalytics { fun logEvent(event: String, params: Map) + fun logScreenEvent(screenName: String, params: Map) } enum class ProfileAnalyticsEvent(val eventName: String, val biValue: String) { + EDIT_PROFILE( + "Profile:Edit Profile", + "edx.bi.app.profile.edit" + ), EDIT_CLICKED( "Profile:Edit Clicked", "edx.bi.app.profile.edit.clicked" diff --git a/profile/src/main/java/org/openedx/profile/presentation/ProfileRouter.kt b/profile/src/main/java/org/openedx/profile/presentation/ProfileRouter.kt index a4b194de4..e9f67ad48 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/ProfileRouter.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/ProfileRouter.kt @@ -1,7 +1,7 @@ package org.openedx.profile.presentation import androidx.fragment.app.FragmentManager -import org.openedx.core.presentation.settings.VideoQualityType +import org.openedx.core.presentation.settings.video.VideoQualityType import org.openedx.profile.domain.model.Account interface ProfileRouter { @@ -21,4 +21,6 @@ interface ProfileRouter { fun navigateToWebContent(fm: FragmentManager, title: String, url: String) fun navigateToManageAccount(fm: FragmentManager) + + fun navigateToCoursesToSync(fm: FragmentManager) } diff --git a/profile/src/main/java/org/openedx/profile/presentation/anothersaccount/AnothersProfileFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/anothersaccount/AnothersProfileFragment.kt index db330faa3..8404bbae1 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/anothersaccount/AnothersProfileFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/anothersaccount/AnothersProfileFragment.kt @@ -41,18 +41,18 @@ import androidx.fragment.app.Fragment import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.core.R -import org.openedx.core.UIMessage import org.openedx.core.domain.model.ProfileImage import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType -import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography -import org.openedx.core.ui.windowSizeValue +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue import org.openedx.profile.domain.model.Account import org.openedx.profile.presentation.ui.ProfileInfoSection import org.openedx.profile.presentation.ui.ProfileTopic diff --git a/profile/src/main/java/org/openedx/profile/presentation/anothersaccount/AnothersProfileViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/anothersaccount/AnothersProfileViewModel.kt index baabdb360..82b906207 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/anothersaccount/AnothersProfileViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/anothersaccount/AnothersProfileViewModel.kt @@ -4,11 +4,11 @@ import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.viewModelScope import kotlinx.coroutines.launch -import org.openedx.core.BaseViewModel import org.openedx.core.R -import org.openedx.core.UIMessage -import org.openedx.core.extension.isInternetError -import org.openedx.core.system.ResourceManager +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import org.openedx.profile.domain.interactor.ProfileInteractor class AnothersProfileViewModel( diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarAccessDialogFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarAccessDialogFragment.kt new file mode 100644 index 000000000..c1dc22df2 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarAccessDialogFragment.kt @@ -0,0 +1,165 @@ +package org.openedx.profile.presentation.calendar + +import android.content.Intent +import android.content.res.Configuration +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.net.Uri +import android.os.Bundle +import android.provider.Settings +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.fragment.app.DialogFragment +import org.koin.android.ext.android.inject +import org.openedx.core.config.Config +import org.openedx.core.presentation.dialog.DefaultDialogBox +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.OpenEdXOutlinedButton +import org.openedx.core.ui.TextIcon +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appTypography +import org.openedx.profile.R +import org.openedx.core.R as CoreR + +class CalendarAccessDialogFragment : DialogFragment() { + + private val config by inject() + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + CalendarAccessDialog( + onCancelClick = { + dismiss() + }, + onGrantCalendarAccessClick = { + val intent = Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.parse("package:" + config.getAppId()) + ) + startActivity(intent) + dismiss() + } + ) + } + } + } + + companion object { + const val DIALOG_TAG = "CalendarAccessDialogFragment" + + fun newInstance(): CalendarAccessDialogFragment { + return CalendarAccessDialogFragment() + } + } +} + +@Composable +private fun CalendarAccessDialog( + modifier: Modifier = Modifier, + onCancelClick: () -> Unit, + onGrantCalendarAccessClick: () -> Unit +) { + val scrollState = rememberScrollState() + DefaultDialogBox( + modifier = modifier, + onDismissClick = onCancelClick + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(scrollState) + .padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Image( + modifier = Modifier.size(24.dp), + painter = painterResource(id = CoreR.drawable.core_ic_warning), + contentDescription = null + ) + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.profile_calendar_access_dialog_title), + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textDark + ) + } + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.profile_calendar_access_dialog_description), + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.textDark + ) + OpenEdXButton( + modifier = Modifier.fillMaxWidth(), + onClick = { + onGrantCalendarAccessClick() + }, + content = { + TextIcon( + text = stringResource(id = R.string.profile_grant_access_calendar), + icon = Icons.AutoMirrored.Filled.OpenInNew, + color = MaterialTheme.appColors.primaryButtonText, + textStyle = MaterialTheme.appTypography.labelLarge, + iconModifier = Modifier.padding(start = 4.dp) + ) + } + ) + OpenEdXOutlinedButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = CoreR.string.core_cancel), + backgroundColor = MaterialTheme.appColors.background, + borderColor = MaterialTheme.appColors.primaryButtonBackground, + textColor = MaterialTheme.appColors.primaryButtonBackground, + onClick = { + onCancelClick() + } + ) + } + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun CalendarAccessDialogPreview() { + OpenEdXTheme { + CalendarAccessDialog( + onCancelClick = { }, + onGrantCalendarAccessClick = { } + ) + } +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarColor.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarColor.kt new file mode 100644 index 000000000..361fa5776 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarColor.kt @@ -0,0 +1,19 @@ +package org.openedx.profile.presentation.calendar + +import androidx.annotation.StringRes +import org.openedx.profile.R + +enum class CalendarColor( + @StringRes + val title: Int, + val color: Long +) { + ACCENT(R.string.calendar_color_accent, 0xFFD13329), + RED(R.string.calendar_color_red, 0xFFFF2967), + ORANGE(R.string.calendar_color_orange, 0xFFFF9501), + YELLOW(R.string.calendar_color_yellow, 0xFFFFCC01), + GREEN(R.string.calendar_color_green, 0xFF64DA38), + BLUE(R.string.calendar_color_blue, 0xFF1AAEF8), + PURPLE(R.string.calendar_color_purple, 0xFFCC73E1), + BROWN(R.string.calendar_color_brown, 0xFFA2845E); +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarFragment.kt new file mode 100644 index 000000000..eaf43cf56 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarFragment.kt @@ -0,0 +1,111 @@ +package org.openedx.profile.presentation.calendar + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +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.compose.koinViewModel +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.rememberWindowSize + +class CalendarFragment : Fragment() { + + private val permissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { isGranted -> + if (!isGranted.containsValue(false)) { + val dialog = NewCalendarDialogFragment.newInstance(NewCalendarDialogType.CREATE_NEW) + dialog.show( + requireActivity().supportFragmentManager, + NewCalendarDialogFragment.DIALOG_TAG + ) + } else { + val dialog = CalendarAccessDialogFragment.newInstance() + dialog.show( + requireActivity().supportFragmentManager, + CalendarAccessDialogFragment.DIALOG_TAG + ) + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val windowSize = rememberWindowSize() + val viewModel: CalendarViewModel = koinViewModel() + val uiState by viewModel.uiState.collectAsState() + + CalendarView( + windowSize = windowSize, + uiState = uiState, + setUpCalendarSync = { + viewModel.setUpCalendarSync(permissionLauncher) + }, + onBackClick = { + requireActivity().supportFragmentManager.popBackStack() + }, + onCalendarSyncSwitchClick = { + viewModel.setCalendarSyncEnabled(it, requireActivity().supportFragmentManager) + }, + onRelativeDateSwitchClick = { + viewModel.setRelativeDateEnabled(it) + }, + onChangeSyncOptionClick = { + val dialog = NewCalendarDialogFragment.newInstance(NewCalendarDialogType.UPDATE) + dialog.show( + requireActivity().supportFragmentManager, + NewCalendarDialogFragment.DIALOG_TAG + ) + }, + onCourseToSyncClick = { + viewModel.navigateToCoursesToSync(requireActivity().supportFragmentManager) + } + ) + } + } + } +} + +@Composable +private fun CalendarView( + windowSize: WindowSize, + uiState: CalendarUIState, + setUpCalendarSync: () -> Unit, + onBackClick: () -> Unit, + onChangeSyncOptionClick: () -> Unit, + onCourseToSyncClick: () -> Unit, + onCalendarSyncSwitchClick: (Boolean) -> Unit, + onRelativeDateSwitchClick: (Boolean) -> Unit +) { + if (!uiState.isCalendarExist) { + CalendarSetUpView( + windowSize = windowSize, + useRelativeDates = uiState.isRelativeDateEnabled, + setUpCalendarSync = setUpCalendarSync, + onRelativeDateSwitchClick = onRelativeDateSwitchClick, + onBackClick = onBackClick + ) + } else { + CalendarSettingsView( + windowSize = windowSize, + uiState = uiState, + onBackClick = onBackClick, + onCalendarSyncSwitchClick = onCalendarSyncSwitchClick, + onRelativeDateSwitchClick = onRelativeDateSwitchClick, + onChangeSyncOptionClick = onChangeSyncOptionClick, + onCourseToSyncClick = onCourseToSyncClick + ) + } +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarSetUpView.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarSetUpView.kt new file mode 100644 index 000000000..363dc70eb --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarSetUpView.kt @@ -0,0 +1,222 @@ +package org.openedx.profile.presentation.calendar + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Card +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Autorenew +import androidx.compose.material.icons.rounded.CalendarToday +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.Toolbar +import org.openedx.core.ui.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.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.windowSizeValue +import org.openedx.profile.R + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun CalendarSetUpView( + windowSize: WindowSize, + useRelativeDates: Boolean, + setUpCalendarSync: () -> Unit, + onRelativeDateSwitchClick: (Boolean) -> Unit, + onBackClick: () -> Unit +) { + val scaffoldState = rememberScaffoldState() + val scrollState = rememberScrollState() + + Scaffold( + modifier = Modifier + .fillMaxSize() + .semantics { + testTagsAsResourceId = true + }, + scaffoldState = scaffoldState + ) { paddingValues -> + + val contentWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 420.dp), + compact = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + ) + ) + } + + val topBarWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), + compact = Modifier + .fillMaxWidth() + ) + ) + } + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.TopCenter + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .settingsHeaderBackground() + .statusBarsInset(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Toolbar( + modifier = topBarWidth + .displayCutoutForLandscape(), + label = stringResource(id = R.string.profile_dates_and_calendar), + canShowBackBtn = true, + labelTint = MaterialTheme.appColors.settingsTitleContent, + iconTint = MaterialTheme.appColors.settingsTitleContent, + onBackClick = onBackClick + ) + + Box( + modifier = Modifier + .fillMaxSize() + .clip(MaterialTheme.appShapes.screenBackgroundShape) + .background(MaterialTheme.appColors.background) + .displayCutoutForLandscape(), + contentAlignment = Alignment.TopCenter + ) { + Column( + modifier = contentWidth + .verticalScroll(scrollState) + .padding(vertical = 28.dp), + ) { + Text( + modifier = Modifier.testTag("txt_calendar_sync"), + text = stringResource(id = R.string.profile_calendar_sync), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textSecondary + ) + Spacer(modifier = Modifier.height(14.dp)) + Card( + shape = MaterialTheme.appShapes.cardShape, + elevation = 0.dp, + backgroundColor = MaterialTheme.appColors.cardViewBackground + ) { + Column( + modifier = Modifier + .padding(horizontal = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .padding(vertical = 28.dp), + contentAlignment = Alignment.Center + ) { + Icon( + modifier = Modifier + .fillMaxWidth() + .height(148.dp), + tint = MaterialTheme.appColors.textDark, + imageVector = Icons.Rounded.CalendarToday, + contentDescription = null + ) + Icon( + modifier = Modifier + .fillMaxWidth() + .padding(top = 30.dp) + .height(60.dp), + tint = MaterialTheme.appColors.textDark, + imageVector = Icons.Default.Autorenew, + contentDescription = null + ) + } + Text( + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + text = stringResource(id = R.string.profile_calendar_sync), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textDark + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + text = stringResource(id = R.string.profile_calendar_sync_description), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textDark + ) + Spacer(modifier = Modifier.height(16.dp)) + OpenEdXButton( + modifier = Modifier.fillMaxWidth(0.75f), + text = stringResource(id = R.string.profile_set_up_calendar_sync), + onClick = { + setUpCalendarSync() + } + ) + Spacer(modifier = Modifier.height(24.dp)) + } + } + Spacer(modifier = Modifier.height(28.dp)) + OptionsSection( + isRelativeDatesEnabled = useRelativeDates, + onRelativeDateSwitchClick = onRelativeDateSwitchClick + ) + } + } + } + } + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun CalendarScreenPreview() { + OpenEdXTheme { + CalendarSetUpView( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + useRelativeDates = true, + setUpCalendarSync = {}, + onRelativeDateSwitchClick = { _ -> }, + onBackClick = {} + ) + } +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarSettingsView.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarSettingsView.kt new file mode 100644 index 000000000..8a78c6f12 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarSettingsView.kt @@ -0,0 +1,331 @@ +package org.openedx.profile.presentation.calendar + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Card +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.LocalMinimumInteractiveComponentEnforcement +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Switch +import androidx.compose.material.SwitchDefaults +import androidx.compose.material.Text +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.openedx.core.domain.model.CalendarData +import org.openedx.core.presentation.settings.calendarsync.CalendarSyncState +import org.openedx.core.ui.OpenEdXOutlinedButton +import org.openedx.core.ui.Toolbar +import org.openedx.core.ui.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.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.windowSizeValue +import org.openedx.profile.R +import org.openedx.profile.presentation.ui.SettingsItem + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun CalendarSettingsView( + windowSize: WindowSize, + uiState: CalendarUIState, + onCalendarSyncSwitchClick: (Boolean) -> Unit, + onRelativeDateSwitchClick: (Boolean) -> Unit, + onChangeSyncOptionClick: () -> Unit, + onCourseToSyncClick: () -> Unit, + onBackClick: () -> Unit +) { + val scaffoldState = rememberScaffoldState() + val scrollState = rememberScrollState() + + Scaffold( + modifier = Modifier + .fillMaxSize() + .semantics { + testTagsAsResourceId = true + }, + scaffoldState = scaffoldState + ) { paddingValues -> + + val contentWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 420.dp), + compact = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + ) + ) + } + + val topBarWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), + compact = Modifier + .fillMaxWidth() + ) + ) + } + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.TopCenter + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .settingsHeaderBackground() + .statusBarsInset(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Toolbar( + modifier = topBarWidth + .displayCutoutForLandscape(), + label = stringResource(id = R.string.profile_dates_and_calendar), + canShowBackBtn = true, + labelTint = MaterialTheme.appColors.settingsTitleContent, + iconTint = MaterialTheme.appColors.settingsTitleContent, + onBackClick = onBackClick + ) + + Box( + modifier = Modifier + .fillMaxSize() + .clip(MaterialTheme.appShapes.screenBackgroundShape) + .background(MaterialTheme.appColors.background) + .displayCutoutForLandscape(), + contentAlignment = Alignment.TopCenter + ) { + Column( + modifier = contentWidth + .verticalScroll(scrollState) + .padding(vertical = 28.dp), + ) { + if (uiState.calendarData != null) { + CalendarSyncSection( + isCourseCalendarSyncEnabled = uiState.isCalendarSyncEnabled, + calendarData = uiState.calendarData, + calendarSyncState = uiState.calendarSyncState, + onCalendarSyncSwitchClick = onCalendarSyncSwitchClick, + onChangeSyncOptionClick = onChangeSyncOptionClick + ) + } + Spacer(modifier = Modifier.height(20.dp)) + if (uiState.coursesSynced != null) { + CoursesToSyncSection( + coursesSynced = uiState.coursesSynced, + onCourseToSyncClick = onCourseToSyncClick + ) + } + Spacer(modifier = Modifier.height(32.dp)) + OptionsSection( + isRelativeDatesEnabled = uiState.isRelativeDateEnabled, + onRelativeDateSwitchClick = onRelativeDateSwitchClick + ) + } + } + } + } + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun CalendarSyncSection( + isCourseCalendarSyncEnabled: Boolean, + calendarData: CalendarData, + calendarSyncState: CalendarSyncState, + onCalendarSyncSwitchClick: (Boolean) -> Unit, + onChangeSyncOptionClick: () -> Unit +) { + Column { + SectionTitle(stringResource(id = R.string.profile_calendar_sync)) + Spacer(modifier = Modifier.height(8.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clip(MaterialTheme.appShapes.cardShape) + .background(MaterialTheme.appColors.cardViewBackground) + .padding(vertical = 8.dp, horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Box( + modifier = Modifier + .size(18.dp) + .clip(CircleShape) + .background(Color(calendarData.color)) + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = calendarData.title, + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textDark + ) + Text( + text = stringResource(id = calendarSyncState.title), + style = MaterialTheme.appTypography.labelSmall, + color = MaterialTheme.appColors.textFieldHint + ) + } + if (calendarSyncState == CalendarSyncState.SYNCHRONIZATION) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = MaterialTheme.appColors.primary + ) + } else { + Icon( + imageVector = calendarSyncState.icon, + tint = calendarSyncState.tint, + contentDescription = null + ) + } + } + Spacer(modifier = Modifier.height(16.dp)) + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier.weight(1f), + text = stringResource(R.string.profile_course_calendar_sync), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textDark + ) + CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) { + Switch( + modifier = Modifier + .padding(0.dp), + checked = isCourseCalendarSyncEnabled, + onCheckedChange = onCalendarSyncSwitchClick, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.appColors.textAccent + ) + ) + } + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.profile_currently_syncing_events), + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textPrimaryVariant + ) + Spacer(modifier = Modifier.height(16.dp)) + SyncOptionsButton( + onChangeSyncOptionClick = onChangeSyncOptionClick + ) + } +} + +@Composable +fun SyncOptionsButton( + onChangeSyncOptionClick: () -> Unit +) { + OpenEdXOutlinedButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.profile_change_sync_options), + backgroundColor = MaterialTheme.appColors.background, + borderColor = MaterialTheme.appColors.primaryButtonBackground, + textColor = MaterialTheme.appColors.primaryButtonBackground, + onClick = { + onChangeSyncOptionClick() + } + ) +} + +@Composable +fun CoursesToSyncSection( + coursesSynced: Int, + onCourseToSyncClick: () -> Unit +) { + Column { + SectionTitle(stringResource(R.string.profile_courses_to_sync)) + Spacer(modifier = Modifier.height(8.dp)) + Card( + modifier = Modifier, + shape = MaterialTheme.appShapes.cardShape, + elevation = 0.dp, + backgroundColor = MaterialTheme.appColors.cardViewBackground + ) { + SettingsItem( + text = stringResource(R.string.profile_syncing_courses, coursesSynced), + onClick = onCourseToSyncClick + ) + } + } +} + +@Composable +fun SectionTitle(title: String) { + Text( + text = title, + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textSecondary + ) +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun CalendarSettingsViewPreview() { + OpenEdXTheme { + CalendarSettingsView( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + uiState = CalendarUIState( + isCalendarExist = true, + calendarData = CalendarData("calendar", Color.Red.toArgb()), + calendarSyncState = CalendarSyncState.SYNCED, + isCalendarSyncEnabled = false, + isRelativeDateEnabled = true, + coursesSynced = 5 + ), + onBackClick = {}, + onCalendarSyncSwitchClick = {}, + onRelativeDateSwitchClick = {}, + onChangeSyncOptionClick = {}, + onCourseToSyncClick = {} + ) + } +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarUIState.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarUIState.kt new file mode 100644 index 000000000..513a5c5e5 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarUIState.kt @@ -0,0 +1,13 @@ +package org.openedx.profile.presentation.calendar + +import org.openedx.core.domain.model.CalendarData +import org.openedx.core.presentation.settings.calendarsync.CalendarSyncState + +data class CalendarUIState( + val isCalendarExist: Boolean, + val calendarData: CalendarData? = null, + val calendarSyncState: CalendarSyncState, + val isCalendarSyncEnabled: Boolean, + val coursesSynced: Int?, + val isRelativeDateEnabled: Boolean, +) diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarView.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarView.kt new file mode 100644 index 000000000..4cc682dc7 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarView.kt @@ -0,0 +1,73 @@ +package org.openedx.profile.presentation.calendar + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.LocalMinimumInteractiveComponentEnforcement +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Switch +import androidx.compose.material.SwitchDefaults +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appTypography +import org.openedx.core.utils.TimeUtils +import org.openedx.profile.R +import java.util.Date + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun OptionsSection( + isRelativeDatesEnabled: Boolean, + onRelativeDateSwitchClick: (Boolean) -> Unit +) { + val context = LocalContext.current + val textDescription = if (isRelativeDatesEnabled) { + stringResource(R.string.profile_show_relative_dates) + } else { + stringResource( + R.string.profile_show_full_dates, + TimeUtils.formatToString(context, Date(), false) + ) + } + Column { + SectionTitle(stringResource(R.string.profile_options)) + Spacer(modifier = Modifier.height(8.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.weight(1f), + text = stringResource(R.string.profile_use_relative_dates), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textDark + ) + CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) { + Switch( + modifier = Modifier + .padding(0.dp), + checked = isRelativeDatesEnabled, + onCheckedChange = onRelativeDateSwitchClick, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.appColors.textAccent + ) + ) + } + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = textDescription, + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textPrimaryVariant + ) + } +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarViewModel.kt new file mode 100644 index 000000000..85f217446 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarViewModel.kt @@ -0,0 +1,147 @@ +package org.openedx.profile.presentation.calendar + +import androidx.activity.result.ActivityResultLauncher +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.openedx.core.data.storage.CalendarPreferences +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.interactor.CalendarInteractor +import org.openedx.core.presentation.settings.calendarsync.CalendarSyncState +import org.openedx.core.system.CalendarManager +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.system.notifier.calendar.CalendarCreated +import org.openedx.core.system.notifier.calendar.CalendarNotifier +import org.openedx.core.system.notifier.calendar.CalendarSyncDisabled +import org.openedx.core.system.notifier.calendar.CalendarSyncFailed +import org.openedx.core.system.notifier.calendar.CalendarSyncOffline +import org.openedx.core.system.notifier.calendar.CalendarSynced +import org.openedx.core.system.notifier.calendar.CalendarSyncing +import org.openedx.core.worker.CalendarSyncScheduler +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.profile.presentation.ProfileRouter + +class CalendarViewModel( + private val calendarSyncScheduler: CalendarSyncScheduler, + private val calendarManager: CalendarManager, + private val calendarPreferences: CalendarPreferences, + private val calendarNotifier: CalendarNotifier, + private val calendarInteractor: CalendarInteractor, + private val corePreferences: CorePreferences, + private val profileRouter: ProfileRouter, + private val networkConnection: NetworkConnection, +) : BaseViewModel() { + + private val calendarInitState: CalendarUIState + get() = CalendarUIState( + isCalendarExist = isCalendarExist(), + calendarData = null, + calendarSyncState = if (networkConnection.isOnline()) CalendarSyncState.SYNCED else CalendarSyncState.OFFLINE, + isCalendarSyncEnabled = calendarPreferences.isCalendarSyncEnabled, + coursesSynced = null, + isRelativeDateEnabled = corePreferences.isRelativeDatesEnabled, + ) + + private val _uiState = MutableStateFlow(calendarInitState) + val uiState: StateFlow + get() = _uiState.asStateFlow() + + init { + calendarSyncScheduler.requestImmediateSync() + viewModelScope.launch { + calendarNotifier.notifier.collect { calendarEvent -> + when (calendarEvent) { + CalendarCreated -> { + calendarSyncScheduler.requestImmediateSync() + _uiState.update { it.copy(isCalendarExist = true) } + getCalendarData() + } + + CalendarSyncing -> { + _uiState.update { it.copy(calendarSyncState = CalendarSyncState.SYNCHRONIZATION) } + } + + CalendarSynced -> { + _uiState.update { it.copy(calendarSyncState = CalendarSyncState.SYNCED) } + updateSyncedCoursesCount() + } + + CalendarSyncFailed -> { + _uiState.update { it.copy(calendarSyncState = CalendarSyncState.SYNC_FAILED) } + updateSyncedCoursesCount() + } + + CalendarSyncOffline -> { + _uiState.update { it.copy(calendarSyncState = CalendarSyncState.OFFLINE) } + } + + CalendarSyncDisabled -> { + _uiState.update { calendarInitState } + } + } + } + } + + getCalendarData() + updateSyncedCoursesCount() + } + + fun setUpCalendarSync(permissionLauncher: ActivityResultLauncher>) { + permissionLauncher.launch(calendarManager.permissions) + } + + fun setCalendarSyncEnabled(isEnabled: Boolean, fragmentManager: FragmentManager) { + if (!isEnabled) { + _uiState.value.calendarData?.let { + val dialog = DisableCalendarSyncDialogFragment.newInstance(it) + dialog.show( + fragmentManager, + DisableCalendarSyncDialogFragment.DIALOG_TAG + ) + } + } else { + calendarPreferences.isCalendarSyncEnabled = true + _uiState.update { it.copy(isCalendarSyncEnabled = true) } + calendarSyncScheduler.requestImmediateSync() + } + } + + fun setRelativeDateEnabled(isEnabled: Boolean) { + corePreferences.isRelativeDatesEnabled = isEnabled + _uiState.update { it.copy(isRelativeDateEnabled = isEnabled) } + } + + fun navigateToCoursesToSync(fragmentManager: FragmentManager) { + profileRouter.navigateToCoursesToSync(fragmentManager) + } + + private fun getCalendarData() { + if (calendarManager.hasPermissions()) { + val calendarData = calendarManager.getCalendarData(calendarId = calendarPreferences.calendarId) + _uiState.update { it.copy(calendarData = calendarData) } + } + } + + private fun updateSyncedCoursesCount() { + viewModelScope.launch { + val courseStates = calendarInteractor.getAllCourseCalendarStateFromCache() + if (courseStates.isNotEmpty()) { + val syncedCoursesCount = courseStates.count { it.isCourseSyncEnabled } + _uiState.update { it.copy(coursesSynced = syncedCoursesCount) } + } + } + } + + private fun isCalendarExist(): Boolean { + return try { + calendarPreferences.calendarId != CalendarManager.CALENDAR_DOES_NOT_EXIST && + calendarManager.isCalendarExist(calendarPreferences.calendarId) + } catch (e: SecurityException) { + false + } + } +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CoursesToSyncFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CoursesToSyncFragment.kt new file mode 100644 index 000000000..fed719696 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CoursesToSyncFragment.kt @@ -0,0 +1,443 @@ +package org.openedx.profile.presentation.calendar + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.Checkbox +import androidx.compose.material.CheckboxDefaults +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.LocalMinimumInteractiveComponentEnforcement +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Switch +import androidx.compose.material.SwitchDefaults +import androidx.compose.material.Tab +import androidx.compose.material.TabRow +import androidx.compose.material.Text +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.fragment.app.Fragment +import org.koin.androidx.compose.koinViewModel +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.Toolbar +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.theme.fontFamily +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue +import org.openedx.profile.R +import org.openedx.core.R as coreR + +class CoursesToSyncFragment : Fragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val windowSize = rememberWindowSize() + val viewModel: CoursesToSyncViewModel = koinViewModel() + val uiState by viewModel.uiState.collectAsState() + val uiMessage by viewModel.uiMessage.collectAsState(initial = null) + + CoursesToSyncView( + windowSize = windowSize, + uiState = uiState, + uiMessage = uiMessage, + onHideInactiveCoursesSwitchClick = { + viewModel.setHideInactiveCoursesEnabled(it) + }, + onCourseSyncCheckChange = { isEnabled, courseId -> + viewModel.setCourseSyncEnabled(isEnabled, courseId) + }, + onBackClick = { + requireActivity().supportFragmentManager.popBackStack() + } + ) + } + } + } +} + +@Composable +private fun CoursesToSyncView( + windowSize: WindowSize, + onBackClick: () -> Unit, + uiState: CoursesToSyncUIState, + uiMessage: UIMessage?, + onHideInactiveCoursesSwitchClick: (Boolean) -> Unit, + onCourseSyncCheckChange: (Boolean, String) -> Unit +) { + val scaffoldState = rememberScaffoldState() + + Scaffold( + modifier = Modifier + .fillMaxSize(), + scaffoldState = scaffoldState + ) { paddingValues -> + + val contentWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 420.dp), + compact = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + ) + ) + } + + val topBarWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), + compact = Modifier + .fillMaxWidth() + ) + ) + } + + HandleUIMessage( + uiMessage = uiMessage, + scaffoldState = scaffoldState + ) + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.TopCenter + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .settingsHeaderBackground() + .statusBarsInset(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Toolbar( + modifier = topBarWidth + .displayCutoutForLandscape(), + label = stringResource(id = R.string.profile_courses_to_sync), + canShowBackBtn = true, + labelTint = MaterialTheme.appColors.settingsTitleContent, + iconTint = MaterialTheme.appColors.settingsTitleContent, + onBackClick = onBackClick + ) + + Box( + modifier = Modifier + .fillMaxSize() + .clip(MaterialTheme.appShapes.screenBackgroundShape) + .background(MaterialTheme.appColors.background) + .displayCutoutForLandscape(), + contentAlignment = Alignment.TopCenter + ) { + Column( + modifier = contentWidth + .padding(vertical = 28.dp), + ) { + Text( + text = stringResource(R.string.profile_courses_to_sync_title), + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textPrimaryVariant + ) + Spacer(modifier = Modifier.height(20.dp)) + HideInactiveCoursesView( + isHideInactiveCourses = uiState.isHideInactiveCourses, + onHideInactiveCoursesSwitchClick = onHideInactiveCoursesSwitchClick + ) + Spacer(modifier = Modifier.height(20.dp)) + SyncCourseTabRow( + uiState = uiState, + onCourseSyncCheckChange = onCourseSyncCheckChange + ) + } + } + } + } + } +} + +@Composable +private fun SyncCourseTabRow( + uiState: CoursesToSyncUIState, + onCourseSyncCheckChange: (Boolean, String) -> Unit +) { + var selectedTab by remember { mutableStateOf(SyncCourseTab.SYNCED) } + val selectedTabIndex = SyncCourseTab.entries.indexOf(selectedTab) + + Column { + TabRow( + modifier = Modifier + .clip(MaterialTheme.appShapes.buttonShape) + .border( + 1.dp, + MaterialTheme.appColors.textAccent, + MaterialTheme.appShapes.buttonShape + ), + selectedTabIndex = selectedTabIndex, + backgroundColor = MaterialTheme.appColors.background, + indicator = {} + ) { + SyncCourseTab.entries.forEachIndexed { index, tab -> + val backgroundColor = if (selectedTabIndex == index) { + MaterialTheme.appColors.textAccent + } else { + MaterialTheme.appColors.background + } + Tab( + modifier = Modifier + .background(backgroundColor), + text = { Text(stringResource(id = tab.title)) }, + selected = selectedTabIndex == index, + onClick = { selectedTab = SyncCourseTab.entries[index] }, + unselectedContentColor = MaterialTheme.appColors.textAccent, + selectedContentColor = MaterialTheme.appColors.background + ) + } + } + + CourseCheckboxList( + selectedTab = selectedTab, + uiState = uiState, + onCourseSyncCheckChange = onCourseSyncCheckChange + ) + } +} + + +@Composable +private fun CourseCheckboxList( + selectedTab: SyncCourseTab, + uiState: CoursesToSyncUIState, + onCourseSyncCheckChange: (Boolean, String) -> Unit +) { + if (uiState.isLoading) { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } + } else { + LazyColumn( + modifier = Modifier.padding(8.dp), + ) { + val courseIds = uiState.coursesCalendarState + .filter { it.isCourseSyncEnabled == (selectedTab == SyncCourseTab.SYNCED) } + .map { it.courseId } + val filteredEnrollments = uiState.enrollmentsStatus + .filter { it.courseId in courseIds } + .let { enrollments -> + if (uiState.isHideInactiveCourses) { + enrollments.filter { it.recentlyActive } + } else { + enrollments + } + } + if (filteredEnrollments.isEmpty()) { + item { + EmptyListState( + selectedTab = selectedTab + ) + } + } else { + items(filteredEnrollments) { course -> + val isCourseSyncEnabled = + uiState.coursesCalendarState.find { it.courseId == course.courseId }?.isCourseSyncEnabled + ?: false + val annotatedString = buildAnnotatedString { + append(course.courseName) + if (!course.recentlyActive) { + append(" ") + withStyle( + style = SpanStyle( + fontSize = 10.sp, + fontWeight = FontWeight.Normal, + letterSpacing = 0.sp, + fontFamily = fontFamily, + color = MaterialTheme.appColors.textFieldHint, + ) + ) { + append(stringResource(R.string.profile_inactive)) + } + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + modifier = Modifier.size(24.dp), + colors = CheckboxDefaults.colors( + checkedColor = MaterialTheme.appColors.primary, + uncheckedColor = MaterialTheme.appColors.textFieldText + ), + checked = isCourseSyncEnabled, + enabled = course.recentlyActive, + onCheckedChange = { isEnabled -> + onCourseSyncCheckChange(isEnabled, course.courseId) + } + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = annotatedString, + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textDark + ) + } + } + } + } + } +} + +@Composable +private fun EmptyListState( + modifier: Modifier = Modifier, + selectedTab: SyncCourseTab, +) { + val description = if (selectedTab == SyncCourseTab.SYNCED) { + stringResource(id = R.string.profile_no_sync_courses) + } else { + stringResource(id = R.string.profile_no_courses_with_current_filter) + } + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 40.dp, vertical = 60.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + modifier = Modifier.size(96.dp), + painter = painterResource(id = coreR.drawable.core_ic_book), + tint = MaterialTheme.appColors.divider, + contentDescription = null + ) + Text( + text = stringResource( + id = R.string.profile_no_courses, + stringResource(id = selectedTab.title) + ), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textDark + ) + Text( + text = description, + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textDark, + textAlign = TextAlign.Center + ) + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +private fun HideInactiveCoursesView( + isHideInactiveCourses: Boolean, + onHideInactiveCoursesSwitchClick: (Boolean) -> Unit +) { + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.weight(1f), + text = stringResource(R.string.profile_hide_inactive_courses), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textDark + ) + CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) { + Switch( + modifier = Modifier + .padding(0.dp), + checked = isHideInactiveCourses, + onCheckedChange = onHideInactiveCoursesSwitchClick, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.appColors.textAccent + ) + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.profile_automatically_remove_events), + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textPrimaryVariant + ) + } +} + +@Preview +@Composable +private fun CoursesToSyncViewPreview() { + OpenEdXTheme { + CoursesToSyncView( + windowSize = rememberWindowSize(), + uiState = CoursesToSyncUIState( + enrollmentsStatus = emptyList(), + coursesCalendarState = emptyList(), + isHideInactiveCourses = true, + isLoading = false + ), + uiMessage = null, + onHideInactiveCoursesSwitchClick = {}, + onCourseSyncCheckChange = { _, _ -> }, + onBackClick = {} + ) + } +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CoursesToSyncUIState.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CoursesToSyncUIState.kt new file mode 100644 index 000000000..e43988d2f --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CoursesToSyncUIState.kt @@ -0,0 +1,11 @@ +package org.openedx.profile.presentation.calendar + +import org.openedx.core.domain.model.CourseCalendarState +import org.openedx.core.domain.model.EnrollmentStatus + +data class CoursesToSyncUIState( + val enrollmentsStatus: List, + val coursesCalendarState: List, + val isHideInactiveCourses: Boolean, + val isLoading: Boolean +) diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CoursesToSyncViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CoursesToSyncViewModel.kt new file mode 100644 index 000000000..cf7f8b24d --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CoursesToSyncViewModel.kt @@ -0,0 +1,92 @@ +package org.openedx.profile.presentation.calendar + +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.openedx.core.R +import org.openedx.core.data.storage.CalendarPreferences +import org.openedx.core.domain.interactor.CalendarInteractor +import org.openedx.core.worker.CalendarSyncScheduler +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager + +class CoursesToSyncViewModel( + private val calendarInteractor: CalendarInteractor, + private val calendarPreferences: CalendarPreferences, + private val calendarSyncScheduler: CalendarSyncScheduler, + private val resourceManager: ResourceManager, +) : BaseViewModel() { + + private val _uiState = MutableStateFlow( + CoursesToSyncUIState( + enrollmentsStatus = emptyList(), + coursesCalendarState = emptyList(), + isHideInactiveCourses = calendarPreferences.isHideInactiveCourses, + isLoading = true + ) + ) + + private val _uiMessage = MutableSharedFlow() + val uiMessage: SharedFlow + get() = _uiMessage.asSharedFlow() + + val uiState: StateFlow + get() = _uiState.asStateFlow() + + init { + getEnrollmentsStatus() + getCourseCalendarState() + } + + fun setHideInactiveCoursesEnabled(isEnabled: Boolean) { + calendarPreferences.isHideInactiveCourses = isEnabled + _uiState.update { it.copy(isHideInactiveCourses = isEnabled) } + } + + fun setCourseSyncEnabled(isEnabled: Boolean, courseId: String) { + viewModelScope.launch { + calendarInteractor.updateCourseCalendarStateByIdInCache( + courseId = courseId, + isCourseSyncEnabled = isEnabled + ) + getCourseCalendarState() + calendarSyncScheduler.requestImmediateSync(courseId) + } + } + + private fun getCourseCalendarState() { + viewModelScope.launch { + try { + val coursesCalendarState = calendarInteractor.getAllCourseCalendarStateFromCache() + _uiState.update { it.copy(coursesCalendarState = coursesCalendarState) } + } catch (e: Exception) { + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error))) + } + } + } + + private fun getEnrollmentsStatus() { + viewModelScope.launch { + try { + val enrollmentsStatus = calendarInteractor.getEnrollmentsStatus() + _uiState.update { it.copy(enrollmentsStatus = enrollmentsStatus) } + } catch (e: Exception) { + if (e.isInternetError()) { + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection))) + } else { + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error))) + } + } finally { + _uiState.update { it.copy(isLoading = false) } + } + } + } +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/DisableCalendarSyncDialogFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/DisableCalendarSyncDialogFragment.kt new file mode 100644 index 000000000..8a71410b1 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/DisableCalendarSyncDialogFragment.kt @@ -0,0 +1,194 @@ +package org.openedx.profile.presentation.calendar + +import android.content.res.Configuration +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.os.bundleOf +import androidx.fragment.app.DialogFragment +import org.koin.androidx.compose.koinViewModel +import org.openedx.core.domain.model.CalendarData +import org.openedx.core.presentation.dialog.DefaultDialogBox +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.OpenEdXOutlinedButton +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes +import org.openedx.core.ui.theme.appTypography +import org.openedx.foundation.extension.parcelable +import org.openedx.profile.R +import androidx.compose.ui.graphics.Color as ComposeColor +import org.openedx.core.R as coreR + +class DisableCalendarSyncDialogFragment : DialogFragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val viewModel: DisableCalendarSyncDialogViewModel = koinViewModel() + DisableCalendarSyncDialogView( + calendarData = requireArguments().parcelable(ARG_CALENDAR_DATA), + onCancelClick = { + dismiss() + }, + onDisableSyncingClick = { + viewModel.disableSyncingClick() + dismiss() + } + ) + } + } + } + + companion object { + const val DIALOG_TAG = "DisableCalendarSyncDialogFragment" + const val ARG_CALENDAR_DATA = "ARG_CALENDAR_DATA" + + fun newInstance( + calendarData: CalendarData + ): DisableCalendarSyncDialogFragment { + val fragment = DisableCalendarSyncDialogFragment() + fragment.arguments = bundleOf( + ARG_CALENDAR_DATA to calendarData + ) + return fragment + } + } +} + +@Composable +private fun DisableCalendarSyncDialogView( + modifier: Modifier = Modifier, + calendarData: CalendarData?, + onCancelClick: () -> Unit, + onDisableSyncingClick: () -> Unit +) { + val scrollState = rememberScrollState() + DefaultDialogBox( + modifier = modifier, + onDismissClick = onCancelClick + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(scrollState) + .padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Image( + modifier = Modifier.size(24.dp), + painter = painterResource(id = coreR.drawable.core_ic_warning), + contentDescription = null + ) + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.profile_disable_calendar_dialog_title), + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textDark + ) + } + calendarData?.let { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clip(MaterialTheme.appShapes.cardShape) + .background(MaterialTheme.appColors.cardViewBackground) + .padding(vertical = 16.dp, horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Box( + modifier = Modifier + .size(18.dp) + .clip(CircleShape) + .background(ComposeColor(calendarData.color)) + ) + Text( + text = calendarData.title, + style = MaterialTheme.appTypography.bodyMedium.copy( + textDecoration = TextDecoration.LineThrough + ), + color = MaterialTheme.appColors.textDark + ) + } + } + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource( + id = R.string.profile_disable_calendar_dialog_description, + calendarData?.title ?: "" + ), + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.textDark + ) + OpenEdXOutlinedButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.profile_disable_syncing), + backgroundColor = MaterialTheme.appColors.background, + borderColor = MaterialTheme.appColors.primaryButtonBackground, + textColor = MaterialTheme.appColors.primaryButtonBackground, + onClick = { + onDisableSyncingClick() + } + ) + OpenEdXButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = coreR.string.core_cancel), + onClick = { + onCancelClick() + } + ) + } + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun DisableCalendarSyncDialogPreview() { + OpenEdXTheme { + DisableCalendarSyncDialogView( + calendarData = CalendarData("calendar", Color.GREEN), + onCancelClick = { }, + onDisableSyncingClick = { } + ) + } +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/DisableCalendarSyncDialogViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/DisableCalendarSyncDialogViewModel.kt new file mode 100644 index 000000000..b29c3394c --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/DisableCalendarSyncDialogViewModel.kt @@ -0,0 +1,27 @@ +package org.openedx.profile.presentation.calendar + +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch +import org.openedx.core.data.storage.CalendarPreferences +import org.openedx.core.domain.interactor.CalendarInteractor +import org.openedx.core.system.CalendarManager +import org.openedx.core.system.notifier.calendar.CalendarNotifier +import org.openedx.core.system.notifier.calendar.CalendarSyncDisabled +import org.openedx.foundation.presentation.BaseViewModel + +class DisableCalendarSyncDialogViewModel( + private val calendarNotifier: CalendarNotifier, + private val calendarManager: CalendarManager, + private val calendarPreferences: CalendarPreferences, + private val calendarInteractor: CalendarInteractor, +) : BaseViewModel() { + + fun disableSyncingClick() { + viewModelScope.launch { + calendarInteractor.clearCalendarCachedData() + calendarManager.deleteCalendar(calendarPreferences.calendarId) + calendarPreferences.clearCalendarPreferences() + calendarNotifier.send(CalendarSyncDisabled) + } + } +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogFragment.kt new file mode 100644 index 000000000..361bd965e --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogFragment.kt @@ -0,0 +1,433 @@ +package org.openedx.profile.presentation.calendar + +import android.content.Context +import android.content.res.Configuration +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Divider +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.material.TextFieldDefaults +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.os.bundleOf +import androidx.fragment.app.DialogFragment +import org.koin.androidx.compose.koinViewModel +import org.openedx.core.presentation.dialog.DefaultDialogBox +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.OpenEdXOutlinedButton +import org.openedx.core.ui.crop +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes +import org.openedx.core.ui.theme.appTypography +import org.openedx.foundation.extension.parcelable +import org.openedx.foundation.extension.toastMessage +import org.openedx.profile.R +import androidx.compose.ui.graphics.Color as ComposeColor +import org.openedx.core.R as CoreR + +class NewCalendarDialogFragment : DialogFragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val viewModel: NewCalendarDialogViewModel = koinViewModel() + + LaunchedEffect(Unit) { + viewModel.uiMessage.collect { message -> + if (message.isNotEmpty()) { + context.toastMessage(message) + } + } + } + + LaunchedEffect(Unit) { + viewModel.isSuccess.collect { isSuccess -> + if (isSuccess) { + dismiss() + } + } + } + + NewCalendarDialog( + newCalendarDialogType = requireArguments().parcelable(ARG_DIALOG_TYPE) + ?: NewCalendarDialogType.CREATE_NEW, + onCancelClick = { + dismiss() + }, + onBeginSyncingClick = { calendarTitle, calendarColor -> + viewModel.createCalendar(calendarTitle, calendarColor) + } + ) + } + } + } + + companion object { + const val DIALOG_TAG = "NewCalendarDialogFragment" + const val ARG_DIALOG_TYPE = "ARG_DIALOG_TYPE" + + fun newInstance( + newCalendarDialogType: NewCalendarDialogType + ): NewCalendarDialogFragment { + val fragment = NewCalendarDialogFragment() + fragment.arguments = bundleOf( + ARG_DIALOG_TYPE to newCalendarDialogType + ) + return fragment + } + + fun getDefaultCalendarTitle(context: Context): String { + return "${context.getString(CoreR.string.app_name)} ${context.getString(R.string.profile_course_dates)}" + } + } +} + +@Composable +private fun NewCalendarDialog( + modifier: Modifier = Modifier, + newCalendarDialogType: NewCalendarDialogType, + onCancelClick: () -> Unit, + onBeginSyncingClick: (calendarTitle: String, calendarColor: CalendarColor) -> Unit +) { + val context = LocalContext.current + val scrollState = rememberScrollState() + val title = when (newCalendarDialogType) { + NewCalendarDialogType.CREATE_NEW -> stringResource(id = R.string.profile_new_calendar) + NewCalendarDialogType.UPDATE -> stringResource(id = R.string.profile_change_sync_options) + } + var calendarTitle by rememberSaveable { + mutableStateOf("") + } + var calendarColor by rememberSaveable { + mutableStateOf(CalendarColor.ACCENT) + } + DefaultDialogBox( + modifier = modifier, + onDismissClick = onCancelClick + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(scrollState) + .padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier.weight(1f), + text = title, + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.titleLarge + ) + Icon( + modifier = Modifier + .size(24.dp) + .clickable { + onCancelClick() + }, + imageVector = Icons.Default.Close, + contentDescription = null, + tint = MaterialTheme.appColors.primary + ) + } + CalendarTitleTextField( + onValueChanged = { + calendarTitle = it + } + ) + ColorDropdown( + onValueChanged = { + calendarColor = it + } + ) + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.profile_new_calendar_description), + style = MaterialTheme.appTypography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.appColors.textDark + ) + OpenEdXOutlinedButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = CoreR.string.core_cancel), + backgroundColor = MaterialTheme.appColors.background, + borderColor = MaterialTheme.appColors.primaryButtonBackground, + textColor = MaterialTheme.appColors.primaryButtonBackground, + onClick = { + onCancelClick() + } + ) + OpenEdXButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.profile_begin_syncing), + onClick = { + onBeginSyncingClick( + calendarTitle.ifEmpty { NewCalendarDialogFragment.getDefaultCalendarTitle(context) }, + calendarColor + ) + } + ) + } + } +} + +@Composable +private fun CalendarTitleTextField( + modifier: Modifier = Modifier, + onValueChanged: (String) -> Unit +) { + val focusManager = LocalFocusManager.current + val maxChar = 40 + var textFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf( + TextFieldValue("") + ) + } + + Column { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.profile_calendar_name), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.labelLarge + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + modifier = modifier + .fillMaxWidth() + .height(48.dp), + value = textFieldValue, + onValueChange = { + if (it.text.length <= maxChar) textFieldValue = it + onValueChanged(it.text.trim()) + }, + colors = TextFieldDefaults.outlinedTextFieldColors( + unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder + ), + shape = MaterialTheme.appShapes.textFieldShape, + placeholder = { + Text( + text = NewCalendarDialogFragment.getDefaultCalendarTitle(LocalContext.current), + color = MaterialTheme.appColors.textFieldHint, + style = MaterialTheme.appTypography.bodyMedium + ) + }, + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Next + ), + keyboardActions = KeyboardActions { + focusManager.clearFocus() + }, + textStyle = MaterialTheme.appTypography.bodyMedium, + singleLine = true + ) + } +} + +@Composable +private fun ColorDropdown( + modifier: Modifier = Modifier, + onValueChanged: (CalendarColor) -> Unit +) { + val density = LocalDensity.current + var expanded by remember { mutableStateOf(false) } + var currentValue by remember { mutableStateOf(CalendarColor.ACCENT) } + var dropdownWidth by remember { mutableStateOf(300.dp) } + val colorArrowRotation by animateFloatAsState( + targetValue = if (expanded) 180f else 0f, + label = "" + ) + + Column( + modifier = modifier + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.profile_color), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.labelLarge + ) + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .clip(MaterialTheme.appShapes.textFieldShape) + .border( + 1.dp, + MaterialTheme.appColors.textFieldBorder, + MaterialTheme.appShapes.textFieldShape + ) + .onSizeChanged { + dropdownWidth = with(density) { it.width.toDp() } + } + .clickable { + expanded = true + }, + verticalAlignment = Alignment.CenterVertically + ) { + ColorCircle( + modifier = Modifier + .padding(start = 16.dp), + color = ComposeColor(currentValue.color) + ) + Text( + modifier = Modifier + .weight(1f) + .padding(horizontal = 8.dp), + text = stringResource(id = currentValue.title), + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.bodyMedium + ) + Icon( + modifier = Modifier + .padding(end = 16.dp) + .rotate(colorArrowRotation), + imageVector = Icons.Default.ExpandMore, + tint = MaterialTheme.appColors.textDark, + contentDescription = null + ) + } + + MaterialTheme( + colors = MaterialTheme.colors.copy(surface = MaterialTheme.appColors.background), + shapes = MaterialTheme.shapes.copy(MaterialTheme.appShapes.textFieldShape) + ) { + Spacer(modifier = Modifier.padding(top = 4.dp)) + DropdownMenu( + modifier = Modifier + .crop(vertical = 8.dp) + .height(240.dp) + .width(dropdownWidth) + .border( + 1.dp, + MaterialTheme.appColors.textFieldBorder, + MaterialTheme.appShapes.textFieldShape + ) + .crop(vertical = 8.dp), + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + for ((index, calendarColor) in CalendarColor.entries.withIndex()) { + DropdownMenuItem( + modifier = Modifier + .background(MaterialTheme.appColors.background), + onClick = { + currentValue = calendarColor + expanded = false + onValueChanged(CalendarColor.entries[index]) + } + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + ColorCircle( + color = ComposeColor(calendarColor.color) + ) + Text( + text = stringResource(id = calendarColor.title), + style = MaterialTheme.appTypography.titleSmall, + color = MaterialTheme.appColors.textDark + ) + } + } + if (index < CalendarColor.entries.lastIndex) { + Divider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.appColors.divider + ) + } + } + } + } + } +} + +@Composable +private fun ColorCircle( + modifier: Modifier = Modifier, + color: ComposeColor +) { + Box( + modifier = modifier + .size(18.dp) + .clip(CircleShape) + .background(color) + ) +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun NewCalendarDialogPreview() { + OpenEdXTheme { + NewCalendarDialog( + newCalendarDialogType = NewCalendarDialogType.CREATE_NEW, + onCancelClick = { }, + onBeginSyncingClick = { _, _ -> } + ) + } +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogType.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogType.kt new file mode 100644 index 000000000..1905b8faa --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogType.kt @@ -0,0 +1,9 @@ +package org.openedx.profile.presentation.calendar + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +enum class NewCalendarDialogType : Parcelable { + CREATE_NEW, UPDATE +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogViewModel.kt new file mode 100644 index 000000000..20fbdbf23 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogViewModel.kt @@ -0,0 +1,62 @@ +package org.openedx.profile.presentation.calendar + +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import org.openedx.core.R +import org.openedx.core.data.storage.CalendarPreferences +import org.openedx.core.domain.interactor.CalendarInteractor +import org.openedx.core.system.CalendarManager +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.system.notifier.calendar.CalendarCreated +import org.openedx.core.system.notifier.calendar.CalendarNotifier +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.system.ResourceManager + +class NewCalendarDialogViewModel( + private val calendarManager: CalendarManager, + private val calendarPreferences: CalendarPreferences, + private val calendarNotifier: CalendarNotifier, + private val calendarInteractor: CalendarInteractor, + private val networkConnection: NetworkConnection, + private val resourceManager: ResourceManager, +) : BaseViewModel() { + + private val _uiMessage = MutableSharedFlow() + val uiMessage: SharedFlow + get() = _uiMessage.asSharedFlow() + + private val _isSuccess = MutableSharedFlow() + val isSuccess: SharedFlow + get() = _isSuccess.asSharedFlow() + + fun createCalendar( + calendarTitle: String, + calendarColor: CalendarColor, + ) { + viewModelScope.launch { + if (networkConnection.isOnline()) { + calendarInteractor.resetChecksums() + val calendarId = calendarManager.createOrUpdateCalendar( + calendarId = calendarPreferences.calendarId, + calendarTitle = calendarTitle, + calendarColor = calendarColor.color + ) + if (calendarId != CalendarManager.CALENDAR_DOES_NOT_EXIST) { + calendarPreferences.calendarId = calendarId + calendarPreferences.calendarUser = calendarManager.accountName + viewModelScope.launch { + calendarNotifier.send(CalendarCreated) + } + _isSuccess.emit(true) + } else { + _uiMessage.emit(resourceManager.getString(R.string.core_error_unknown_error)) + } + } else { + _uiMessage.emit(resourceManager.getString(R.string.core_error_no_connection)) + } + } + } +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/SyncCourseTab.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/SyncCourseTab.kt new file mode 100644 index 000000000..ef65db249 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/SyncCourseTab.kt @@ -0,0 +1,12 @@ +package org.openedx.profile.presentation.calendar + +import androidx.annotation.StringRes +import org.openedx.core.R + +enum class SyncCourseTab( + @StringRes + val title: Int +) { + SYNCED(R.string.core_to_sync), + NOT_SYNCED(R.string.core_not_synced) +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileFragment.kt index f92ca3c3e..f9d481466 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileFragment.kt @@ -56,22 +56,22 @@ import androidx.fragment.app.Fragment import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.openedx.core.R -import org.openedx.core.UIMessage import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.OpenEdXOutlinedTextField import org.openedx.core.ui.Toolbar -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType import org.openedx.core.ui.displayCutoutForLandscape -import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.settingsHeaderBackground import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography -import org.openedx.core.ui.windowSizeValue +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue import org.openedx.profile.presentation.ProfileRouter import org.openedx.profile.presentation.settings.SettingsViewModel import org.openedx.profile.R as profileR diff --git a/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileViewModel.kt index c4477ef28..d29e70ab3 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileViewModel.kt @@ -4,19 +4,19 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.launch -import org.openedx.core.BaseViewModel import org.openedx.core.R -import org.openedx.core.UIMessage import org.openedx.core.Validator -import org.openedx.core.extension.isInternetError import org.openedx.core.system.EdxError -import org.openedx.core.system.ResourceManager +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import org.openedx.profile.domain.interactor.ProfileInteractor import org.openedx.profile.presentation.ProfileAnalytics import org.openedx.profile.presentation.ProfileAnalyticsEvent import org.openedx.profile.presentation.ProfileAnalyticsKey -import org.openedx.profile.system.notifier.AccountDeactivated -import org.openedx.profile.system.notifier.ProfileNotifier +import org.openedx.profile.system.notifier.account.AccountDeactivated +import org.openedx.profile.system.notifier.profile.ProfileNotifier class DeleteProfileViewModel( private val resourceManager: ResourceManager, diff --git a/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileFragment.kt index 907b3942a..4005d1191 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileFragment.kt @@ -42,7 +42,6 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.Card import androidx.compose.material.Divider -import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.ModalBottomSheetLayout @@ -108,13 +107,9 @@ import kotlinx.coroutines.launch import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.core.AppDataConstants.DEFAULT_MIME_TYPE -import org.openedx.core.UIMessage import org.openedx.core.domain.model.LanguageProficiency import org.openedx.core.domain.model.ProfileImage import org.openedx.core.domain.model.RegistrationField -import org.openedx.core.extension.getFileName -import org.openedx.core.extension.parcelable -import org.openedx.core.extension.tagId import org.openedx.core.ui.AutoSizeText import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage @@ -122,20 +117,24 @@ import org.openedx.core.ui.IconText import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.OpenEdXOutlinedButton import org.openedx.core.ui.SheetContent -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.isImeVisibleState import org.openedx.core.ui.noRippleClickable import org.openedx.core.ui.rememberSaveableMap -import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography -import org.openedx.core.ui.windowSizeValue import org.openedx.core.utils.LocaleUtils +import org.openedx.foundation.extension.getFileName +import org.openedx.foundation.extension.parcelable +import org.openedx.foundation.extension.tagId +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue import org.openedx.profile.R import org.openedx.profile.domain.model.Account import java.io.ByteArrayOutputStream @@ -304,10 +303,7 @@ class EditProfileFragment : Fragment() { } -@OptIn( - ExperimentalMaterialApi::class, - ExperimentalComposeUiApi::class -) +@OptIn(ExperimentalComposeUiApi::class) @Composable private fun EditProfileScreen( windowSize: WindowSize, @@ -648,7 +644,7 @@ private fun EditProfileScreen( }, painter = painterResource(id = R.drawable.profile_ic_edit_image), contentDescription = null, - tint = Color.White + tint = MaterialTheme.appColors.onPrimary ) } Spacer(modifier = Modifier.height(20.dp)) @@ -675,25 +671,29 @@ private fun EditProfileScreen( openWarningMessageDialog = true } }, - text = stringResource(if (uiState.isLimited) R.string.profile_switch_to_full else R.string.profile_switch_to_limited), + text = stringResource( + if (uiState.isLimited) { + R.string.profile_switch_to_full + } else { + R.string.profile_switch_to_limited + } + ), color = MaterialTheme.appColors.textAccent, style = MaterialTheme.appTypography.labelLarge ) Spacer(modifier = Modifier.height(20.dp)) ProfileFields( disabled = uiState.isLimited, - onFieldClick = { it, title -> - when (it) { + onFieldClick = { field, title -> + when (field) { YEAR_OF_BIRTH -> { serverFieldName.value = YEAR_OF_BIRTH - expandedList = - LocaleUtils.getBirthYearsRange() + expandedList = LocaleUtils.getBirthYearsRange() } COUNTRY -> { serverFieldName.value = COUNTRY - expandedList = - LocaleUtils.getCountries() + expandedList = LocaleUtils.getCountries() } LANGUAGE -> { @@ -706,9 +706,8 @@ private fun EditProfileScreen( coroutine.launch { val index = expandedList.indexOfFirst { option -> if (serverFieldName.value == LANGUAGE) { - option.value == (mapFields[serverFieldName.value] as List).getOrNull( - 0 - )?.code + option.value == (mapFields[serverFieldName.value] as List) + .getOrNull(0)?.code } else { option.value == mapFields[serverFieldName.value] } @@ -949,10 +948,12 @@ private fun SelectableField( ) } else { TextFieldDefaults.outlinedTextFieldColors( + textColor = MaterialTheme.appColors.textFieldText, + backgroundColor = MaterialTheme.appColors.textFieldBackground, unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, + cursorColor = MaterialTheme.appColors.textFieldText, disabledBorderColor = MaterialTheme.appColors.textFieldBorder, - disabledTextColor = MaterialTheme.appColors.textPrimary, - backgroundColor = MaterialTheme.appColors.textFieldBackground, + disabledTextColor = MaterialTheme.appColors.textFieldHint, disabledPlaceholderColor = MaterialTheme.appColors.textFieldHint ) } @@ -991,7 +992,7 @@ private fun SelectableField( Text( modifier = Modifier.testTag("txt_placeholder_${name.tagId()}"), text = name, - color = MaterialTheme.appColors.textFieldHint, + color = MaterialTheme.appColors.textFieldText, style = MaterialTheme.appTypography.bodyMedium ) } @@ -1029,8 +1030,10 @@ private fun InputEditField( onValueChanged(it) }, colors = TextFieldDefaults.outlinedTextFieldColors( + textColor = MaterialTheme.appColors.textFieldText, + backgroundColor = MaterialTheme.appColors.textFieldBackground, unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, - backgroundColor = MaterialTheme.appColors.textFieldBackground + cursorColor = MaterialTheme.appColors.textFieldText, ), shape = MaterialTheme.appShapes.textFieldShape, placeholder = { @@ -1043,7 +1046,7 @@ private fun InputEditField( }, keyboardOptions = KeyboardOptions.Default.copy( keyboardType = keyboardType, - imeAction = ImeAction.Done + imeAction = ImeAction.Default ), keyboardActions = KeyboardActions { keyboardController?.hide() @@ -1116,14 +1119,14 @@ private fun LeaveProfile( OpenEdXButton( text = stringResource(id = R.string.profile_leave), onClick = onLeaveClick, - backgroundColor = MaterialTheme.appColors.warning, + backgroundColor = MaterialTheme.appColors.primary, content = { Text( modifier = Modifier .testTag("txt_leave") .fillMaxWidth(), text = stringResource(id = R.string.profile_leave), - color = MaterialTheme.appColors.textWarning, + color = MaterialTheme.appColors.primaryButtonText, style = MaterialTheme.appTypography.labelLarge, textAlign = TextAlign.Center ) @@ -1131,7 +1134,7 @@ private fun LeaveProfile( ) Spacer(Modifier.height(24.dp)) OpenEdXOutlinedButton( - borderColor = MaterialTheme.appColors.textPrimary, + borderColor = MaterialTheme.appColors.textFieldBorder, textColor = MaterialTheme.appColors.textPrimary, text = stringResource(id = R.string.profile_keep_editing), onClick = onDismissRequest @@ -1208,20 +1211,20 @@ private fun LeaveProfileLandscape( ) { OpenEdXButton( text = stringResource(id = R.string.profile_leave), - backgroundColor = MaterialTheme.appColors.warning, + backgroundColor = MaterialTheme.appColors.primary, content = { AutoSizeText( modifier = Modifier.testTag("txt_leave_profile_dialog_leave"), text = stringResource(id = R.string.profile_leave), style = MaterialTheme.appTypography.bodyMedium, - color = MaterialTheme.appColors.textDark + color = MaterialTheme.appColors.primaryButtonText ) }, onClick = onLeaveClick ) Spacer(Modifier.height(16.dp)) OpenEdXOutlinedButton( - borderColor = MaterialTheme.appColors.textPrimary, + borderColor = MaterialTheme.appColors.textFieldBorder, textColor = MaterialTheme.appColors.textPrimary, text = stringResource(id = R.string.profile_keep_editing), onClick = onDismissRequest, diff --git a/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileViewModel.kt index 64cf9789f..33e804d28 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileViewModel.kt @@ -5,18 +5,18 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.launch -import org.openedx.core.BaseViewModel import org.openedx.core.R -import org.openedx.core.UIMessage -import org.openedx.core.extension.isInternetError -import org.openedx.core.system.ResourceManager +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import org.openedx.profile.domain.interactor.ProfileInteractor import org.openedx.profile.domain.model.Account import org.openedx.profile.presentation.ProfileAnalytics import org.openedx.profile.presentation.ProfileAnalyticsEvent import org.openedx.profile.presentation.ProfileAnalyticsKey -import org.openedx.profile.system.notifier.AccountUpdated -import org.openedx.profile.system.notifier.ProfileNotifier +import org.openedx.profile.system.notifier.account.AccountUpdated +import org.openedx.profile.system.notifier.profile.ProfileNotifier import java.io.File class EditProfileViewModel( @@ -67,6 +67,9 @@ class EditProfileViewModel( val showLeaveDialog: LiveData get() = _showLeaveDialog + init { + logProfileScreenEvent(ProfileAnalyticsEvent.EDIT_PROFILE) + } fun updateAccount(fields: Map) { _uiState.value = EditProfileUIState(account, true, isLimitedProfile) @@ -156,4 +159,18 @@ class EditProfileViewModel( } ) } + + private fun logProfileScreenEvent( + event: ProfileAnalyticsEvent, + params: Map = emptyMap(), + ) { + analytics.logScreenEvent( + screenName = event.eventName, + params = buildMap { + put(ProfileAnalyticsKey.NAME.key, event.biValue) + put(ProfileAnalyticsKey.CATEGORY.key, ProfileAnalyticsKey.PROFILE.key) + putAll(params) + } + ) + } } diff --git a/profile/src/main/java/org/openedx/profile/presentation/manageaccount/ManageAccountFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/manageaccount/ManageAccountFragment.kt index 084d544aa..0616c9e84 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/manageaccount/ManageAccountFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/manageaccount/ManageAccountFragment.kt @@ -9,8 +9,8 @@ import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.fragment.app.Fragment import org.koin.androidx.viewmodel.ext.android.viewModel -import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.foundation.presentation.rememberWindowSize import org.openedx.profile.presentation.manageaccount.compose.ManageAccountView import org.openedx.profile.presentation.manageaccount.compose.ManageAccountViewAction diff --git a/profile/src/main/java/org/openedx/profile/presentation/manageaccount/ManageAccountViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/manageaccount/ManageAccountViewModel.kt index 2370e0508..3e25214a1 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/manageaccount/ManageAccountViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/manageaccount/ManageAccountViewModel.kt @@ -10,18 +10,18 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import org.openedx.core.BaseViewModel import org.openedx.core.R -import org.openedx.core.UIMessage -import org.openedx.core.extension.isInternetError -import org.openedx.core.system.ResourceManager +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import org.openedx.profile.domain.interactor.ProfileInteractor import org.openedx.profile.presentation.ProfileAnalytics import org.openedx.profile.presentation.ProfileAnalyticsEvent import org.openedx.profile.presentation.ProfileAnalyticsKey import org.openedx.profile.presentation.ProfileRouter -import org.openedx.profile.system.notifier.AccountUpdated -import org.openedx.profile.system.notifier.ProfileNotifier +import org.openedx.profile.system.notifier.account.AccountUpdated +import org.openedx.profile.system.notifier.profile.ProfileNotifier class ManageAccountViewModel( private val interactor: ProfileInteractor, diff --git a/profile/src/main/java/org/openedx/profile/presentation/manageaccount/compose/ManageAccountView.kt b/profile/src/main/java/org/openedx/profile/presentation/manageaccount/compose/ManageAccountView.kt index 42ff5afef..016f1e90c 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/manageaccount/compose/ManageAccountView.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/manageaccount/compose/ManageAccountView.kt @@ -38,13 +38,10 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import org.openedx.core.R -import org.openedx.core.UIMessage import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.IconText import org.openedx.core.ui.OpenEdXOutlinedButton import org.openedx.core.ui.Toolbar -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.settingsHeaderBackground import org.openedx.core.ui.statusBarsInset @@ -52,7 +49,10 @@ import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography -import org.openedx.core.ui.windowSizeValue +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.windowSizeValue import org.openedx.profile.presentation.manageaccount.ManageAccountUIState import org.openedx.profile.presentation.ui.ProfileTopic import org.openedx.profile.presentation.ui.mockAccount @@ -174,7 +174,7 @@ internal fun ManageAccountView( onClick = { onAction(ManageAccountViewAction.EditAccountClick) }, - borderColor = MaterialTheme.appColors.buttonBackground, + borderColor = MaterialTheme.appColors.primaryButtonBackground, textColor = MaterialTheme.appColors.textAccent ) Spacer(modifier = Modifier.height(12.dp)) diff --git a/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileFragment.kt index cdf190e6a..581bdc63f 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileFragment.kt @@ -10,8 +10,8 @@ import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.fragment.app.Fragment import org.koin.androidx.viewmodel.ext.android.viewModel -import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.foundation.presentation.rememberWindowSize import org.openedx.profile.presentation.profile.compose.ProfileView import org.openedx.profile.presentation.profile.compose.ProfileViewAction diff --git a/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileViewModel.kt index d8fc19715..b2d4ccb4e 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileViewModel.kt @@ -9,18 +9,18 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import org.openedx.core.BaseViewModel import org.openedx.core.R -import org.openedx.core.UIMessage -import org.openedx.core.extension.isInternetError -import org.openedx.core.system.ResourceManager +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import org.openedx.profile.domain.interactor.ProfileInteractor import org.openedx.profile.presentation.ProfileAnalytics import org.openedx.profile.presentation.ProfileAnalyticsEvent import org.openedx.profile.presentation.ProfileAnalyticsKey import org.openedx.profile.presentation.ProfileRouter -import org.openedx.profile.system.notifier.AccountUpdated -import org.openedx.profile.system.notifier.ProfileNotifier +import org.openedx.profile.system.notifier.account.AccountUpdated +import org.openedx.profile.system.notifier.profile.ProfileNotifier class ProfileViewModel( private val interactor: ProfileInteractor, diff --git a/profile/src/main/java/org/openedx/profile/presentation/profile/compose/ProfileView.kt b/profile/src/main/java/org/openedx/profile/presentation/profile/compose/ProfileView.kt index bec24967f..7a0d90b16 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/profile/compose/ProfileView.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/profile/compose/ProfileView.kt @@ -37,17 +37,17 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import org.openedx.core.R -import org.openedx.core.UIMessage import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OpenEdXOutlinedButton import org.openedx.core.ui.Toolbar -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors -import org.openedx.core.ui.windowSizeValue +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.windowSizeValue import org.openedx.profile.presentation.profile.ProfileUIState import org.openedx.profile.presentation.ui.ProfileInfoSection import org.openedx.profile.presentation.ui.ProfileTopic @@ -149,7 +149,7 @@ internal fun ProfileView( onClick = { onAction(ProfileViewAction.EditAccountClick) }, - borderColor = MaterialTheme.appColors.buttonBackground, + borderColor = MaterialTheme.appColors.primaryButtonBackground, textColor = MaterialTheme.appColors.textAccent ) Spacer(modifier = Modifier.height(12.dp)) diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsFragment.kt index fbdd0b4af..1746fa167 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsFragment.kt @@ -10,8 +10,8 @@ import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.fragment.app.Fragment import org.koin.androidx.viewmodel.ext.android.viewModel -import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.foundation.presentation.rememberWindowSize class SettingsFragment : Fragment() { @@ -83,11 +83,17 @@ class SettingsFragment : Fragment() { ) } - SettingsScreenAction.ManageAccount -> { + SettingsScreenAction.ManageAccountClick -> { viewModel.manageAccountClicked( requireActivity().supportFragmentManager ) } + + SettingsScreenAction.CalendarSettingsClick -> { + viewModel.calendarSettingsClicked( + requireActivity().supportFragmentManager + ) + } } } ) @@ -112,6 +118,7 @@ internal interface SettingsScreenAction { object TermsClick : SettingsScreenAction object SupportClick : SettingsScreenAction object VideoSettingsClick : SettingsScreenAction - object ManageAccount : SettingsScreenAction + object ManageAccountClick : SettingsScreenAction + object CalendarSettingsClick : SettingsScreenAction } diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt index f5c0a7bc5..21d0fe1fb 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt @@ -21,7 +21,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.Card import androidx.compose.material.CircularProgressIndicator -import androidx.compose.material.Divider import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme @@ -54,11 +53,9 @@ import androidx.compose.ui.window.Dialog import org.openedx.core.R import org.openedx.core.domain.model.AgreementUrls import org.openedx.core.presentation.global.AppData -import org.openedx.core.system.notifier.AppUpgradeEvent +import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.Toolbar -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.settingsHeaderBackground import org.openedx.core.ui.statusBarsInset @@ -66,8 +63,11 @@ import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography -import org.openedx.core.ui.windowSizeValue +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.windowSizeValue import org.openedx.profile.domain.model.Configuration +import org.openedx.profile.presentation.ui.SettingsDivider import org.openedx.profile.presentation.ui.SettingsItem import org.openedx.profile.R as profileR @@ -170,14 +170,19 @@ internal fun SettingsScreen( Spacer(Modifier.height(30.dp)) ManageAccountSection(onManageAccountClick = { - onAction(SettingsScreenAction.ManageAccount) + onAction(SettingsScreenAction.ManageAccountClick) }) Spacer(modifier = Modifier.height(24.dp)) - SettingsSection(onVideoSettingsClick = { - onAction(SettingsScreenAction.VideoSettingsClick) - }) + SettingsSection( + onVideoSettingsClick = { + onAction(SettingsScreenAction.VideoSettingsClick) + }, + onCalendarSettingsClick = { + onAction(SettingsScreenAction.CalendarSettingsClick) + } + ) Spacer(modifier = Modifier.height(24.dp)) @@ -205,7 +210,10 @@ internal fun SettingsScreen( } @Composable -private fun SettingsSection(onVideoSettingsClick: () -> Unit) { +private fun SettingsSection( + onVideoSettingsClick: () -> Unit, + onCalendarSettingsClick: () -> Unit +) { Column { Text( modifier = Modifier.testTag("txt_settings"), @@ -225,6 +233,11 @@ private fun SettingsSection(onVideoSettingsClick: () -> Unit) { text = stringResource(id = profileR.string.profile_video), onClick = onVideoSettingsClick ) + SettingsDivider() + SettingsItem( + text = stringResource(id = profileR.string.profile_dates_and_calendar), + onClick = onCalendarSettingsClick + ) } } } @@ -273,46 +286,31 @@ private fun SupportInfoSection( SettingsItem(text = stringResource(id = profileR.string.profile_contact_support)) { onAction(SettingsScreenAction.SupportClick) } - Divider( - modifier = Modifier.padding(vertical = 4.dp), - color = MaterialTheme.appColors.divider - ) + SettingsDivider() } if (uiState.configuration.agreementUrls.tosUrl.isNotBlank()) { SettingsItem(text = stringResource(id = R.string.core_terms_of_use)) { onAction(SettingsScreenAction.TermsClick) } - Divider( - modifier = Modifier.padding(vertical = 4.dp), - color = MaterialTheme.appColors.divider - ) + SettingsDivider() } if (uiState.configuration.agreementUrls.privacyPolicyUrl.isNotBlank()) { SettingsItem(text = stringResource(id = R.string.core_privacy_policy)) { onAction(SettingsScreenAction.PrivacyPolicyClick) } - Divider( - modifier = Modifier.padding(vertical = 4.dp), - color = MaterialTheme.appColors.divider - ) + SettingsDivider() } if (uiState.configuration.agreementUrls.cookiePolicyUrl.isNotBlank()) { SettingsItem(text = stringResource(id = R.string.core_cookie_policy)) { onAction(SettingsScreenAction.CookiePolicyClick) } - Divider( - modifier = Modifier.padding(vertical = 4.dp), - color = MaterialTheme.appColors.divider - ) + SettingsDivider() } if (uiState.configuration.agreementUrls.dataSellConsentUrl.isNotBlank()) { SettingsItem(text = stringResource(id = R.string.core_data_sell)) { onAction(SettingsScreenAction.DataSellClick) } - Divider( - modifier = Modifier.padding(vertical = 4.dp), - color = MaterialTheme.appColors.divider - ) + SettingsDivider() } if (uiState.configuration.faqUrl.isNotBlank()) { val uriHandler = LocalUriHandler.current @@ -323,10 +321,7 @@ private fun SupportInfoSection( uriHandler.openUri(uiState.configuration.faqUrl) onAction(SettingsScreenAction.FaqClick) } - Divider( - modifier = Modifier.padding(vertical = 4.dp), - color = MaterialTheme.appColors.divider - ) + SettingsDivider() } AppVersionItem( versionName = uiState.configuration.versionName, @@ -344,16 +339,17 @@ private fun LogoutButton(onClick: () -> Unit) { Card( modifier = Modifier .testTag("btn_logout") - .fillMaxWidth() - .clickable { - onClick() - }, + .fillMaxWidth(), shape = MaterialTheme.appShapes.cardShape, elevation = 0.dp, backgroundColor = MaterialTheme.appColors.cardViewBackground ) { Row( - modifier = Modifier.padding(20.dp), + modifier = Modifier + .clickable { + onClick() + } + .padding(20.dp), horizontalArrangement = Arrangement.SpaceBetween ) { Text( @@ -522,7 +518,7 @@ private fun AppVersionItemAppToDate(versionName: String) { ), painter = painterResource(id = R.drawable.core_ic_check), contentDescription = null, - tint = MaterialTheme.appColors.accessGreen + tint = MaterialTheme.appColors.successGreen ) Text( modifier = Modifier.testTag("txt_up_to_date"), diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt index 2c7471ebd..a483d0b91 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt @@ -14,26 +14,28 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.openedx.core.AppUpdateState -import org.openedx.core.BaseViewModel +import org.openedx.core.CalendarRouter import org.openedx.core.R -import org.openedx.core.UIMessage import org.openedx.core.config.Config -import org.openedx.core.extension.isInternetError import org.openedx.core.module.DownloadWorkerController import org.openedx.core.presentation.global.AppData import org.openedx.core.system.AppCookieManager -import org.openedx.core.system.ResourceManager -import org.openedx.core.system.notifier.AppUpgradeEvent -import org.openedx.core.system.notifier.AppUpgradeNotifier +import org.openedx.core.system.notifier.app.AppNotifier +import org.openedx.core.system.notifier.app.AppUpgradeEvent +import org.openedx.core.system.notifier.app.LogoutEvent import org.openedx.core.utils.EmailUtil +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import org.openedx.profile.domain.interactor.ProfileInteractor import org.openedx.profile.domain.model.Configuration import org.openedx.profile.presentation.ProfileAnalytics import org.openedx.profile.presentation.ProfileAnalyticsEvent import org.openedx.profile.presentation.ProfileAnalyticsKey import org.openedx.profile.presentation.ProfileRouter -import org.openedx.profile.system.notifier.AccountDeactivated -import org.openedx.profile.system.notifier.ProfileNotifier +import org.openedx.profile.system.notifier.account.AccountDeactivated +import org.openedx.profile.system.notifier.profile.ProfileNotifier class SettingsViewModel( private val appData: AppData, @@ -43,8 +45,9 @@ class SettingsViewModel( private val cookieManager: AppCookieManager, private val workerController: DownloadWorkerController, private val analytics: ProfileAnalytics, - private val router: ProfileRouter, - private val appUpgradeNotifier: AppUpgradeNotifier, + private val profileRouter: ProfileRouter, + private val calendarRouter: CalendarRouter, + private val appNotifier: AppNotifier, private val profileNotifier: ProfileNotifier, ) : BaseViewModel() { @@ -100,6 +103,7 @@ class SettingsViewModel( } } finally { cookieManager.clearWebViewCookie() + appNotifier.send(LogoutEvent(false)) _successLogout.emit(true) } } @@ -107,8 +111,10 @@ class SettingsViewModel( private fun collectAppUpgradeEvent() { viewModelScope.launch { - appUpgradeNotifier.notifier.collect { event -> - _appUpgradeEvent.value = event + appNotifier.notifier.collect { event -> + if (event is AppUpgradeEvent) { + _appUpgradeEvent.value = event + } } } } @@ -124,12 +130,12 @@ class SettingsViewModel( } fun videoSettingsClicked(fragmentManager: FragmentManager) { - router.navigateToVideoSettings(fragmentManager) + profileRouter.navigateToVideoSettings(fragmentManager) logProfileEvent(ProfileAnalyticsEvent.VIDEO_SETTING_CLICKED) } fun privacyPolicyClicked(fragmentManager: FragmentManager) { - router.navigateToWebContent( + profileRouter.navigateToWebContent( fm = fragmentManager, title = resourceManager.getString(R.string.core_privacy_policy), url = configuration.agreementUrls.privacyPolicyUrl, @@ -138,7 +144,7 @@ class SettingsViewModel( } fun cookiePolicyClicked(fragmentManager: FragmentManager) { - router.navigateToWebContent( + profileRouter.navigateToWebContent( fm = fragmentManager, title = resourceManager.getString(R.string.core_cookie_policy), url = configuration.agreementUrls.cookiePolicyUrl, @@ -147,7 +153,7 @@ class SettingsViewModel( } fun dataSellClicked(fragmentManager: FragmentManager) { - router.navigateToWebContent( + profileRouter.navigateToWebContent( fm = fragmentManager, title = resourceManager.getString(R.string.core_data_sell), url = configuration.agreementUrls.dataSellConsentUrl, @@ -160,7 +166,7 @@ class SettingsViewModel( } fun termsOfUseClicked(fragmentManager: FragmentManager) { - router.navigateToWebContent( + profileRouter.navigateToWebContent( fm = fragmentManager, title = resourceManager.getString(R.string.core_terms_of_use), url = configuration.agreementUrls.tosUrl, @@ -182,11 +188,15 @@ class SettingsViewModel( } fun manageAccountClicked(fragmentManager: FragmentManager) { - router.navigateToManageAccount(fragmentManager) + profileRouter.navigateToManageAccount(fragmentManager) + } + + fun calendarSettingsClicked(fragmentManager: FragmentManager) { + calendarRouter.navigateToCalendarSettings(fragmentManager) } fun restartApp(fragmentManager: FragmentManager) { - router.restartApp( + profileRouter.restartApp( fragmentManager, isLogistrationEnabled ) diff --git a/profile/src/main/java/org/openedx/profile/presentation/ui/SettingsUI.kt b/profile/src/main/java/org/openedx/profile/presentation/ui/SettingsUI.kt index 6960a0864..f4811135a 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/ui/SettingsUI.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/ui/SettingsUI.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.material.Divider import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Text @@ -18,9 +19,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import org.openedx.core.extension.tagId import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography +import org.openedx.foundation.extension.tagId @Composable fun SettingsItem( @@ -38,7 +39,10 @@ fun SettingsItem( .testTag("btn_${text.tagId()}") .fillMaxWidth() .clickable { onClick() } - .padding(20.dp), + .padding( + vertical = 24.dp, + horizontal = 20.dp + ), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { @@ -59,3 +63,14 @@ fun SettingsItem( ) } } + +@Composable +fun SettingsDivider() { + Divider( + modifier = Modifier + .padding( + horizontal = 20.dp + ), + color = MaterialTheme.appColors.divider + ) +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/video/VideoSettingsFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/video/VideoSettingsFragment.kt index 5de93fdad..2213b083d 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/video/VideoSettingsFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/video/VideoSettingsFragment.kt @@ -51,18 +51,18 @@ import androidx.fragment.app.Fragment import org.koin.androidx.viewmodel.ext.android.viewModel import org.openedx.core.domain.model.VideoSettings import org.openedx.core.ui.Toolbar -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.noRippleClickable -import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.settingsHeaderBackground import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography -import org.openedx.core.ui.windowSizeValue +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue import org.openedx.profile.R import org.openedx.core.R as CoreR diff --git a/profile/src/main/java/org/openedx/profile/presentation/video/VideoSettingsViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/video/VideoSettingsViewModel.kt index b98ec8709..f2fd90c61 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/video/VideoSettingsViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/video/VideoSettingsViewModel.kt @@ -7,12 +7,12 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import org.openedx.core.BaseViewModel import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.VideoSettings -import org.openedx.core.presentation.settings.VideoQualityType +import org.openedx.core.presentation.settings.video.VideoQualityType import org.openedx.core.system.notifier.VideoNotifier import org.openedx.core.system.notifier.VideoQualityChanged +import org.openedx.foundation.presentation.BaseViewModel import org.openedx.profile.presentation.ProfileAnalytics import org.openedx.profile.presentation.ProfileAnalyticsEvent import org.openedx.profile.presentation.ProfileAnalyticsKey diff --git a/profile/src/main/java/org/openedx/profile/system/notifier/AccountDeactivated.kt b/profile/src/main/java/org/openedx/profile/system/notifier/AccountDeactivated.kt deleted file mode 100644 index ff09cbf72..000000000 --- a/profile/src/main/java/org/openedx/profile/system/notifier/AccountDeactivated.kt +++ /dev/null @@ -1,3 +0,0 @@ -package org.openedx.profile.system.notifier - -class AccountDeactivated : ProfileEvent diff --git a/profile/src/main/java/org/openedx/profile/system/notifier/AccountUpdated.kt b/profile/src/main/java/org/openedx/profile/system/notifier/AccountUpdated.kt deleted file mode 100644 index 2870235f2..000000000 --- a/profile/src/main/java/org/openedx/profile/system/notifier/AccountUpdated.kt +++ /dev/null @@ -1,3 +0,0 @@ -package org.openedx.profile.system.notifier - -class AccountUpdated : ProfileEvent diff --git a/profile/src/main/java/org/openedx/profile/system/notifier/ProfileEvent.kt b/profile/src/main/java/org/openedx/profile/system/notifier/ProfileEvent.kt deleted file mode 100644 index dbe877081..000000000 --- a/profile/src/main/java/org/openedx/profile/system/notifier/ProfileEvent.kt +++ /dev/null @@ -1,3 +0,0 @@ -package org.openedx.profile.system.notifier - -interface ProfileEvent diff --git a/profile/src/main/java/org/openedx/profile/system/notifier/account/AccountDeactivated.kt b/profile/src/main/java/org/openedx/profile/system/notifier/account/AccountDeactivated.kt new file mode 100644 index 000000000..68f68e58f --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/system/notifier/account/AccountDeactivated.kt @@ -0,0 +1,5 @@ +package org.openedx.profile.system.notifier.account + +import org.openedx.profile.system.notifier.profile.ProfileEvent + +class AccountDeactivated : ProfileEvent diff --git a/profile/src/main/java/org/openedx/profile/system/notifier/account/AccountUpdated.kt b/profile/src/main/java/org/openedx/profile/system/notifier/account/AccountUpdated.kt new file mode 100644 index 000000000..f43d6c329 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/system/notifier/account/AccountUpdated.kt @@ -0,0 +1,5 @@ +package org.openedx.profile.system.notifier.account + +import org.openedx.profile.system.notifier.profile.ProfileEvent + +class AccountUpdated : ProfileEvent diff --git a/profile/src/main/java/org/openedx/profile/system/notifier/profile/ProfileEvent.kt b/profile/src/main/java/org/openedx/profile/system/notifier/profile/ProfileEvent.kt new file mode 100644 index 000000000..c978a78d3 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/system/notifier/profile/ProfileEvent.kt @@ -0,0 +1,3 @@ +package org.openedx.profile.system.notifier.profile + +interface ProfileEvent diff --git a/profile/src/main/java/org/openedx/profile/system/notifier/ProfileNotifier.kt b/profile/src/main/java/org/openedx/profile/system/notifier/profile/ProfileNotifier.kt similarity index 70% rename from profile/src/main/java/org/openedx/profile/system/notifier/ProfileNotifier.kt rename to profile/src/main/java/org/openedx/profile/system/notifier/profile/ProfileNotifier.kt index c51d82340..71e2dbf1d 100644 --- a/profile/src/main/java/org/openedx/profile/system/notifier/ProfileNotifier.kt +++ b/profile/src/main/java/org/openedx/profile/system/notifier/profile/ProfileNotifier.kt @@ -1,9 +1,10 @@ -package org.openedx.profile.system.notifier +package org.openedx.profile.system.notifier.profile import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow -import org.openedx.core.system.notifier.VideoQualityChanged +import org.openedx.profile.system.notifier.account.AccountDeactivated +import org.openedx.profile.system.notifier.account.AccountUpdated class ProfileNotifier { diff --git a/profile/src/main/res/values-uk/strings.xml b/profile/src/main/res/values-uk/strings.xml deleted file mode 100644 index fe5eb14b8..000000000 --- a/profile/src/main/res/values-uk/strings.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - Інформація про профіль - Біо: %1$s - Рік народження: %1$s - Повний профіль - Обмежений профіль - Редагувати профіль - Редагувати - Зберегти - Видалити профіль - Вам повинно бути не менше 13 років, щоб мати повний доступ до інформації в профілі - Рік народження - Місцезнаходження - Про мене - Мова - Перейти до повного профілю - Перейти до обмеженого профілю - Готово - Змінити зображення профілю - Вибрати з галереї - Видалити фото - Налаштування - Видалити акаунт - Ви впевнені, що бажаєте - видалити свій акаунт? - Для підтвердження цієї дії потрібно ввести пароль вашого акаунту - Так, видалити акаунт - Пароль невірний. Будь ласка, спробуйте знову. - Пароль занадто короткий - Покинути профіль? - Покинути - Продовжити редагування - Зміни, які ви внесли, можуть не бути збереженими. - - diff --git a/profile/src/main/res/values/strings.xml b/profile/src/main/res/values/strings.xml index efdb04c30..1adf22c97 100644 --- a/profile/src/main/res/values/strings.xml +++ b/profile/src/main/res/values/strings.xml @@ -37,7 +37,47 @@ Contact Support Support Video + Dates & Calendar Wi-fi only download Only download content when wi-fi is turned on + Calendar Sync + Set up calendar sync to show your upcoming assignments and course milestones on your calendar. New assignments and shifted course dates will sync automatically + Set Up Calendar Sync + Calendar Access + To show upcoming assignments and course milestones on your calendar, we need permission to access your calendar. + Grant Calendar Access + New Calendar + Upcoming assignments for active courses will appear on this calendar + Begin Syncing + Calendar Name + Red + Orange + Yellow + Green + Blue + Purple + Brown + Accent + Course Dates + Color + Course Calendar Sync + Currently syncing events to your calendar + Change Sync Options + Courses to Sync + Syncing %1$s Courses + Options + Use relative dates + Show relative dates like “Tomorrow” and “Yesterday” + Disabling sync for a course will remove all events connected to the course from your synced calendar. + Automatically remove events from courses you haven’t viewed in the last month + Inactive + Hide Inactive Courses + Disable Calendar Sync + Disabling calendar sync will delete the calendar “%1$s.” You can turn calendar sync back on at any time. + Disable Syncing + No %1$s Courses + No courses are currently being synced to your calendar. + No courses match the current filter. + Show full dates like “%1$s” diff --git a/profile/src/test/java/org/openedx/profile/presentation/edit/EditProfileViewModelTest.kt b/profile/src/test/java/org/openedx/profile/presentation/edit/EditProfileViewModelTest.kt index bfe6bb0b3..9cc8c79ff 100644 --- a/profile/src/test/java/org/openedx/profile/presentation/edit/EditProfileViewModelTest.kt +++ b/profile/src/test/java/org/openedx/profile/presentation/edit/EditProfileViewModelTest.kt @@ -1,25 +1,33 @@ package org.openedx.profile.presentation.edit import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import org.openedx.core.R -import org.openedx.core.UIMessage -import org.openedx.profile.domain.model.Account -import org.openedx.core.domain.model.ProfileImage -import org.openedx.core.system.ResourceManager -import org.openedx.profile.domain.interactor.ProfileInteractor -import org.openedx.profile.presentation.ProfileAnalytics -import org.openedx.profile.system.notifier.AccountUpdated -import org.openedx.profile.system.notifier.ProfileNotifier -import io.mockk.* +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.* +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule +import org.openedx.core.R +import org.openedx.core.domain.model.ProfileImage +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager +import org.openedx.profile.domain.interactor.ProfileInteractor +import org.openedx.profile.domain.model.Account +import org.openedx.profile.presentation.ProfileAnalytics +import org.openedx.profile.system.notifier.account.AccountUpdated +import org.openedx.profile.system.notifier.profile.ProfileNotifier import java.io.File import java.net.UnknownHostException @@ -64,6 +72,7 @@ class EditProfileViewModelTest { Dispatchers.setMain(dispatcher) every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong + every { analytics.logScreenEvent(any(), any()) } returns Unit } @After @@ -172,6 +181,7 @@ class EditProfileViewModelTest { advanceUntilIdle() verify(exactly = 1) { analytics.logEvent(any(), any()) } + verify(exactly = 1) { analytics.logScreenEvent(any(), any()) } coVerify(exactly = 1) { interactor.updateAccount(any()) } coVerify(exactly = 1) { interactor.setProfileImage(any(), any()) } diff --git a/profile/src/test/java/org/openedx/profile/presentation/profile/AnothersProfileViewModelTest.kt b/profile/src/test/java/org/openedx/profile/presentation/profile/AnothersProfileViewModelTest.kt index 0e299e82a..6bdd07b82 100644 --- a/profile/src/test/java/org/openedx/profile/presentation/profile/AnothersProfileViewModelTest.kt +++ b/profile/src/test/java/org/openedx/profile/presentation/profile/AnothersProfileViewModelTest.kt @@ -19,9 +19,9 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule import org.openedx.core.R -import org.openedx.core.UIMessage import org.openedx.core.domain.model.ProfileImage -import org.openedx.core.system.ResourceManager +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import org.openedx.profile.domain.interactor.ProfileInteractor import org.openedx.profile.presentation.anothersaccount.AnothersProfileUIState import org.openedx.profile.presentation.anothersaccount.AnothersProfileViewModel diff --git a/profile/src/test/java/org/openedx/profile/presentation/profile/CalendarViewModelTest.kt b/profile/src/test/java/org/openedx/profile/presentation/profile/CalendarViewModelTest.kt new file mode 100644 index 000000000..7fd8977a1 --- /dev/null +++ b/profile/src/test/java/org/openedx/profile/presentation/profile/CalendarViewModelTest.kt @@ -0,0 +1,158 @@ +package org.openedx.profile.presentation.profile + +import androidx.activity.result.ActivityResultLauncher +import androidx.fragment.app.FragmentManager +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.openedx.core.data.storage.CalendarPreferences +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.interactor.CalendarInteractor +import org.openedx.core.presentation.settings.calendarsync.CalendarSyncState +import org.openedx.core.system.CalendarManager +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.system.notifier.calendar.CalendarCreated +import org.openedx.core.system.notifier.calendar.CalendarNotifier +import org.openedx.core.system.notifier.calendar.CalendarSynced +import org.openedx.core.worker.CalendarSyncScheduler +import org.openedx.profile.presentation.ProfileRouter +import org.openedx.profile.presentation.calendar.CalendarViewModel + +@OptIn(ExperimentalCoroutinesApi::class) +class CalendarViewModelTest { + + private val dispatcher = StandardTestDispatcher() + private lateinit var viewModel: CalendarViewModel + + private val calendarSyncScheduler = mockk(relaxed = true) + private val calendarManager = mockk(relaxed = true) + private val calendarPreferences = mockk(relaxed = true) + private val calendarNotifier = mockk(relaxed = true) + private val calendarInteractor = mockk(relaxed = true) + private val corePreferences = mockk(relaxed = true) + private val profileRouter = mockk() + private val networkConnection = mockk() + private val permissionLauncher = mockk>>() + private val fragmentManager = mockk() + + @Before + fun setup() { + Dispatchers.setMain(dispatcher) + every { networkConnection.isOnline() } returns true + viewModel = CalendarViewModel( + calendarSyncScheduler = calendarSyncScheduler, + calendarManager = calendarManager, + calendarPreferences = calendarPreferences, + calendarNotifier = calendarNotifier, + calendarInteractor = calendarInteractor, + corePreferences = corePreferences, + profileRouter = profileRouter, + networkConnection = networkConnection + ) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `init triggers immediate sync and loads calendar data`() = runTest(dispatcher) { + coVerify { calendarSyncScheduler.requestImmediateSync() } + coVerify { calendarInteractor.getAllCourseCalendarStateFromCache() } + } + + @Test + fun `setUpCalendarSync launches permission request`() = runTest(dispatcher) { + every { permissionLauncher.launch(calendarManager.permissions) } returns Unit + viewModel.setUpCalendarSync(permissionLauncher) + coVerify { permissionLauncher.launch(calendarManager.permissions) } + } + + @Test + fun `setCalendarSyncEnabled enables sync and triggers sync when isEnabled is true`() = runTest(dispatcher) { + viewModel.setCalendarSyncEnabled(isEnabled = true, fragmentManager = fragmentManager) + + coVerify { + calendarPreferences.isCalendarSyncEnabled = true + calendarSyncScheduler.requestImmediateSync() + } + assertTrue(viewModel.uiState.value.isCalendarSyncEnabled) + } + + @Test + fun `setRelativeDateEnabled updates preference and UI state`() = runTest(dispatcher) { + viewModel.setRelativeDateEnabled(true) + + coVerify { corePreferences.isRelativeDatesEnabled = true } + assertTrue(viewModel.uiState.value.isRelativeDateEnabled) + } + + @Test + fun `network disconnection changes sync state to offline`() = runTest(dispatcher) { + every { networkConnection.isOnline() } returns false + viewModel = CalendarViewModel( + calendarSyncScheduler, + calendarManager, + calendarPreferences, + calendarNotifier, + calendarInteractor, + corePreferences, + profileRouter, + networkConnection + ) + + assertEquals(CalendarSyncState.OFFLINE, viewModel.uiState.value.calendarSyncState) + } + + @Test + fun `successful calendar sync updates sync state to SYNCED`() = runTest(dispatcher) { + viewModel = CalendarViewModel( + calendarSyncScheduler, + calendarManager, + calendarPreferences, + calendarNotifier.apply { + every { notifier } returns flowOf(CalendarSynced) + }, + calendarInteractor, + corePreferences, + profileRouter, + networkConnection + ) + + assertEquals(CalendarSyncState.SYNCED, viewModel.uiState.value.calendarSyncState) + } + + @Test + fun `calendar creation updates calendar existence state`() = runTest(dispatcher) { + every { calendarPreferences.calendarId } returns 1 + every { calendarManager.isCalendarExist(1) } returns true + + viewModel = CalendarViewModel( + calendarSyncScheduler, + calendarManager, + calendarPreferences, + calendarNotifier.apply { + every { notifier } returns flowOf(CalendarCreated) + }, + calendarInteractor, + corePreferences, + profileRouter, + networkConnection + ) + + assertTrue(viewModel.uiState.value.isCalendarExist) + } +} diff --git a/profile/src/test/java/org/openedx/profile/presentation/profile/ProfileViewModelTest.kt b/profile/src/test/java/org/openedx/profile/presentation/profile/ProfileViewModelTest.kt index ca2ffd9bb..0d11e9c8a 100644 --- a/profile/src/test/java/org/openedx/profile/presentation/profile/ProfileViewModelTest.kt +++ b/profile/src/test/java/org/openedx/profile/presentation/profile/ProfileViewModelTest.kt @@ -24,16 +24,16 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule import org.openedx.core.R -import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.domain.model.AgreementUrls import org.openedx.core.domain.model.ProfileImage -import org.openedx.core.system.ResourceManager +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import org.openedx.profile.domain.interactor.ProfileInteractor import org.openedx.profile.presentation.ProfileAnalytics import org.openedx.profile.presentation.ProfileRouter -import org.openedx.profile.system.notifier.AccountUpdated -import org.openedx.profile.system.notifier.ProfileNotifier +import org.openedx.profile.system.notifier.account.AccountUpdated +import org.openedx.profile.system.notifier.profile.ProfileNotifier import java.net.UnknownHostException @OptIn(ExperimentalCoroutinesApi::class) diff --git a/settings.gradle b/settings.gradle index 66cb04c11..40beee473 100644 --- a/settings.gradle +++ b/settings.gradle @@ -12,7 +12,7 @@ pluginManagement { } } dependencies { - classpath("com.android.tools:r8:8.2.26") + classpath("com.android.tools:r8:8.5.35") } } } @@ -25,11 +25,10 @@ dependencyResolutionManagement { url "http://appboy.github.io/appboy-android-sdk/sdk" allowInsecureProtocol = true } + maven { url "https://pkgs.dev.azure.com/MicrosoftDeviceSDK/DuoSDK-Public/_packaging/Duo-SDK-Feed/maven/v1" } maven { - url "https://appboy.github.io/appboy-segment-android/sdk" - allowInsecureProtocol = true + url = uri("https://jitpack.io") } - maven { url "https://pkgs.dev.azure.com/MicrosoftDeviceSDK/DuoSDK-Public/_packaging/Duo-SDK-Feed/maven/v1" } } } //Workaround for AS Iguana https://github.com/gradle/gradle/issues/28407 diff --git a/whatsnew/build.gradle b/whatsnew/build.gradle index 4a400063e..59a5e14cc 100644 --- a/whatsnew/build.gradle +++ b/whatsnew/build.gradle @@ -2,6 +2,7 @@ plugins { id 'com.android.library' id 'org.jetbrains.kotlin.android' id 'kotlin-parcelize' + id "org.jetbrains.kotlin.plugin.compose" } android { @@ -30,6 +31,7 @@ android { kotlinOptions { jvmTarget = JavaVersion.VERSION_17 + freeCompilerArgs = List.of("-Xstring-concat=inline") } buildFeatures { @@ -37,10 +39,6 @@ android { compose true } - composeOptions { - kotlinCompilerExtensionVersion = "$compose_compiler_version" - } - flavorDimensions += "env" productFlavors { prod { @@ -58,11 +56,10 @@ android { dependencies { implementation project(path: ":core") - androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + androidTestImplementation 'androidx.test.ext:junit:1.2.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' testImplementation "junit:junit:$junit_version" testImplementation "io.mockk:mockk:$mockk_version" testImplementation "io.mockk:mockk-android:$mockk_version" - testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version" testImplementation "androidx.arch.core:core-testing:$android_arch_version" } \ No newline at end of file diff --git a/whatsnew/proguard-rules.pro b/whatsnew/proguard-rules.pro index 481bb4348..cdb308aa0 100644 --- a/whatsnew/proguard-rules.pro +++ b/whatsnew/proguard-rules.pro @@ -1,21 +1,7 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +# Prevent shrinking, optimization, and obfuscation of the library when consumed by other modules. +# This ensures that all classes and methods remain available for use by the consumer of the library. +# Disabling these steps at the library level is important because the main app module will handle +# shrinking, optimization, and obfuscation for the entire application, including this library. +-dontshrink +-dontoptimize +-dontobfuscate diff --git a/whatsnew/src/main/java/org/openedx/whatsnew/WhatsNewRouter.kt b/whatsnew/src/main/java/org/openedx/whatsnew/WhatsNewRouter.kt index a8d1cd463..82d02f000 100644 --- a/whatsnew/src/main/java/org/openedx/whatsnew/WhatsNewRouter.kt +++ b/whatsnew/src/main/java/org/openedx/whatsnew/WhatsNewRouter.kt @@ -3,5 +3,10 @@ package org.openedx.whatsnew import androidx.fragment.app.FragmentManager interface WhatsNewRouter { - fun navigateToMain(fm: FragmentManager, courseId: String? = null, infoType: String? = null) + fun navigateToMain( + fm: FragmentManager, + courseId: String?, + infoType: String?, + openTab: String + ) } diff --git a/whatsnew/src/main/java/org/openedx/whatsnew/presentation/ui/WhatsNewUI.kt b/whatsnew/src/main/java/org/openedx/whatsnew/presentation/ui/WhatsNewUI.kt index a76ff9a10..7d97eee40 100644 --- a/whatsnew/src/main/java/org/openedx/whatsnew/presentation/ui/WhatsNewUI.kt +++ b/whatsnew/src/main/java/org/openedx/whatsnew/presentation/ui/WhatsNewUI.kt @@ -210,7 +210,7 @@ fun NextFinishButton( .testTag("btn_next") .height(42.dp), colors = ButtonDefaults.buttonColors( - backgroundColor = MaterialTheme.appColors.buttonBackground + backgroundColor = MaterialTheme.appColors.primaryButtonBackground ), elevation = null, shape = MaterialTheme.appShapes.navigationButtonShape, @@ -231,14 +231,14 @@ fun NextFinishButton( Text( modifier = Modifier.testTag("txt_next"), text = stringResource(id = R.string.whats_new_navigation_next), - color = MaterialTheme.appColors.buttonText, + color = MaterialTheme.appColors.primaryButtonText, style = MaterialTheme.appTypography.labelLarge ) Spacer(Modifier.width(8.dp)) Icon( painter = painterResource(id = org.openedx.core.R.drawable.core_ic_forward), contentDescription = null, - tint = MaterialTheme.appColors.buttonText + tint = MaterialTheme.appColors.primaryButtonText ) } } else { @@ -249,14 +249,14 @@ fun NextFinishButton( Text( modifier = Modifier.testTag("txt_done"), text = stringResource(id = R.string.whats_new_navigation_done), - color = MaterialTheme.appColors.buttonText, + color = MaterialTheme.appColors.primaryButtonText, style = MaterialTheme.appTypography.labelLarge ) Spacer(Modifier.width(8.dp)) Icon( painter = painterResource(id = org.openedx.core.R.drawable.core_ic_check), contentDescription = null, - tint = MaterialTheme.appColors.buttonText + tint = MaterialTheme.appColors.primaryButtonText ) } } diff --git a/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewFragment.kt b/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewFragment.kt index da0458054..8b9523eaa 100644 --- a/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewFragment.kt +++ b/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewFragment.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn @@ -56,14 +57,14 @@ import androidx.fragment.app.Fragment import kotlinx.coroutines.launch import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf -import org.openedx.core.ui.WindowSize import org.openedx.core.ui.calculateCurrentOffsetForPage -import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography -import org.openedx.core.ui.windowSizeValue +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue import org.openedx.whatsnew.domain.model.WhatsNewItem import org.openedx.whatsnew.domain.model.WhatsNewMessage import org.openedx.whatsnew.presentation.ui.NavigationUnitsButtons @@ -120,7 +121,7 @@ class WhatsNewFragment : Fragment() { } } -@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class) +@OptIn(ExperimentalComposeUiApi::class) @Composable fun WhatsNewScreen( windowSize: WindowSize, @@ -140,6 +141,7 @@ fun WhatsNewScreen( .semantics { testTagsAsResourceId = true } + .navigationBarsPadding() .fillMaxSize(), scaffoldState = scaffoldState, topBar = { @@ -174,7 +176,6 @@ fun WhatsNewScreen( } } -@OptIn(ExperimentalFoundationApi::class) @Composable private fun WhatsNewTopBar( windowSize: WindowSize, @@ -247,26 +248,26 @@ private fun WhatsNewScreenPortrait( .background(MaterialTheme.appColors.background), contentAlignment = Alignment.TopCenter ) { - HorizontalPager( - modifier = Modifier.fillMaxSize(), - verticalAlignment = Alignment.Top, - state = pagerState - ) { page -> - val image = whatsNewItem.messages[page].image - Image( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 36.dp, vertical = 48.dp), - painter = painterResource(id = image), - contentDescription = null - ) - } - Box( + Column( modifier = Modifier - .fillMaxSize() - .padding(horizontal = 24.dp, vertical = 120.dp), - contentAlignment = Alignment.BottomCenter + .padding(horizontal = 24.dp, vertical = 36.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), ) { + HorizontalPager( + modifier = Modifier + .fillMaxWidth() + .weight(1.0f), + verticalAlignment = Alignment.Top, + state = pagerState + ) { page -> + val image = whatsNewItem.messages[page].image + Image( + modifier = Modifier + .fillMaxWidth(), + painter = painterResource(id = image), + contentDescription = null + ) + } Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(20.dp), diff --git a/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewViewModel.kt b/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewViewModel.kt index 51f0f9646..dbbbdda2f 100644 --- a/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewViewModel.kt +++ b/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewViewModel.kt @@ -3,8 +3,8 @@ package org.openedx.whatsnew.presentation.whatsnew import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf import androidx.fragment.app.FragmentManager -import org.openedx.core.BaseViewModel import org.openedx.core.presentation.global.AppData +import org.openedx.foundation.presentation.BaseViewModel import org.openedx.whatsnew.WhatsNewManager import org.openedx.whatsnew.WhatsNewRouter import org.openedx.whatsnew.data.storage.WhatsNewPreferences @@ -41,7 +41,8 @@ class WhatsNewViewModel( router.navigateToMain( fm, courseId, - infoType + infoType, + "" ) } diff --git a/whatsnew/src/main/res/values-uk/strings.xml b/whatsnew/src/main/res/values-uk/strings.xml deleted file mode 100644 index d1ad95a41..000000000 --- a/whatsnew/src/main/res/values-uk/strings.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - Що нового - Попередній - Наступний - Закрити - \ No newline at end of file