diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS deleted file mode 100644 index a3be1b453..000000000 --- a/.github/CODEOWNERS +++ /dev/null @@ -1 +0,0 @@ -* @raccoongang/educationx-app-android-reviewers diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..9186f7421 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ +version: 2 +updates: + # Adding new check for github-actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/detekt.yml b/.github/workflows/detekt.yml new file mode 100644 index 000000000..0cee02ffe --- /dev/null +++ b/.github/workflows/detekt.yml @@ -0,0 +1,33 @@ +name: Detekt + +on: + workflow_dispatch: + pull_request: { } + +env: + GRADLE_OPTS: -Dorg.gradle.daemon=false + CI_GRADLE_ARG_PROPERTIES: --stacktrace + +jobs: + linting: + name: Run Detekt + runs-on: ubuntu-latest + + steps: + - name: Checkout Repo + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: 'temurin' # See 'Supported distributions' for available options + java-version: '17' + + - name: Run Detekt + run: ./gradlew detektAll + + - name: Upload report + uses: github/codeql-action/upload-sarif@v3 + if: success() || failure() + with: + sarif_file: build/reports/detekt/detekt.sarif 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..f7ad7ef16 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' @@ -26,12 +28,13 @@ if (firebaseEnabled) { } android { - compileSdk 34 + namespace 'org.openedx.app' + compileSdkVersion compile_sdk_version defaultConfig { applicationId appId - minSdk 24 - targetSdk 34 + minSdk min_sdk_version + targetSdk target_sdk_version versionCode 1 versionName "1.0.0" @@ -40,7 +43,6 @@ android { testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } - namespace 'org.openedx.app' flavorDimensions += "env" productFlavors { @@ -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"] } } @@ -86,20 +88,20 @@ android { } } compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 + sourceCompatibility java_version + targetCompatibility java_version } - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17 + kotlin { + compilerOptions { + jvmTarget = jvm_target_version + freeCompilerArgs = ['-XXLanguage:+PropertyParamAnnotationDefaultTargetMode'] + } } buildFeatures { viewBinding true compose true buildConfig true } - composeOptions { - kotlinCompilerExtensionVersion = "$compose_compiler_version" - } bundle { language { enableSplit = false @@ -125,35 +127,26 @@ dependencies { implementation project(path: ':profile') implementation project(path: ':discussion') implementation project(path: ':whatsnew') + implementation project(path: ':downloads') - kapt "androidx.room:room-compiler:$room_version" + ksp "androidx.room:room-compiler:$room_version" - implementation 'androidx.core:core-splashscreen:1.0.1' + implementation "androidx.core:core-splashscreen:$core_splashscreen_version" - // Segment Library - implementation "com.segment.analytics.kotlin:android:1.14.2" - // Segment's Firebase integration - implementation 'com.segment.analytics.kotlin.destinations:firebase:1.5.2' - // Braze SDK Integration - implementation "com.braze:braze-segment-kotlin:1.4.2" - implementation "com.braze:android-sdk-ui:30.2.0" + api platform("com.google.firebase:firebase-bom:$firebase_version") + api "com.google.firebase:firebase-messaging" - // Firebase Cloud Messaging Integration for Braze - implementation 'com.google.firebase:firebase-messaging-ktx:23.4.1' + // Braze SDK Integration + implementation "com.braze:android-sdk-ui:$braze_sdk_version" - // 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:$openedx_firebase_analytics_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:$test_ext_version" + androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_version" 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" } @@ -190,3 +183,7 @@ private def setupFirebaseConfigFields(buildType) { buildType.manifestPlaceholders = [fcmEnabled: firebaseEnabled && cloudMessagingEnabled] } + +ksp { + arg("room.schemaLocation", "$projectDir/schemas") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index dc403e8f7..9e4670b9e 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,80 +1,17 @@ -# 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. -keepattributes Signature -# For using GSON @Expose annotation +# CRITICAL: Keep generic type information for TypeToken to work properly +-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations -keepattributes *Annotation* -# Gson specific classes --dontwarn sun.misc.** -#-keep class com.google.gson.stream.** { *; } +# For using GSON @Expose annotation +-keepattributes *Annotation* # 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 +22,79 @@ # 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 +# CRITICAL: Do NOT allow obfuscation or shrinking of TypeToken - it needs to preserve generic type information +-keep class com.google.gson.reflect.TypeToken +-keep class * extends com.google.gson.reflect.TypeToken + +# Keep TypeToken constructors and methods to preserve generic type information +-keepclassmembers class com.google.gson.reflect.TypeToken { + (...); + ; +} + +# Keep all Gson reflection classes that handle generic types +-keep class com.google.gson.reflect.** { *; } + +# CRITICAL: Keep Google Guava TypeToken and TypeCapture classes (used by Gson) +-keep class com.google.common.reflect.TypeToken { *; } +-keep class com.google.common.reflect.TypeCapture { *; } +-keep class com.google.common.reflect.TypeToken$* { *; } +-keep class com.google.common.reflect.TypeCapture$* { *; } + +# Keep all anonymous subclasses of TypeToken (created by object : TypeToken() {}) +-keep class * extends com.google.common.reflect.TypeToken { *; } +-keep class * extends com.google.gson.reflect.TypeToken { *; } + +# Keep Gson TypeAdapter classes used by Room TypeConverters +-keep class * extends com.google.gson.TypeAdapter +-keep class * implements com.google.gson.TypeAdapterFactory + +# Keep Room TypeConverters that use Gson (important for complex types like List) +-keep @androidx.room.TypeConverter class * { *; } +-keepclassmembers class * { + @androidx.room.TypeConverter ; +} +# Keep generic type information for Room entities with complex types +-keepclassmembers class org.openedx.**.data.model.room.** { + ; + (...); + * mapToDomain(); + * mapToRoomEntity(); + * mapToEntity(); +} + +# CRITICAL: Keep the CourseConverter and all its TypeToken usage +-keep class org.openedx.course.data.storage.CourseConverter { *; } +-keepclassmembers class org.openedx.course.data.storage.CourseConverter { + (...); + ; +} + +# Keep anonymous TypeToken subclasses created in CourseConverter +-keep class org.openedx.course.data.storage.CourseConverter$* { *; } + +# CRITICAL: Prevent obfuscation of CourseConverter methods that use TypeToken +-keepclassmembers,allowobfuscation class org.openedx.course.data.storage.CourseConverter { + @androidx.room.TypeConverter ; +} + +# Keep all TypeConverter classes that use Gson +-keep class org.openedx.discovery.data.converter.DiscoveryConverter { *; } + +# Keep the specific TypeToken usage patterns in TypeConverters +-keepclassmembers class org.openedx.**.data.storage.** { + @androidx.room.TypeConverter ; +} + +-keepclassmembers class org.openedx.**.data.converter.** { + @androidx.room.TypeConverter ; +} ##---------------End: proguard configuration for Gson ---------- -keepclassmembers class * extends java.lang.Enum { @@ -100,6 +103,45 @@ public static ** valueOf(java.lang.String); } +##---------------Begin: proguard configuration for Kotlin Coroutines ---------- +# Keep all coroutine-related classes and methods +-keep class kotlinx.coroutines.** { *; } +-keep class kotlin.coroutines.** { *; } +-keep class kotlin.coroutines.intrinsics.** { *; } + +# Keep suspend functions and coroutine builders +-keepclassmembers class * { + kotlin.coroutines.Continuation *(...); +} + +# Keep coroutine context and related classes +-keep class kotlinx.coroutines.CoroutineContext$* { *; } + +# Keep Flow and StateFlow classes +-keep class kotlinx.coroutines.flow.** { *; } + +# Keep coroutine dispatchers +-keep class kotlinx.coroutines.Dispatchers { *; } +-keep class kotlinx.coroutines.Dispatchers$* { *; } + +# Keep coroutine scope and job classes +-keep class kotlinx.coroutines.CoroutineScope { *; } +-keep class kotlinx.coroutines.Job { *; } +-keep class kotlinx.coroutines.Job$* { *; } + +# Keep coroutine intrinsics that are causing the error +-keep class kotlin.coroutines.intrinsics.IntrinsicsKt { *; } +-keep class kotlin.coroutines.intrinsics.IntrinsicsKt$* { *; } + +# Keep suspend function markers +-keepclassmembers class * { + @kotlin.coroutines.RestrictsSuspension ; +} + +# Keep coroutine-related annotations +-keep @kotlin.coroutines.RestrictsSuspension class * { *; } +##---------------End: proguard configuration for Kotlin Coroutines ---------- + -dontwarn org.bouncycastle.jsse.BCSSLParameters -dontwarn org.bouncycastle.jsse.BCSSLSocket -dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider @@ -108,4 +150,35 @@ -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 +-dontwarn com.android.billingclient.api.BillingClientStateListener +-dontwarn com.android.billingclient.api.PurchasesUpdatedListener +-dontwarn com.google.crypto.tink.subtle.XChaCha20Poly1305 +-dontwarn net.jcip.annotations.GuardedBy +-dontwarn net.jcip.annotations.Immutable +-dontwarn net.jcip.annotations.ThreadSafe diff --git a/app/schemas/org.openedx.app.room.AppDatabase/1.json b/app/schemas/org.openedx.app.room.AppDatabase/1.json new file mode 100644 index 000000000..c249fa741 --- /dev/null +++ b/app/schemas/org.openedx.app.room.AppDatabase/1.json @@ -0,0 +1,772 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "bcac519e74e751a75f3e6fa5d39ac5a3", + "entities": [ + { + "tableName": "course_discovery_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `blocksUrl` TEXT NOT NULL, `courseId` TEXT NOT NULL, `effort` TEXT NOT NULL, `enrollmentStart` TEXT NOT NULL, `enrollmentEnd` TEXT NOT NULL, `hidden` INTEGER NOT NULL, `invitationOnly` INTEGER NOT NULL, `mobileAvailable` INTEGER NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `pacing` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `start` TEXT NOT NULL, `end` TEXT NOT NULL, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `overview` TEXT NOT NULL, `isEnrolled` INTEGER NOT NULL, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blocksUrl", + "columnName": "blocksUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "effort", + "columnName": "effort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enrollmentStart", + "columnName": "enrollmentStart", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enrollmentEnd", + "columnName": "enrollmentEnd", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "invitationOnly", + "columnName": "invitationOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mobileAvailable", + "columnName": "mobileAvailable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pacing", + "columnName": "pacing", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shortDescription", + "columnName": "shortDescription", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "overview", + "columnName": "overview", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isEnrolled", + "columnName": "isEnrolled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_enrolled_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`courseId` TEXT NOT NULL, `auditAccessExpires` TEXT NOT NULL, `created` TEXT NOT NULL, `mode` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `start` TEXT NOT NULL, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `end` TEXT NOT NULL, `dynamicUpgradeDeadline` TEXT NOT NULL, `subscriptionId` TEXT NOT NULL, `course_image_link` TEXT NOT NULL, `courseAbout` TEXT NOT NULL, `courseUpdates` TEXT NOT NULL, `courseHandouts` TEXT NOT NULL, `discussionUrl` TEXT NOT NULL, `videoOutline` TEXT NOT NULL, `isSelfPaced` INTEGER NOT NULL, `hasAccess` INTEGER, `errorCode` TEXT, `developerMessage` TEXT, `userMessage` TEXT, `additionalContextUserMessage` TEXT, `userFragment` TEXT, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, `facebook` TEXT NOT NULL, `twitter` TEXT NOT NULL, `certificateURL` TEXT, `assignments_completed` INTEGER NOT NULL, `total_assignments_count` INTEGER NOT NULL, `lastVisitedModuleId` TEXT, `lastVisitedModulePath` TEXT, `lastVisitedBlockId` TEXT, `lastVisitedUnitDisplayName` TEXT, `futureAssignments` TEXT, `pastAssignments` TEXT, PRIMARY KEY(`courseId`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "auditAccessExpires", + "columnName": "auditAccessExpires", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "course.id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.start", + "columnName": "start", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.end", + "columnName": "end", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.dynamicUpgradeDeadline", + "columnName": "dynamicUpgradeDeadline", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.subscriptionId", + "columnName": "subscriptionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseImage", + "columnName": "course_image_link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseAbout", + "columnName": "courseAbout", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseUpdates", + "columnName": "courseUpdates", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseHandouts", + "columnName": "courseHandouts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.discussionUrl", + "columnName": "discussionUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.videoOutline", + "columnName": "videoOutline", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "course.coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.courseSharingUtmParameters.facebook", + "columnName": "facebook", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseSharingUtmParameters.twitter", + "columnName": "twitter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "progress.assignmentsCompleted", + "columnName": "assignments_completed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress.totalAssignmentsCount", + "columnName": "total_assignments_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseStatus.lastVisitedModuleId", + "columnName": "lastVisitedModuleId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseStatus.lastVisitedModulePath", + "columnName": "lastVisitedModulePath", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseStatus.lastVisitedBlockId", + "columnName": "lastVisitedBlockId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseStatus.lastVisitedUnitDisplayName", + "columnName": "lastVisitedUnitDisplayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAssignments.futureAssignments", + "columnName": "futureAssignments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAssignments.pastAssignments", + "columnName": "pastAssignments", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "courseId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_structure_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`root` TEXT NOT NULL, `id` TEXT NOT NULL, `blocks` TEXT NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `start` TEXT, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `end` TEXT, `isSelfPaced` INTEGER NOT NULL, `hasAccess` INTEGER, `errorCode` TEXT, `developerMessage` TEXT, `userMessage` TEXT, `additionalContextUserMessage` TEXT, `userFragment` TEXT, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, `certificateURL` TEXT, `assignments_completed` INTEGER NOT NULL, `total_assignments_count` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "root", + "columnName": "root", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blocks", + "columnName": "blocks", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "progress.assignmentsCompleted", + "columnName": "assignments_completed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress.totalAssignmentsCount", + "columnName": "total_assignments_count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "download_model", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `courseId` TEXT NOT NULL, `size` INTEGER NOT NULL, `path` TEXT NOT NULL, `url` TEXT NOT NULL, `type` TEXT NOT NULL, `downloadedState` TEXT NOT NULL, `lastModified` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "downloadedState", + "columnName": "downloadedState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "offline_x_block_progress_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `courseId` TEXT NOT NULL, `url` TEXT NOT NULL, `type` TEXT NOT NULL, `data` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "blockId", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_calendar_event_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`event_id` INTEGER NOT NULL, `course_id` TEXT NOT NULL, PRIMARY KEY(`event_id`))", + "fields": [ + { + "fieldPath": "eventId", + "columnName": "event_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "course_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "event_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_calendar_state_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`course_id` TEXT NOT NULL, `checksum` INTEGER NOT NULL, `is_course_sync_enabled` INTEGER NOT NULL, PRIMARY KEY(`course_id`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "course_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "checksum", + "columnName": "checksum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCourseSyncEnabled", + "columnName": "is_course_sync_enabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "course_id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'bcac519e74e751a75f3e6fa5d39ac5a3')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/org.openedx.app.room.AppDatabase/2.json b/app/schemas/org.openedx.app.room.AppDatabase/2.json new file mode 100644 index 000000000..002abc547 --- /dev/null +++ b/app/schemas/org.openedx.app.room.AppDatabase/2.json @@ -0,0 +1,978 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "ed545aec6739ec7692c4bb72179331c4", + "entities": [ + { + "tableName": "course_discovery_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `blocksUrl` TEXT NOT NULL, `courseId` TEXT NOT NULL, `effort` TEXT NOT NULL, `enrollmentStart` TEXT NOT NULL, `enrollmentEnd` TEXT NOT NULL, `hidden` INTEGER NOT NULL, `invitationOnly` INTEGER NOT NULL, `mobileAvailable` INTEGER NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `pacing` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `start` TEXT NOT NULL, `end` TEXT NOT NULL, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `overview` TEXT NOT NULL, `isEnrolled` INTEGER NOT NULL, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blocksUrl", + "columnName": "blocksUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "effort", + "columnName": "effort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enrollmentStart", + "columnName": "enrollmentStart", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enrollmentEnd", + "columnName": "enrollmentEnd", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "invitationOnly", + "columnName": "invitationOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mobileAvailable", + "columnName": "mobileAvailable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pacing", + "columnName": "pacing", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shortDescription", + "columnName": "shortDescription", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "overview", + "columnName": "overview", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isEnrolled", + "columnName": "isEnrolled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_enrolled_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`courseId` TEXT NOT NULL, `auditAccessExpires` TEXT NOT NULL, `created` TEXT NOT NULL, `mode` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `start` TEXT NOT NULL, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `end` TEXT NOT NULL, `dynamicUpgradeDeadline` TEXT NOT NULL, `subscriptionId` TEXT NOT NULL, `course_image_link` TEXT NOT NULL, `courseAbout` TEXT NOT NULL, `courseUpdates` TEXT NOT NULL, `courseHandouts` TEXT NOT NULL, `discussionUrl` TEXT NOT NULL, `videoOutline` TEXT NOT NULL, `isSelfPaced` INTEGER NOT NULL, `hasAccess` INTEGER, `errorCode` TEXT, `developerMessage` TEXT, `userMessage` TEXT, `additionalContextUserMessage` TEXT, `userFragment` TEXT, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, `facebook` TEXT NOT NULL, `twitter` TEXT NOT NULL, `certificateURL` TEXT, `assignments_completed` INTEGER NOT NULL, `total_assignments_count` INTEGER NOT NULL, `lastVisitedModuleId` TEXT, `lastVisitedModulePath` TEXT, `lastVisitedBlockId` TEXT, `lastVisitedUnitDisplayName` TEXT, `futureAssignments` TEXT, `pastAssignments` TEXT, PRIMARY KEY(`courseId`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "auditAccessExpires", + "columnName": "auditAccessExpires", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "course.id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.start", + "columnName": "start", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.end", + "columnName": "end", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.dynamicUpgradeDeadline", + "columnName": "dynamicUpgradeDeadline", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.subscriptionId", + "columnName": "subscriptionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseImage", + "columnName": "course_image_link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseAbout", + "columnName": "courseAbout", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseUpdates", + "columnName": "courseUpdates", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseHandouts", + "columnName": "courseHandouts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.discussionUrl", + "columnName": "discussionUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.videoOutline", + "columnName": "videoOutline", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "course.coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.courseSharingUtmParameters.facebook", + "columnName": "facebook", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseSharingUtmParameters.twitter", + "columnName": "twitter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "progress.assignmentsCompleted", + "columnName": "assignments_completed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress.totalAssignmentsCount", + "columnName": "total_assignments_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseStatus.lastVisitedModuleId", + "columnName": "lastVisitedModuleId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseStatus.lastVisitedModulePath", + "columnName": "lastVisitedModulePath", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseStatus.lastVisitedBlockId", + "columnName": "lastVisitedBlockId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseStatus.lastVisitedUnitDisplayName", + "columnName": "lastVisitedUnitDisplayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAssignments.futureAssignments", + "columnName": "futureAssignments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAssignments.pastAssignments", + "columnName": "pastAssignments", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "courseId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_structure_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`root` TEXT NOT NULL, `id` TEXT NOT NULL, `blocks` TEXT NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `start` TEXT, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `end` TEXT, `isSelfPaced` INTEGER NOT NULL, `hasAccess` INTEGER, `errorCode` TEXT, `developerMessage` TEXT, `userMessage` TEXT, `additionalContextUserMessage` TEXT, `userFragment` TEXT, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, `certificateURL` TEXT, `assignments_completed` INTEGER NOT NULL, `total_assignments_count` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "root", + "columnName": "root", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blocks", + "columnName": "blocks", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "progress.assignmentsCompleted", + "columnName": "assignments_completed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress.totalAssignmentsCount", + "columnName": "total_assignments_count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "download_model", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `courseId` TEXT NOT NULL, `size` INTEGER NOT NULL, `path` TEXT NOT NULL, `url` TEXT NOT NULL, `type` TEXT NOT NULL, `downloadedState` TEXT NOT NULL, `lastModified` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "downloadedState", + "columnName": "downloadedState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "offline_x_block_progress_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `courseId` TEXT NOT NULL, `url` TEXT NOT NULL, `type` TEXT NOT NULL, `data` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "blockId", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_calendar_event_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`event_id` INTEGER NOT NULL, `course_id` TEXT NOT NULL, PRIMARY KEY(`event_id`))", + "fields": [ + { + "fieldPath": "eventId", + "columnName": "event_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "course_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "event_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_calendar_state_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`course_id` TEXT NOT NULL, `checksum` INTEGER NOT NULL, `is_course_sync_enabled` INTEGER NOT NULL, PRIMARY KEY(`course_id`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "course_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "checksum", + "columnName": "checksum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCourseSyncEnabled", + "columnName": "is_course_sync_enabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "course_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_enrollment_details_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `courseUpdates` TEXT NOT NULL, `courseHandouts` TEXT NOT NULL, `discussionUrl` TEXT NOT NULL, `hasUnmetPrerequisites` INTEGER NOT NULL, `isTooEarly` INTEGER NOT NULL, `isStaff` INTEGER NOT NULL, `auditAccessExpires` TEXT, `hasAccess` INTEGER, `errorCode` TEXT, `developerMessage` TEXT, `userMessage` TEXT, `additionalContextUserMessage` TEXT, `userFragment` TEXT, `certificateURL` TEXT, `created` TEXT, `mode` TEXT, `isActive` INTEGER NOT NULL, `upgradeDeadline` TEXT, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `isSelfPaced` INTEGER NOT NULL, `courseAbout` TEXT NOT NULL, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, `facebook` TEXT NOT NULL, `twitter` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseUpdates", + "columnName": "courseUpdates", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseHandouts", + "columnName": "courseHandouts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "discussionUrl", + "columnName": "discussionUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.hasUnmetPrerequisites", + "columnName": "hasUnmetPrerequisites", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.isTooEarly", + "columnName": "isTooEarly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.isStaff", + "columnName": "isStaff", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.auditAccessExpires", + "columnName": "auditAccessExpires", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enrollmentDetails.created", + "columnName": "created", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enrollmentDetails.mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enrollmentDetails.isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enrollmentDetails.upgradeDeadline", + "columnName": "upgradeDeadline", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.courseAbout", + "columnName": "courseAbout", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.courseSharingUtmParameters.facebook", + "columnName": "facebook", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.courseSharingUtmParameters.twitter", + "columnName": "twitter", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ed545aec6739ec7692c4bb72179331c4')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/org.openedx.app.room.AppDatabase/3.json b/app/schemas/org.openedx.app.room.AppDatabase/3.json new file mode 100644 index 000000000..0b47d8504 --- /dev/null +++ b/app/schemas/org.openedx.app.room.AppDatabase/3.json @@ -0,0 +1,1198 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "bcf7a22441e12e4c8b6fb332754827bf", + "entities": [ + { + "tableName": "course_discovery_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `blocksUrl` TEXT NOT NULL, `courseId` TEXT NOT NULL, `effort` TEXT NOT NULL, `enrollmentStart` TEXT NOT NULL, `enrollmentEnd` TEXT NOT NULL, `hidden` INTEGER NOT NULL, `invitationOnly` INTEGER NOT NULL, `mobileAvailable` INTEGER NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `pacing` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `start` TEXT NOT NULL, `end` TEXT NOT NULL, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `overview` TEXT NOT NULL, `isEnrolled` INTEGER NOT NULL, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blocksUrl", + "columnName": "blocksUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "effort", + "columnName": "effort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enrollmentStart", + "columnName": "enrollmentStart", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enrollmentEnd", + "columnName": "enrollmentEnd", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "invitationOnly", + "columnName": "invitationOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mobileAvailable", + "columnName": "mobileAvailable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pacing", + "columnName": "pacing", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shortDescription", + "columnName": "shortDescription", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "overview", + "columnName": "overview", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isEnrolled", + "columnName": "isEnrolled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_enrolled_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`courseId` TEXT NOT NULL, `auditAccessExpires` TEXT NOT NULL, `created` TEXT NOT NULL, `mode` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `start` TEXT NOT NULL, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `end` TEXT NOT NULL, `dynamicUpgradeDeadline` TEXT NOT NULL, `subscriptionId` TEXT NOT NULL, `course_image_link` TEXT NOT NULL, `courseAbout` TEXT NOT NULL, `courseUpdates` TEXT NOT NULL, `courseHandouts` TEXT NOT NULL, `discussionUrl` TEXT NOT NULL, `videoOutline` TEXT NOT NULL, `isSelfPaced` INTEGER NOT NULL, `hasAccess` INTEGER, `errorCode` TEXT, `developerMessage` TEXT, `userMessage` TEXT, `additionalContextUserMessage` TEXT, `userFragment` TEXT, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, `facebook` TEXT NOT NULL, `twitter` TEXT NOT NULL, `certificateURL` TEXT, `assignments_completed` INTEGER NOT NULL, `total_assignments_count` INTEGER NOT NULL, `lastVisitedModuleId` TEXT, `lastVisitedModulePath` TEXT, `lastVisitedBlockId` TEXT, `lastVisitedUnitDisplayName` TEXT, `futureAssignments` TEXT, `pastAssignments` TEXT, PRIMARY KEY(`courseId`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "auditAccessExpires", + "columnName": "auditAccessExpires", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "course.id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.start", + "columnName": "start", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.end", + "columnName": "end", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.dynamicUpgradeDeadline", + "columnName": "dynamicUpgradeDeadline", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.subscriptionId", + "columnName": "subscriptionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseImage", + "columnName": "course_image_link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseAbout", + "columnName": "courseAbout", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseUpdates", + "columnName": "courseUpdates", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseHandouts", + "columnName": "courseHandouts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.discussionUrl", + "columnName": "discussionUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.videoOutline", + "columnName": "videoOutline", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "course.coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.courseSharingUtmParameters.facebook", + "columnName": "facebook", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseSharingUtmParameters.twitter", + "columnName": "twitter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "progress.assignmentsCompleted", + "columnName": "assignments_completed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress.totalAssignmentsCount", + "columnName": "total_assignments_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseStatus.lastVisitedModuleId", + "columnName": "lastVisitedModuleId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseStatus.lastVisitedModulePath", + "columnName": "lastVisitedModulePath", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseStatus.lastVisitedBlockId", + "columnName": "lastVisitedBlockId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseStatus.lastVisitedUnitDisplayName", + "columnName": "lastVisitedUnitDisplayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAssignments.futureAssignments", + "columnName": "futureAssignments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAssignments.pastAssignments", + "columnName": "pastAssignments", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "courseId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_structure_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`root` TEXT NOT NULL, `id` TEXT NOT NULL, `blocks` TEXT NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `start` TEXT, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `end` TEXT, `isSelfPaced` INTEGER NOT NULL, `hasAccess` INTEGER, `errorCode` TEXT, `developerMessage` TEXT, `userMessage` TEXT, `additionalContextUserMessage` TEXT, `userFragment` TEXT, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, `certificateURL` TEXT, `assignments_completed` INTEGER NOT NULL, `total_assignments_count` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "root", + "columnName": "root", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blocks", + "columnName": "blocks", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "progress.assignmentsCompleted", + "columnName": "assignments_completed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress.totalAssignmentsCount", + "columnName": "total_assignments_count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "download_model", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `courseId` TEXT NOT NULL, `size` INTEGER NOT NULL, `path` TEXT NOT NULL, `url` TEXT NOT NULL, `type` TEXT NOT NULL, `downloadedState` TEXT NOT NULL, `lastModified` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "downloadedState", + "columnName": "downloadedState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "offline_x_block_progress_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `courseId` TEXT NOT NULL, `url` TEXT NOT NULL, `type` TEXT NOT NULL, `data` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "blockId", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_calendar_event_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`event_id` INTEGER NOT NULL, `course_id` TEXT NOT NULL, PRIMARY KEY(`event_id`))", + "fields": [ + { + "fieldPath": "eventId", + "columnName": "event_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "course_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "event_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_calendar_state_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`course_id` TEXT NOT NULL, `checksum` INTEGER NOT NULL, `is_course_sync_enabled` INTEGER NOT NULL, PRIMARY KEY(`course_id`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "course_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "checksum", + "columnName": "checksum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCourseSyncEnabled", + "columnName": "is_course_sync_enabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "course_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "download_course_preview_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`course_id` TEXT NOT NULL, `course_name` TEXT, `course_image` TEXT, `total_size` INTEGER, PRIMARY KEY(`course_id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "course_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "course_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "course_image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "totalSize", + "columnName": "total_size", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "course_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_enrollment_details_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `courseUpdates` TEXT NOT NULL, `courseHandouts` TEXT NOT NULL, `discussionUrl` TEXT NOT NULL, `hasUnmetPrerequisites` INTEGER NOT NULL, `isTooEarly` INTEGER NOT NULL, `isStaff` INTEGER NOT NULL, `auditAccessExpires` TEXT, `hasAccess` INTEGER, `errorCode` TEXT, `developerMessage` TEXT, `userMessage` TEXT, `additionalContextUserMessage` TEXT, `userFragment` TEXT, `certificateURL` TEXT, `created` TEXT, `mode` TEXT, `isActive` INTEGER NOT NULL, `upgradeDeadline` TEXT, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `isSelfPaced` INTEGER NOT NULL, `courseAbout` TEXT NOT NULL, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, `facebook` TEXT NOT NULL, `twitter` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseUpdates", + "columnName": "courseUpdates", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseHandouts", + "columnName": "courseHandouts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "discussionUrl", + "columnName": "discussionUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.hasUnmetPrerequisites", + "columnName": "hasUnmetPrerequisites", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.isTooEarly", + "columnName": "isTooEarly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.isStaff", + "columnName": "isStaff", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.auditAccessExpires", + "columnName": "auditAccessExpires", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enrollmentDetails.created", + "columnName": "created", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enrollmentDetails.mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enrollmentDetails.isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enrollmentDetails.upgradeDeadline", + "columnName": "upgradeDeadline", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.courseAbout", + "columnName": "courseAbout", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.courseSharingUtmParameters.facebook", + "columnName": "facebook", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.courseSharingUtmParameters.twitter", + "columnName": "twitter", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_progress_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`courseId` TEXT NOT NULL, `verifiedMode` TEXT NOT NULL, `accessExpiration` TEXT NOT NULL, `creditCourseRequirements` TEXT NOT NULL, `end` TEXT NOT NULL, `enrollmentMode` TEXT NOT NULL, `hasScheduledContent` INTEGER NOT NULL, `sectionScores` TEXT NOT NULL, `studioUrl` TEXT NOT NULL, `username` TEXT NOT NULL, `userHasPassingGrade` INTEGER NOT NULL, `disableProgressGraph` INTEGER NOT NULL, `certificate_certStatus` TEXT, `certificate_certWebViewUrl` TEXT, `certificate_downloadUrl` TEXT, `certificate_certificateAvailableDate` TEXT, `completion_completeCount` INTEGER, `completion_incompleteCount` INTEGER, `completion_lockedCount` INTEGER, `grade_letterGrade` TEXT, `grade_percent` REAL, `grade_isPassing` INTEGER, `grading_assignmentPolicies` TEXT, `grading_gradeRange` TEXT, `grading_assignmentColors` TEXT, `verification_link` TEXT, `verification_status` TEXT, `verification_statusDate` TEXT, PRIMARY KEY(`courseId`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verifiedMode", + "columnName": "verifiedMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessExpiration", + "columnName": "accessExpiration", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creditCourseRequirements", + "columnName": "creditCourseRequirements", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enrollmentMode", + "columnName": "enrollmentMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasScheduledContent", + "columnName": "hasScheduledContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sectionScores", + "columnName": "sectionScores", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "studioUrl", + "columnName": "studioUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userHasPassingGrade", + "columnName": "userHasPassingGrade", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "disableProgressGraph", + "columnName": "disableProgressGraph", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "certificateData.certStatus", + "columnName": "certificate_certStatus", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "certificateData.certWebViewUrl", + "columnName": "certificate_certWebViewUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "certificateData.downloadUrl", + "columnName": "certificate_downloadUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "certificateData.certificateAvailableDate", + "columnName": "certificate_certificateAvailableDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "completionSummary.completeCount", + "columnName": "completion_completeCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "completionSummary.incompleteCount", + "columnName": "completion_incompleteCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "completionSummary.lockedCount", + "columnName": "completion_lockedCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "courseGrade.letterGrade", + "columnName": "grade_letterGrade", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseGrade.percent", + "columnName": "grade_percent", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "courseGrade.isPassing", + "columnName": "grade_isPassing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "gradingPolicy.assignmentPolicies", + "columnName": "grading_assignmentPolicies", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "gradingPolicy.gradeRange", + "columnName": "grading_gradeRange", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "gradingPolicy.assignmentColors", + "columnName": "grading_assignmentColors", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "verificationData.link", + "columnName": "verification_link", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "verificationData.status", + "columnName": "verification_status", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "verificationData.statusDate", + "columnName": "verification_statusDate", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "courseId" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'bcf7a22441e12e4c8b6fb332754827bf')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/org.openedx.app.room.AppDatabase/4.json b/app/schemas/org.openedx.app.room.AppDatabase/4.json new file mode 100644 index 000000000..0bf47775d --- /dev/null +++ b/app/schemas/org.openedx.app.room.AppDatabase/4.json @@ -0,0 +1,1236 @@ +{ + "formatVersion": 1, + "database": { + "version": 4, + "identityHash": "7ea446decde04c9c16700cb3981703c2", + "entities": [ + { + "tableName": "course_discovery_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `blocksUrl` TEXT NOT NULL, `courseId` TEXT NOT NULL, `effort` TEXT NOT NULL, `enrollmentStart` TEXT NOT NULL, `enrollmentEnd` TEXT NOT NULL, `hidden` INTEGER NOT NULL, `invitationOnly` INTEGER NOT NULL, `mobileAvailable` INTEGER NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `pacing` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `start` TEXT NOT NULL, `end` TEXT NOT NULL, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `overview` TEXT NOT NULL, `isEnrolled` INTEGER NOT NULL, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blocksUrl", + "columnName": "blocksUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "effort", + "columnName": "effort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enrollmentStart", + "columnName": "enrollmentStart", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enrollmentEnd", + "columnName": "enrollmentEnd", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "invitationOnly", + "columnName": "invitationOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mobileAvailable", + "columnName": "mobileAvailable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pacing", + "columnName": "pacing", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shortDescription", + "columnName": "shortDescription", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "overview", + "columnName": "overview", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isEnrolled", + "columnName": "isEnrolled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_enrolled_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`courseId` TEXT NOT NULL, `auditAccessExpires` TEXT NOT NULL, `created` TEXT NOT NULL, `mode` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `start` TEXT NOT NULL, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `end` TEXT NOT NULL, `dynamicUpgradeDeadline` TEXT NOT NULL, `subscriptionId` TEXT NOT NULL, `course_image_link` TEXT NOT NULL, `courseAbout` TEXT NOT NULL, `courseUpdates` TEXT NOT NULL, `courseHandouts` TEXT NOT NULL, `discussionUrl` TEXT NOT NULL, `videoOutline` TEXT NOT NULL, `isSelfPaced` INTEGER NOT NULL, `hasAccess` INTEGER, `errorCode` TEXT, `developerMessage` TEXT, `userMessage` TEXT, `additionalContextUserMessage` TEXT, `userFragment` TEXT, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, `facebook` TEXT NOT NULL, `twitter` TEXT NOT NULL, `certificateURL` TEXT, `assignments_completed` INTEGER NOT NULL, `total_assignments_count` INTEGER NOT NULL, `lastVisitedModuleId` TEXT, `lastVisitedModulePath` TEXT, `lastVisitedBlockId` TEXT, `lastVisitedUnitDisplayName` TEXT, `futureAssignments` TEXT, `pastAssignments` TEXT, PRIMARY KEY(`courseId`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "auditAccessExpires", + "columnName": "auditAccessExpires", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "course.id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.start", + "columnName": "start", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.end", + "columnName": "end", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.dynamicUpgradeDeadline", + "columnName": "dynamicUpgradeDeadline", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.subscriptionId", + "columnName": "subscriptionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseImage", + "columnName": "course_image_link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseAbout", + "columnName": "courseAbout", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseUpdates", + "columnName": "courseUpdates", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseHandouts", + "columnName": "courseHandouts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.discussionUrl", + "columnName": "discussionUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.videoOutline", + "columnName": "videoOutline", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "course.coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.courseSharingUtmParameters.facebook", + "columnName": "facebook", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseSharingUtmParameters.twitter", + "columnName": "twitter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "progress.assignmentsCompleted", + "columnName": "assignments_completed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress.totalAssignmentsCount", + "columnName": "total_assignments_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseStatus.lastVisitedModuleId", + "columnName": "lastVisitedModuleId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseStatus.lastVisitedModulePath", + "columnName": "lastVisitedModulePath", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseStatus.lastVisitedBlockId", + "columnName": "lastVisitedBlockId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseStatus.lastVisitedUnitDisplayName", + "columnName": "lastVisitedUnitDisplayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAssignments.futureAssignments", + "columnName": "futureAssignments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAssignments.pastAssignments", + "columnName": "pastAssignments", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "courseId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_structure_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`root` TEXT NOT NULL, `id` TEXT NOT NULL, `blocks` TEXT NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `start` TEXT, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `end` TEXT, `isSelfPaced` INTEGER NOT NULL, `hasAccess` INTEGER, `errorCode` TEXT, `developerMessage` TEXT, `userMessage` TEXT, `additionalContextUserMessage` TEXT, `userFragment` TEXT, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, `certificateURL` TEXT, `assignments_completed` INTEGER NOT NULL, `total_assignments_count` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "root", + "columnName": "root", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blocks", + "columnName": "blocks", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "progress.assignmentsCompleted", + "columnName": "assignments_completed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress.totalAssignmentsCount", + "columnName": "total_assignments_count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "download_model", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `courseId` TEXT NOT NULL, `size` INTEGER NOT NULL, `path` TEXT NOT NULL, `url` TEXT NOT NULL, `type` TEXT NOT NULL, `downloadedState` TEXT NOT NULL, `lastModified` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "downloadedState", + "columnName": "downloadedState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "offline_x_block_progress_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `courseId` TEXT NOT NULL, `url` TEXT NOT NULL, `type` TEXT NOT NULL, `data` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "blockId", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_calendar_event_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`event_id` INTEGER NOT NULL, `course_id` TEXT NOT NULL, PRIMARY KEY(`event_id`))", + "fields": [ + { + "fieldPath": "eventId", + "columnName": "event_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "course_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "event_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_calendar_state_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`course_id` TEXT NOT NULL, `checksum` INTEGER NOT NULL, `is_course_sync_enabled` INTEGER NOT NULL, PRIMARY KEY(`course_id`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "course_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "checksum", + "columnName": "checksum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCourseSyncEnabled", + "columnName": "is_course_sync_enabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "course_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "download_course_preview_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`course_id` TEXT NOT NULL, `course_name` TEXT, `course_image` TEXT, `total_size` INTEGER, PRIMARY KEY(`course_id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "course_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "course_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "course_image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "totalSize", + "columnName": "total_size", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "course_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_enrollment_details_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `courseUpdates` TEXT NOT NULL, `courseHandouts` TEXT NOT NULL, `discussionUrl` TEXT NOT NULL, `hasUnmetPrerequisites` INTEGER NOT NULL, `isTooEarly` INTEGER NOT NULL, `isStaff` INTEGER NOT NULL, `auditAccessExpires` TEXT, `hasAccess` INTEGER, `errorCode` TEXT, `developerMessage` TEXT, `userMessage` TEXT, `additionalContextUserMessage` TEXT, `userFragment` TEXT, `certificateURL` TEXT, `created` TEXT, `mode` TEXT, `isActive` INTEGER NOT NULL, `upgradeDeadline` TEXT, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `isSelfPaced` INTEGER NOT NULL, `courseAbout` TEXT NOT NULL, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, `facebook` TEXT NOT NULL, `twitter` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseUpdates", + "columnName": "courseUpdates", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseHandouts", + "columnName": "courseHandouts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "discussionUrl", + "columnName": "discussionUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.hasUnmetPrerequisites", + "columnName": "hasUnmetPrerequisites", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.isTooEarly", + "columnName": "isTooEarly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.isStaff", + "columnName": "isStaff", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.auditAccessExpires", + "columnName": "auditAccessExpires", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enrollmentDetails.created", + "columnName": "created", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enrollmentDetails.mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enrollmentDetails.isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enrollmentDetails.upgradeDeadline", + "columnName": "upgradeDeadline", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.courseAbout", + "columnName": "courseAbout", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.courseSharingUtmParameters.facebook", + "columnName": "facebook", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.courseSharingUtmParameters.twitter", + "columnName": "twitter", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "video_progress_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`block_id` TEXT NOT NULL, `video_url` TEXT NOT NULL, `video_time` INTEGER, `duration` INTEGER, PRIMARY KEY(`block_id`))", + "fields": [ + { + "fieldPath": "blockId", + "columnName": "block_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoUrl", + "columnName": "video_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoTime", + "columnName": "video_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "block_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_progress_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`courseId` TEXT NOT NULL, `verifiedMode` TEXT NOT NULL, `accessExpiration` TEXT NOT NULL, `creditCourseRequirements` TEXT NOT NULL, `end` TEXT NOT NULL, `enrollmentMode` TEXT NOT NULL, `hasScheduledContent` INTEGER NOT NULL, `sectionScores` TEXT NOT NULL, `studioUrl` TEXT NOT NULL, `username` TEXT NOT NULL, `userHasPassingGrade` INTEGER NOT NULL, `disableProgressGraph` INTEGER NOT NULL, `certificate_certStatus` TEXT, `certificate_certWebViewUrl` TEXT, `certificate_downloadUrl` TEXT, `certificate_certificateAvailableDate` TEXT, `completion_completeCount` INTEGER, `completion_incompleteCount` INTEGER, `completion_lockedCount` INTEGER, `grade_letterGrade` TEXT, `grade_percent` REAL, `grade_isPassing` INTEGER, `grading_assignmentPolicies` TEXT, `grading_gradeRange` TEXT, `grading_assignmentColors` TEXT, `verification_link` TEXT, `verification_status` TEXT, `verification_statusDate` TEXT, PRIMARY KEY(`courseId`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verifiedMode", + "columnName": "verifiedMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessExpiration", + "columnName": "accessExpiration", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creditCourseRequirements", + "columnName": "creditCourseRequirements", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enrollmentMode", + "columnName": "enrollmentMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasScheduledContent", + "columnName": "hasScheduledContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sectionScores", + "columnName": "sectionScores", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "studioUrl", + "columnName": "studioUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userHasPassingGrade", + "columnName": "userHasPassingGrade", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "disableProgressGraph", + "columnName": "disableProgressGraph", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "certificateData.certStatus", + "columnName": "certificate_certStatus", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "certificateData.certWebViewUrl", + "columnName": "certificate_certWebViewUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "certificateData.downloadUrl", + "columnName": "certificate_downloadUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "certificateData.certificateAvailableDate", + "columnName": "certificate_certificateAvailableDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "completionSummary.completeCount", + "columnName": "completion_completeCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "completionSummary.incompleteCount", + "columnName": "completion_incompleteCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "completionSummary.lockedCount", + "columnName": "completion_lockedCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "courseGrade.letterGrade", + "columnName": "grade_letterGrade", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseGrade.percent", + "columnName": "grade_percent", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "courseGrade.isPassing", + "columnName": "grade_isPassing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "gradingPolicy.assignmentPolicies", + "columnName": "grading_assignmentPolicies", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "gradingPolicy.gradeRange", + "columnName": "grading_gradeRange", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "gradingPolicy.assignmentColors", + "columnName": "grading_assignmentColors", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "verificationData.link", + "columnName": "verification_link", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "verificationData.status", + "columnName": "verification_status", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "verificationData.statusDate", + "columnName": "verification_statusDate", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "courseId" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7ea446decde04c9c16700cb3981703c2')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/org.openedx.app.room.AppDatabase/5.json b/app/schemas/org.openedx.app.room.AppDatabase/5.json new file mode 100644 index 000000000..3b42cabf3 --- /dev/null +++ b/app/schemas/org.openedx.app.room.AppDatabase/5.json @@ -0,0 +1,1152 @@ +{ + "formatVersion": 1, + "database": { + "version": 5, + "identityHash": "09f6fc49a2f7a494d27f3290d7bae350", + "entities": [ + { + "tableName": "course_discovery_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `blocksUrl` TEXT NOT NULL, `courseId` TEXT NOT NULL, `effort` TEXT NOT NULL, `enrollmentStart` TEXT NOT NULL, `enrollmentEnd` TEXT NOT NULL, `hidden` INTEGER NOT NULL, `invitationOnly` INTEGER NOT NULL, `mobileAvailable` INTEGER NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `pacing` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `start` TEXT NOT NULL, `end` TEXT NOT NULL, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `overview` TEXT NOT NULL, `isEnrolled` INTEGER NOT NULL, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blocksUrl", + "columnName": "blocksUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "effort", + "columnName": "effort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enrollmentStart", + "columnName": "enrollmentStart", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enrollmentEnd", + "columnName": "enrollmentEnd", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "invitationOnly", + "columnName": "invitationOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mobileAvailable", + "columnName": "mobileAvailable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pacing", + "columnName": "pacing", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shortDescription", + "columnName": "shortDescription", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "overview", + "columnName": "overview", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isEnrolled", + "columnName": "isEnrolled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT" + }, + { + "fieldPath": "media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT" + }, + { + "fieldPath": "media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT" + }, + { + "fieldPath": "media.image", + "columnName": "image", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "course_enrolled_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`courseId` TEXT NOT NULL, `auditAccessExpires` TEXT NOT NULL, `created` TEXT NOT NULL, `mode` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `start` TEXT NOT NULL, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `end` TEXT NOT NULL, `dynamicUpgradeDeadline` TEXT NOT NULL, `subscriptionId` TEXT NOT NULL, `course_image_link` TEXT NOT NULL, `courseAbout` TEXT NOT NULL, `courseUpdates` TEXT NOT NULL, `courseHandouts` TEXT NOT NULL, `discussionUrl` TEXT NOT NULL, `videoOutline` TEXT NOT NULL, `isSelfPaced` INTEGER NOT NULL, `hasAccess` INTEGER, `errorCode` TEXT, `developerMessage` TEXT, `userMessage` TEXT, `additionalContextUserMessage` TEXT, `userFragment` TEXT, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, `facebook` TEXT NOT NULL, `twitter` TEXT NOT NULL, `certificateURL` TEXT, `assignments_completed` INTEGER NOT NULL, `total_assignments_count` INTEGER NOT NULL, `lastVisitedModuleId` TEXT, `lastVisitedModulePath` TEXT, `lastVisitedBlockId` TEXT, `lastVisitedUnitDisplayName` TEXT, `futureAssignments` TEXT, `pastAssignments` TEXT, PRIMARY KEY(`courseId`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "auditAccessExpires", + "columnName": "auditAccessExpires", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "course.id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.start", + "columnName": "start", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.end", + "columnName": "end", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.dynamicUpgradeDeadline", + "columnName": "dynamicUpgradeDeadline", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.subscriptionId", + "columnName": "subscriptionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseImage", + "columnName": "course_image_link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseAbout", + "columnName": "courseAbout", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseUpdates", + "columnName": "courseUpdates", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseHandouts", + "columnName": "courseHandouts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.discussionUrl", + "columnName": "discussionUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.videoOutline", + "columnName": "videoOutline", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "course.coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER" + }, + { + "fieldPath": "course.coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT" + }, + { + "fieldPath": "course.coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "course.coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "course.coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "course.coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT" + }, + { + "fieldPath": "course.media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT" + }, + { + "fieldPath": "course.media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT" + }, + { + "fieldPath": "course.media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT" + }, + { + "fieldPath": "course.media.image", + "columnName": "image", + "affinity": "TEXT" + }, + { + "fieldPath": "course.courseSharingUtmParameters.facebook", + "columnName": "facebook", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseSharingUtmParameters.twitter", + "columnName": "twitter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT" + }, + { + "fieldPath": "progress.assignmentsCompleted", + "columnName": "assignments_completed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress.totalAssignmentsCount", + "columnName": "total_assignments_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseStatus.lastVisitedModuleId", + "columnName": "lastVisitedModuleId", + "affinity": "TEXT" + }, + { + "fieldPath": "courseStatus.lastVisitedModulePath", + "columnName": "lastVisitedModulePath", + "affinity": "TEXT" + }, + { + "fieldPath": "courseStatus.lastVisitedBlockId", + "columnName": "lastVisitedBlockId", + "affinity": "TEXT" + }, + { + "fieldPath": "courseStatus.lastVisitedUnitDisplayName", + "columnName": "lastVisitedUnitDisplayName", + "affinity": "TEXT" + }, + { + "fieldPath": "courseAssignments.futureAssignments", + "columnName": "futureAssignments", + "affinity": "TEXT" + }, + { + "fieldPath": "courseAssignments.pastAssignments", + "columnName": "pastAssignments", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "courseId" + ] + } + }, + { + "tableName": "course_structure_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`root` TEXT NOT NULL, `id` TEXT NOT NULL, `blocks` TEXT NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `start` TEXT, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `end` TEXT, `isSelfPaced` INTEGER NOT NULL, `hasAccess` INTEGER, `errorCode` TEXT, `developerMessage` TEXT, `userMessage` TEXT, `additionalContextUserMessage` TEXT, `userFragment` TEXT, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, `certificateURL` TEXT, `assignments_completed` INTEGER NOT NULL, `total_assignments_count` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "root", + "columnName": "root", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blocks", + "columnName": "blocks", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "TEXT" + }, + { + "fieldPath": "startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT" + }, + { + "fieldPath": "isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER" + }, + { + "fieldPath": "coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT" + }, + { + "fieldPath": "coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT" + }, + { + "fieldPath": "media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT" + }, + { + "fieldPath": "media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT" + }, + { + "fieldPath": "media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT" + }, + { + "fieldPath": "media.image", + "columnName": "image", + "affinity": "TEXT" + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT" + }, + { + "fieldPath": "progress.assignmentsCompleted", + "columnName": "assignments_completed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress.totalAssignmentsCount", + "columnName": "total_assignments_count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "download_model", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `courseId` TEXT NOT NULL, `size` INTEGER NOT NULL, `path` TEXT NOT NULL, `url` TEXT NOT NULL, `type` TEXT NOT NULL, `downloadedState` TEXT NOT NULL, `lastModified` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "downloadedState", + "columnName": "downloadedState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "offline_x_block_progress_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `courseId` TEXT NOT NULL, `url` TEXT NOT NULL, `type` TEXT NOT NULL, `data` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "blockId", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "course_calendar_event_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`event_id` INTEGER NOT NULL, `course_id` TEXT NOT NULL, PRIMARY KEY(`event_id`))", + "fields": [ + { + "fieldPath": "eventId", + "columnName": "event_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "course_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "event_id" + ] + } + }, + { + "tableName": "course_calendar_state_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`course_id` TEXT NOT NULL, `checksum` INTEGER NOT NULL, `is_course_sync_enabled` INTEGER NOT NULL, PRIMARY KEY(`course_id`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "course_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "checksum", + "columnName": "checksum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCourseSyncEnabled", + "columnName": "is_course_sync_enabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "course_id" + ] + } + }, + { + "tableName": "download_course_preview_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`course_id` TEXT NOT NULL, `course_name` TEXT, `course_image` TEXT, `total_size` INTEGER, PRIMARY KEY(`course_id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "course_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "course_name", + "affinity": "TEXT" + }, + { + "fieldPath": "image", + "columnName": "course_image", + "affinity": "TEXT" + }, + { + "fieldPath": "totalSize", + "columnName": "total_size", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "course_id" + ] + } + }, + { + "tableName": "course_enrollment_details_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `courseUpdates` TEXT NOT NULL, `courseHandouts` TEXT NOT NULL, `discussionUrl` TEXT NOT NULL, `hasUnmetPrerequisites` INTEGER NOT NULL, `isTooEarly` INTEGER NOT NULL, `isStaff` INTEGER NOT NULL, `auditAccessExpires` TEXT, `hasAccess` INTEGER, `errorCode` TEXT, `developerMessage` TEXT, `userMessage` TEXT, `additionalContextUserMessage` TEXT, `userFragment` TEXT, `certificateURL` TEXT, `created` TEXT, `mode` TEXT, `isActive` INTEGER NOT NULL, `upgradeDeadline` TEXT, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `start` INTEGER, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `end` INTEGER, `isSelfPaced` INTEGER NOT NULL, `courseAbout` TEXT NOT NULL, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, `facebook` TEXT NOT NULL, `twitter` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseUpdates", + "columnName": "courseUpdates", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseHandouts", + "columnName": "courseHandouts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "discussionUrl", + "columnName": "discussionUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.hasUnmetPrerequisites", + "columnName": "hasUnmetPrerequisites", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.isTooEarly", + "columnName": "isTooEarly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.isStaff", + "columnName": "isStaff", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.auditAccessExpires", + "columnName": "auditAccessExpires", + "affinity": "TEXT" + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER" + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT" + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT" + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT" + }, + { + "fieldPath": "enrollmentDetails.created", + "columnName": "created", + "affinity": "TEXT" + }, + { + "fieldPath": "enrollmentDetails.mode", + "columnName": "mode", + "affinity": "TEXT" + }, + { + "fieldPath": "enrollmentDetails.isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enrollmentDetails.upgradeDeadline", + "columnName": "upgradeDeadline", + "affinity": "TEXT" + }, + { + "fieldPath": "courseInfoOverview.name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.start", + "columnName": "start", + "affinity": "INTEGER" + }, + { + "fieldPath": "courseInfoOverview.startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.end", + "columnName": "end", + "affinity": "INTEGER" + }, + { + "fieldPath": "courseInfoOverview.isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.courseAbout", + "columnName": "courseAbout", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT" + }, + { + "fieldPath": "courseInfoOverview.media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT" + }, + { + "fieldPath": "courseInfoOverview.media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT" + }, + { + "fieldPath": "courseInfoOverview.media.image", + "columnName": "image", + "affinity": "TEXT" + }, + { + "fieldPath": "courseInfoOverview.courseSharingUtmParameters.facebook", + "columnName": "facebook", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.courseSharingUtmParameters.twitter", + "columnName": "twitter", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "video_progress_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`block_id` TEXT NOT NULL, `video_url` TEXT NOT NULL, `video_time` INTEGER, `duration` INTEGER, PRIMARY KEY(`block_id`))", + "fields": [ + { + "fieldPath": "blockId", + "columnName": "block_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoUrl", + "columnName": "video_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoTime", + "columnName": "video_time", + "affinity": "INTEGER" + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "block_id" + ] + } + }, + { + "tableName": "course_progress_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`courseId` TEXT NOT NULL, `verifiedMode` TEXT NOT NULL, `accessExpiration` TEXT NOT NULL, `creditCourseRequirements` TEXT NOT NULL, `end` TEXT NOT NULL, `enrollmentMode` TEXT NOT NULL, `hasScheduledContent` INTEGER NOT NULL, `sectionScores` TEXT NOT NULL, `studioUrl` TEXT NOT NULL, `username` TEXT NOT NULL, `userHasPassingGrade` INTEGER NOT NULL, `disableProgressGraph` INTEGER NOT NULL, `certificate_certStatus` TEXT, `certificate_certWebViewUrl` TEXT, `certificate_downloadUrl` TEXT, `certificate_certificateAvailableDate` TEXT, `completion_completeCount` INTEGER, `completion_incompleteCount` INTEGER, `completion_lockedCount` INTEGER, `grade_letterGrade` TEXT, `grade_percent` REAL, `grade_isPassing` INTEGER, `grading_assignmentPolicies` TEXT, `grading_gradeRange` TEXT, `grading_assignmentColors` TEXT, `verification_link` TEXT, `verification_status` TEXT, `verification_statusDate` TEXT, PRIMARY KEY(`courseId`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verifiedMode", + "columnName": "verifiedMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessExpiration", + "columnName": "accessExpiration", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creditCourseRequirements", + "columnName": "creditCourseRequirements", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enrollmentMode", + "columnName": "enrollmentMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasScheduledContent", + "columnName": "hasScheduledContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sectionScores", + "columnName": "sectionScores", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "studioUrl", + "columnName": "studioUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userHasPassingGrade", + "columnName": "userHasPassingGrade", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "disableProgressGraph", + "columnName": "disableProgressGraph", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "certificateData.certStatus", + "columnName": "certificate_certStatus", + "affinity": "TEXT" + }, + { + "fieldPath": "certificateData.certWebViewUrl", + "columnName": "certificate_certWebViewUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "certificateData.downloadUrl", + "columnName": "certificate_downloadUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "certificateData.certificateAvailableDate", + "columnName": "certificate_certificateAvailableDate", + "affinity": "TEXT" + }, + { + "fieldPath": "completionSummary.completeCount", + "columnName": "completion_completeCount", + "affinity": "INTEGER" + }, + { + "fieldPath": "completionSummary.incompleteCount", + "columnName": "completion_incompleteCount", + "affinity": "INTEGER" + }, + { + "fieldPath": "completionSummary.lockedCount", + "columnName": "completion_lockedCount", + "affinity": "INTEGER" + }, + { + "fieldPath": "courseGrade.letterGrade", + "columnName": "grade_letterGrade", + "affinity": "TEXT" + }, + { + "fieldPath": "courseGrade.percent", + "columnName": "grade_percent", + "affinity": "REAL" + }, + { + "fieldPath": "courseGrade.isPassing", + "columnName": "grade_isPassing", + "affinity": "INTEGER" + }, + { + "fieldPath": "gradingPolicy.assignmentPolicies", + "columnName": "grading_assignmentPolicies", + "affinity": "TEXT" + }, + { + "fieldPath": "gradingPolicy.gradeRange", + "columnName": "grading_gradeRange", + "affinity": "TEXT" + }, + { + "fieldPath": "gradingPolicy.assignmentColors", + "columnName": "grading_assignmentColors", + "affinity": "TEXT" + }, + { + "fieldPath": "verificationData.link", + "columnName": "verification_link", + "affinity": "TEXT" + }, + { + "fieldPath": "verificationData.status", + "columnName": "verification_status", + "affinity": "TEXT" + }, + { + "fieldPath": "verificationData.statusDate", + "columnName": "verification_statusDate", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "courseId" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '09f6fc49a2f7a494d27f3290d7bae350')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8020f6b74..65c64e538 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,6 +6,8 @@ + + @@ -39,12 +41,19 @@ android:exported="true" android:fitsSystemWindows="true" android:theme="@style/Theme.App.Starting" - android:windowSoftInputMode="adjustPan"> + android:windowSoftInputMode="adjustPan" + android:launchMode="singleInstance"> + + + + + + @@ -102,15 +111,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..6c29cdf12 100644 --- a/app/src/main/java/org/openedx/app/AnalyticsManager.kt +++ b/app/src/main/java/org/openedx/app/AnalyticsManager.kt @@ -1,73 +1,80 @@ 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.DownloadsAnalytics 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, + DownloadsAnalytics { - 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) } } - override fun dashboardCourseClickedEvent(courseId: String, courseName: String) { - logEvent(Event.DASHBOARD_COURSE_CLICKED, buildMap { - put(Key.COURSE_ID.keyName, courseId) - put(Key.COURSE_NAME.keyName, courseName) - }) + override fun dashboardCourseClickedEvent( + courseId: String, + courseName: String + ) { + logEvent( + Event.DASHBOARD_COURSE_CLICKED, + buildMap { + put(Key.COURSE_ID.keyName, courseId) + put(Key.COURSE_NAME.keyName, courseName) + } + ) } override fun logoutEvent(force: Boolean) { - logEvent(Event.USER_LOGOUT, buildMap { - put(Key.FORCE.keyName, force) - }) + logEvent( + Event.USER_LOGOUT, + buildMap { + put(Key.FORCE.keyName, force) + } + ) } override fun setUserIdForSession(userId: Long) { @@ -79,104 +86,164 @@ class AnalyticsManager( } override fun discoveryCourseSearchEvent(label: String, coursesCount: Int) { - logEvent(Event.DISCOVERY_COURSE_SEARCH, buildMap { - put(Key.LABEL.keyName, label) - put(Key.COURSE_COUNT.keyName, coursesCount) - }) + logEvent( + Event.DISCOVERY_COURSE_SEARCH, + buildMap { + put(Key.LABEL.keyName, label) + put(Key.COURSE_COUNT.keyName, coursesCount) + } + ) } override fun discoveryCourseClickedEvent(courseId: String, courseName: String) { - logEvent(Event.DISCOVERY_COURSE_CLICKED, buildMap { - put(Key.COURSE_ID.keyName, courseId) - put(Key.COURSE_NAME.keyName, courseName) - }) + logEvent( + Event.DISCOVERY_COURSE_CLICKED, + buildMap { + put(Key.COURSE_ID.keyName, courseId) + put(Key.COURSE_NAME.keyName, courseName) + } + ) } override fun sequentialClickedEvent( - courseId: String, courseName: String, blockId: String, blockName: String, + courseId: String, + courseName: String, + blockId: String, + blockName: String, ) { - logEvent(Event.SEQUENTIAL_CLICKED, buildMap { - put(Key.COURSE_ID.keyName, courseId) - put(Key.COURSE_NAME.keyName, courseName) - put(Key.BLOCK_ID.keyName, blockId) - put(Key.BLOCK_NAME.keyName, blockName) - }) + logEvent( + Event.SEQUENTIAL_CLICKED, + buildMap { + put(Key.COURSE_ID.keyName, courseId) + put(Key.COURSE_NAME.keyName, courseName) + put(Key.BLOCK_ID.keyName, blockId) + put(Key.BLOCK_NAME.keyName, blockName) + } + ) } override fun nextBlockClickedEvent( - courseId: String, courseName: String, blockId: String, blockName: String, + courseId: String, + courseName: String, + blockId: String, + blockName: String, ) { - logEvent(Event.NEXT_BLOCK_CLICKED, buildMap { - put(Key.COURSE_ID.keyName, courseId) - put(Key.COURSE_NAME.keyName, courseName) - put(Key.BLOCK_ID.keyName, blockId) - put(Key.BLOCK_NAME.keyName, blockName) - }) + logEvent( + Event.NEXT_BLOCK_CLICKED, + buildMap { + put(Key.COURSE_ID.keyName, courseId) + put(Key.COURSE_NAME.keyName, courseName) + put(Key.BLOCK_ID.keyName, blockId) + put(Key.BLOCK_NAME.keyName, blockName) + } + ) } override fun prevBlockClickedEvent( - courseId: String, courseName: String, blockId: String, blockName: String, + courseId: String, + courseName: String, + blockId: String, + blockName: String, ) { - logEvent(Event.PREV_BLOCK_CLICKED, buildMap { - put(Key.COURSE_ID.keyName, courseId) - put(Key.COURSE_NAME.keyName, courseName) - put(Key.BLOCK_ID.keyName, blockId) - put(Key.BLOCK_NAME.keyName, blockName) - }) + logEvent( + Event.PREV_BLOCK_CLICKED, + buildMap { + put(Key.COURSE_ID.keyName, courseId) + put(Key.COURSE_NAME.keyName, courseName) + put(Key.BLOCK_ID.keyName, blockId) + put(Key.BLOCK_NAME.keyName, blockName) + } + ) } override fun finishVerticalClickedEvent( - courseId: String, courseName: String, blockId: String, blockName: String, + courseId: String, + courseName: String, + blockId: String, + blockName: String, ) { - logEvent(Event.FINISH_VERTICAL_CLICKED, buildMap { - put(Key.COURSE_ID.keyName, courseId) - put(Key.COURSE_NAME.keyName, courseName) - put(Key.BLOCK_ID.keyName, blockId) - put(Key.BLOCK_NAME.keyName, blockName) - }) + logEvent( + Event.FINISH_VERTICAL_CLICKED, + buildMap { + put(Key.COURSE_ID.keyName, courseId) + put(Key.COURSE_NAME.keyName, courseName) + put(Key.BLOCK_ID.keyName, blockId) + put(Key.BLOCK_NAME.keyName, blockName) + } + ) } override fun finishVerticalNextClickedEvent( - courseId: String, courseName: String, blockId: String, blockName: String, + courseId: String, + courseName: String, + blockId: String, + blockName: String, ) { - logEvent(Event.FINISH_VERTICAL_NEXT_CLICKED, buildMap { - put(Key.COURSE_ID.keyName, courseId) - put(Key.COURSE_NAME.keyName, courseName) - put(Key.BLOCK_ID.keyName, blockId) - put(Key.BLOCK_NAME.keyName, blockName) - }) + logEvent( + Event.FINISH_VERTICAL_NEXT_CLICKED, + buildMap { + put(Key.COURSE_ID.keyName, courseId) + put(Key.COURSE_NAME.keyName, courseName) + put(Key.BLOCK_ID.keyName, blockId) + put(Key.BLOCK_NAME.keyName, blockName) + } + ) } - override fun finishVerticalBackClickedEvent(courseId: String, courseName: String) { - logEvent(Event.FINISH_VERTICAL_BACK_CLICKED, buildMap { - put(Key.COURSE_ID.keyName, courseId) - put(Key.COURSE_NAME.keyName, courseName) - }) + override fun finishVerticalBackClickedEvent( + courseId: String, + courseName: String + ) { + logEvent( + Event.FINISH_VERTICAL_BACK_CLICKED, + buildMap { + put(Key.COURSE_ID.keyName, courseId) + put(Key.COURSE_NAME.keyName, courseName) + } + ) } - override fun discussionAllPostsClickedEvent(courseId: String, courseName: String) { - logEvent(Event.DISCUSSION_ALL_POSTS_CLICKED, buildMap { - put(Key.COURSE_ID.keyName, courseId) - put(Key.COURSE_NAME.keyName, courseName) - }) + override fun discussionAllPostsClickedEvent( + courseId: String, + courseName: String + ) { + logEvent( + Event.DISCUSSION_ALL_POSTS_CLICKED, + buildMap { + put(Key.COURSE_ID.keyName, courseId) + put(Key.COURSE_NAME.keyName, courseName) + } + ) } - override fun discussionFollowingClickedEvent(courseId: String, courseName: String) { - logEvent(Event.DISCUSSION_FOLLOWING_CLICKED, buildMap { - put(Key.COURSE_ID.keyName, courseId) - put(Key.COURSE_NAME.keyName, courseName) - }) + override fun discussionFollowingClickedEvent( + courseId: String, + courseName: String + ) { + logEvent( + Event.DISCUSSION_FOLLOWING_CLICKED, + buildMap { + put(Key.COURSE_ID.keyName, courseId) + put(Key.COURSE_NAME.keyName, courseName) + } + ) } override fun discussionTopicClickedEvent( - courseId: String, courseName: String, topicId: String, topicName: String, + courseId: String, + courseName: String, + topicId: String, + topicName: String, ) { - logEvent(Event.DISCUSSION_TOPIC_CLICKED, buildMap { - put(Key.COURSE_ID.keyName, courseId) - put(Key.COURSE_NAME.keyName, courseName) - put(Key.TOPIC_ID.keyName, topicId) - put(Key.TOPIC_NAME.keyName, topicName) - }) + logEvent( + Event.DISCUSSION_TOPIC_CLICKED, + buildMap { + put(Key.COURSE_ID.keyName, courseId) + put(Key.COURSE_NAME.keyName, courseName) + put(Key.TOPIC_ID.keyName, topicId) + put(Key.TOPIC_NAME.keyName, topicName) + } + ) } } diff --git a/app/src/main/java/org/openedx/app/AppActivity.kt b/app/src/main/java/org/openedx/app/AppActivity.kt index 5ab0d0b0e..b904bf6a1 100644 --- a/app/src/main/java/org/openedx/app/AppActivity.kt +++ b/app/src/main/java/org/openedx/app/AppActivity.kt @@ -3,6 +3,8 @@ package org.openedx.app import android.content.Intent import android.content.res.Configuration import android.graphics.Color +import android.net.Uri +import android.os.Build import android.os.Bundle import android.view.View import android.view.WindowManager @@ -12,21 +14,28 @@ 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.ApiConstants import org.openedx.core.data.storage.CorePreferences -import org.openedx.core.extension.requestApplyInsetsWhenAttached +import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager 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.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 +57,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) @@ -56,6 +67,32 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { private var _insetCutout = 0 private var _windowSize = WindowSize(WindowType.Compact, WindowType.Compact) + private val authCode: String? + get() { + val data = intent?.data + if ( + data is Uri && + data.scheme == BuildConfig.APPLICATION_ID && + data.host == ApiConstants.BrowserLogin.REDIRECT_HOST + ) { + return data.getQueryParameter(ApiConstants.BrowserLogin.CODE_QUERY_PARAM) + } + return null + } + + 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) @@ -71,8 +108,18 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { lifecycle.addObserver(viewModel) viewModel.logAppLaunchEvent() setContentView(binding.root) - val container = binding.rootLayout + setupWindowInsets(savedInstanceState) + setupWindowSettings() + setupInitialFragment(savedInstanceState) + observeLogoutEvent() + observeDownloadFailedDialog() + + calendarSyncScheduler.scheduleDailySync() + } + + private fun setupWindowInsets(savedInstanceState: Bundle?) { + val container = binding.rootLayout container.addView(object : View(this) { override fun onConfigurationChanged(newConfig: Configuration?) { super.onConfigurationChanged(newConfig) @@ -81,20 +128,10 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { }) computeWindowSizeClasses() - if (savedInstanceState != null) { - _insetTop = savedInstanceState.getInt(TOP_INSET, 0) - _insetBottom = savedInstanceState.getInt(BOTTOM_INSET, 0) - _insetCutout = savedInstanceState.getInt(CUTOUT_INSET, 0) - } - - window.apply { - addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) - - WindowCompat.setDecorFitsSystemWindows(this, false) - - val insetsController = WindowInsetsControllerCompat(this, binding.root) - insetsController.isAppearanceLightStatusBars = !isUsingNightModeResources() - statusBarColor = Color.TRANSPARENT + savedInstanceState?.let { + _insetTop = it.getInt(TOP_INSET, 0) + _insetBottom = it.getInt(BOTTOM_INSET, 0) + _insetCutout = it.getInt(CUTOUT_INSET, 0) } binding.root.setOnApplyWindowInsetsListener { _, insets -> @@ -115,65 +152,91 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { insets } binding.root.requestApplyInsetsWhenAttached() + } + + private fun setupWindowSettings() { + window.apply { + addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) + WindowCompat.setDecorFitsSystemWindows(this, false) + val insetsController = WindowInsetsControllerCompat(this, binding.root) + insetsController.isAppearanceLightStatusBars = !isUsingNightModeResources() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + insetsController.systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } else { + window.statusBarColor = Color.TRANSPARENT + } + } + } + private fun setupInitialFragment(savedInstanceState: Bundle?) { if (savedInstanceState == null) { when { corePreferencesManager.user == null -> { - if (viewModel.isLogistrationEnabled) { - addFragment(LogistrationFragment()) + val fragment = if (viewModel.isLogistrationEnabled && authCode == null) { + LogistrationFragment() } else { - addFragment(SignInFragment()) + SignInFragment.newInstance(null, null, authCode = authCode) } + addFragment(fragment) } - whatsNewManager.shouldShowWhatsNew() -> { - addFragment(WhatsNewFragment.newInstance()) - } + whatsNewManager.shouldShowWhatsNew() -> addFragment(WhatsNewFragment.newInstance()) + else -> addFragment(MainFragment.newInstance()) + } - corePreferencesManager.user != null -> { - addFragment(MainFragment.newInstance()) - } + intent.extras?.takeIf { it.containsKey(DeepLink.Keys.NOTIFICATION_TYPE.value) }?.let { + handlePushNotification(it) } } + } + private fun observeLogoutEvent() { viewModel.logoutUser.observe(this) { profileRouter.restartApp(supportFragmentManager, viewModel.isLogistrationEnabled) } } + private fun observeDownloadFailedDialog() { + lifecycleScope.launch { + viewModel.downloadFailedDialog.collect { + downloadDialogManager.showDownloadFailedPopup( + downloadModel = it.downloadModel, + fragmentManager = supportFragmentManager, + ) + } + } + } + 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() } } - override fun onNewIntent(intent: Intent?) { + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) this.intent = intent + if (authCode != null) { + addFragment(SignInFragment.newInstance(null, null, authCode = authCode)) + } + + 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() + if (intent.getBooleanExtra(BRANCH_FORCE_NEW_SESSION, false)) { + Branch.sessionBuilder(this) + .withCallback(branchCallback) + .reInit() } } } @@ -190,15 +253,15 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { val widthDp = metrics.bounds.width() / resources.displayMetrics.density val widthWindowSize = when { - widthDp < 600f -> WindowType.Compact - widthDp < 840f -> WindowType.Medium + widthDp < COMPACT_MAX_WIDTH -> WindowType.Compact + widthDp < MEDIUM_MAX_WIDTH -> WindowType.Medium else -> WindowType.Expanded } val heightDp = metrics.bounds.height() / resources.displayMetrics.density val heightWindowSize = when { - heightDp < 480f -> WindowType.Compact - heightDp < 900f -> WindowType.Medium + heightDp < COMPACT_MAX_HEIGHT -> WindowType.Compact + heightDp < MEDIUM_MAX_HEIGHT -> WindowType.Medium else -> WindowType.Expanded } _windowSize = WindowSize(widthWindowSize, heightWindowSize) @@ -213,11 +276,21 @@ 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" const val CUTOUT_INSET = "cutoutInset" const val BRANCH_TAG = "Branch" const val BRANCH_FORCE_NEW_SESSION = "branch_force_new_session" + + internal const val COMPACT_MAX_WIDTH = 600 + internal const val MEDIUM_MAX_WIDTH = 840 + internal const val COMPACT_MAX_HEIGHT = 480 + internal const val MEDIUM_MAX_HEIGHT = 900 } } diff --git a/app/src/main/java/org/openedx/app/AppAnalytics.kt b/app/src/main/java/org/openedx/app/AppAnalytics.kt index 51278ef13..55b26b492 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) { @@ -11,17 +12,17 @@ enum class AppAnalyticsEvent(val eventName: String, val biValue: String) { "Launch", "edx.bi.app.launch" ), + LEARN( + "MainDashboard:Learn", + "edx.bi.app.main_dashboard.learn" + ), DISCOVER( "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" + DOWNLOADS( + "MainDashboard:Downloads", + "edx.bi.app.main_dashboard.downloads" ), PROFILE( "MainDashboard: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..4678344ee 100644 --- a/app/src/main/java/org/openedx/app/AppRouter.kt +++ b/app/src/main/java/org/openedx/app/AppRouter.kt @@ -3,18 +3,19 @@ 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.appupgrade.AppUpgradeRouter +import org.openedx.core.presentation.global.appupgrade.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 @@ -22,9 +23,11 @@ import org.openedx.course.presentation.handouts.HandoutsType import org.openedx.course.presentation.handouts.HandoutsWebViewFragment import org.openedx.course.presentation.section.CourseSectionFragment import org.openedx.course.presentation.unit.container.CourseUnitContainerFragment +import org.openedx.course.presentation.unit.container.CourseViewMode 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 @@ -41,9 +44,12 @@ import org.openedx.discussion.presentation.responses.DiscussionResponsesFragment import org.openedx.discussion.presentation.search.DiscussionSearchThreadFragment import org.openedx.discussion.presentation.threads.DiscussionAddThreadFragment import org.openedx.discussion.presentation.threads.DiscussionThreadsFragment +import org.openedx.downloads.presentation.DownloadsRouter 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 @@ -53,15 +59,33 @@ import org.openedx.profile.presentation.video.VideoSettingsFragment import org.openedx.whatsnew.WhatsNewRouter import org.openedx.whatsnew.presentation.whatsnew.WhatsNewFragment -class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, DiscussionRouter, - ProfileRouter, AppUpgradeRouter, WhatsNewRouter { - - //region AuthRouter - override fun navigateToMain(fm: FragmentManager, courseId: String?, infoType: String?) { - fm.popBackStack() - fm.beginTransaction() - .replace(R.id.container, MainFragment.newInstance(courseId, infoType)) - .commit() +class AppRouter : + AuthRouter, + DiscoveryRouter, + DashboardRouter, + CourseRouter, + DiscussionRouter, + ProfileRouter, + AppUpgradeRouter, + WhatsNewRouter, + CalendarRouter, + DownloadsRouter { + + // region AuthRouter + 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,23 +117,31 @@ 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 + // endregion - //region DiscoveryRouter + // region DiscoveryRouter override fun navigateToCourseDetail(fm: FragmentManager, courseId: String) { replaceFragmentWithBackStack(fm, CourseDetailsFragment.newInstance(courseId)) } @@ -122,6 +154,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,35 +169,55 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di ) { replaceFragmentWithBackStack(fm, CourseInfoFragment.newInstance(courseId, infoType)) } - //endregion - //region DashboardRouter + override fun navigateToCourseOutline( + fm: FragmentManager, + courseId: String, + courseTitle: String, + ) { + replaceFragmentWithBackStack( + fm, + CourseContainerFragment.newInstance(courseId, courseTitle) + ) + } + // endregion + + // region DashboardRouter override fun navigateToCourseOutline( 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)) } - //endregion + // endregion - //region CourseRouter + // region CourseRouter override fun navigateToCourseSubsections( fm: FragmentManager, @@ -165,7 +225,7 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di subSectionId: String, unitId: String, componentId: String, - mode: CourseViewMode + mode: CourseViewMode, ) { replaceFragmentWithBackStack( fm, @@ -184,7 +244,7 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di courseId: String, unitId: String, componentId: String, - mode: CourseViewMode + mode: CourseViewMode, ) { replaceFragmentWithBackStack( fm, @@ -202,7 +262,7 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di courseId: String, unitId: String, componentId: String, - mode: CourseViewMode + mode: CourseViewMode, ) { replaceFragment( fm, @@ -222,7 +282,7 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di videoTime: Long, blockId: String, courseId: String, - isPlaying: Boolean + isPlaying: Boolean, ) { replaceFragmentWithBackStack( fm, @@ -236,7 +296,7 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di videoTime: Long, blockId: String, courseId: String, - isPlaying: Boolean + isPlaying: Boolean, ) { replaceFragmentWithBackStack( fm, @@ -253,24 +313,23 @@ 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 + // endregion - //region DiscussionRouter + // region DiscussionRouter override fun navigateToDiscussionThread( fm: FragmentManager, action: String, courseId: String, topicId: String, title: String, - viewType: FragmentViewType + viewType: FragmentViewType, ) { replaceFragmentWithBackStack( fm, @@ -288,7 +347,7 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di override fun navigateToDiscussionResponses( fm: FragmentManager, comment: DiscussionComment, - isClosed: Boolean + isClosed: Boolean, ) { replaceFragmentWithBackStack( fm, @@ -316,16 +375,16 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di override fun navigateToAnothersProfile( fm: FragmentManager, - username: String + username: String, ) { replaceFragmentWithBackStack( fm, AnothersProfileFragment.newInstance(username) ) } - //endregion + // endregion - //region ProfileRouter + // region ProfileRouter override fun navigateToEditProfile(fm: FragmentManager, account: Account) { replaceFragmentWithBackStack(fm, EditProfileFragment.newInstance(account)) } @@ -360,6 +419,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 +435,56 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di override fun navigateToManageAccount(fm: FragmentManager) { replaceFragmentWithBackStack(fm, ManageAccountFragment()) } - //endregion + + 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 + // 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 + // 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..e195a7940 100644 --- a/app/src/main/java/org/openedx/app/AppViewModel.kt +++ b/app/src/main/java/org/openedx/app/AppViewModel.kt @@ -1,51 +1,93 @@ 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 +102,43 @@ 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 > LOGOUT_EVENT_THRESHOLD) { + 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() + } + } + } + + companion object { + private const val LOGOUT_EVENT_THRESHOLD = 5000L + } } 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..82092e439 100644 --- a/app/src/main/java/org/openedx/app/MainFragment.kt +++ b/app/src/main/java/org/openedx/app/MainFragment.kt @@ -1,7 +1,13 @@ package org.openedx.app import android.os.Bundle +import android.view.Menu import android.view.View +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier import androidx.core.os.bundleOf import androidx.core.view.forEach import androidx.fragment.app.Fragment @@ -11,15 +17,20 @@ 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.core.presentation.global.app_upgrade.UpgradeRequiredFragment +import org.openedx.app.deeplink.HomeTab +import org.openedx.core.AppUpdateState +import org.openedx.core.AppUpdateState.wasUpgradeDialogClosed +import org.openedx.core.adapter.NavigationFragmentAdapter +import org.openedx.core.presentation.dialog.appupgrade.AppUpgradeDialogFragment +import org.openedx.core.presentation.global.appupgrade.AppUpgradeRecommendedBox +import org.openedx.core.presentation.global.appupgrade.UpgradeRequiredFragment import org.openedx.core.presentation.global.viewBinding -import org.openedx.dashboard.presentation.DashboardFragment -import org.openedx.discovery.presentation.DiscoveryNavigator +import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.discovery.presentation.DiscoveryRouter -import org.openedx.discovery.presentation.program.ProgramFragment +import org.openedx.downloads.presentation.download.DownloadsFragment +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 +38,6 @@ 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 override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -42,36 +50,107 @@ class MainFragment : Fragment(R.layout.fragment_main) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + handleArguments() + setupBottomNavigation() + setupViewPager() + setupBottomPopup() + observeViewModel() + } - initViewPager() - - binding.bottomNavView.setOnItemSelectedListener { - when (it.itemId) { - R.id.fragmentHome -> { - viewModel.logDiscoveryTabClickedEvent() - binding.viewPager.setCurrentItem(0, false) + private fun handleArguments() { + requireArguments().apply { + getString(ARG_COURSE_ID).takeIf { it.isNullOrBlank().not() }?.let { courseId -> + val infoType = getString(ARG_INFO_TYPE) + if (viewModel.isDiscoveryTypeWebView && infoType != null) { + router.navigateToCourseInfo(parentFragmentManager, courseId, infoType) + } else { + router.navigateToCourseDetail(parentFragmentManager, courseId) } + putString(ARG_COURSE_ID, "") + putString(ARG_INFO_TYPE, "") + } + } + } - R.id.fragmentDashboard -> { - viewModel.logMyCoursesTabClickedEvent() - binding.viewPager.setCurrentItem(1, false) - } + private fun setupBottomNavigation() { + val openTabArg = requireArguments().getString(ARG_OPEN_TAB, HomeTab.LEARN.name) + val initialMenuId = getInitialMenuId(openTabArg) + binding.bottomNavView.selectedItemId = initialMenuId - R.id.fragmentPrograms -> { - viewModel.logMyProgramsTabClickedEvent() - binding.viewPager.setCurrentItem(2, false) - } + val menu = binding.bottomNavView.menu + menu.clear() - R.id.fragmentProfile -> { - viewModel.logProfileTabClickedEvent() - binding.viewPager.setCurrentItem(3, false) + val tabList = createTabList(openTabArg) + addMenuItems(menu, tabList) + setupBottomNavListener(tabList) + + requireArguments().remove(ARG_OPEN_TAB) + } + + private fun createTabList(openTabArg: String): List Fragment>> { + val learnFragmentFactory = { + LearnFragment.newInstance( + openTab = if (openTabArg == HomeTab.PROGRAMS.name) { + LearnTab.PROGRAMS.name + } else { + LearnTab.COURSES.name } + ) + } + + return mutableListOf Fragment>>().apply { + add(R.id.fragmentLearn to learnFragmentFactory) + add(R.id.fragmentDiscover to { viewModel.getDiscoveryFragment }) + if (viewModel.isDownloadsFragmentEnabled) { + add(R.id.fragmentDownloads to { DownloadsFragment() }) + } + add(R.id.fragmentProfile to { ProfileFragment() }) + } + } + + private fun addMenuItems(menu: Menu, tabList: List Fragment>>) { + val tabTitles = mapOf( + R.id.fragmentLearn to resources.getString(R.string.app_navigation_learn), + R.id.fragmentDiscover to resources.getString(R.string.app_navigation_discovery), + R.id.fragmentDownloads to resources.getString(R.string.app_navigation_downloads), + R.id.fragmentProfile to resources.getString(R.string.app_navigation_profile), + ) + val tabIconSelectors = mapOf( + R.id.fragmentLearn to R.drawable.app_ic_learn_selector, + R.id.fragmentDiscover to R.drawable.app_ic_discover_selector, + R.id.fragmentDownloads to R.drawable.app_ic_downloads_selector, + R.id.fragmentProfile to R.drawable.app_ic_profile_selector + ) + + for ((id, _) in tabList) { + val menuItem = menu.add(Menu.NONE, id, Menu.NONE, tabTitles[id] ?: "") + tabIconSelectors[id]?.let { menuItem.setIcon(it) } + } + } + + private fun setupBottomNavListener(tabList: List Fragment>>) { + val menuIdToIndex = tabList.mapIndexed { index, pair -> pair.first to index }.toMap() + + binding.bottomNavView.setOnItemSelectedListener { menuItem -> + when (menuItem.itemId) { + R.id.fragmentLearn -> viewModel.logLearnTabClickedEvent() + R.id.fragmentDiscover -> viewModel.logDiscoveryTabClickedEvent() + R.id.fragmentDownloads -> viewModel.logDownloadsTabClickedEvent() + R.id.fragmentProfile -> viewModel.logProfileTabClickedEvent() + } + menuIdToIndex[menuItem.itemId]?.let { index -> + binding.viewPager.setCurrentItem(index, false) } true } - // Trigger click event for the first tab on initial load - binding.bottomNavView.selectedItemId = binding.bottomNavView.selectedItemId + } + + private fun setupViewPager() { + val tabList = createTabList(requireArguments().getString(ARG_OPEN_TAB, HomeTab.LEARN.name)) + initViewPager(tabList) + } + private fun observeViewModel() { viewModel.isBottomBarEnabled.observe(viewLifecycleOwner) { isBottomBarEnabled -> enableBottomBar(isBottomBarEnabled) } @@ -79,47 +158,36 @@ 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 } } } + } - requireArguments().apply { - getString(ARG_COURSE_ID).takeIf { it.isNullOrBlank().not() }?.let { courseId -> - val infoType = getString(ARG_INFO_TYPE) - - if (config.getDiscoveryConfig().isViewTypeWebView() && infoType != null) { - router.navigateToCourseInfo(parentFragmentManager, courseId, infoType) - } else { - router.navigateToCourseDetail(parentFragmentManager, courseId) - } - - // Clear arguments after navigation - putString(ARG_COURSE_ID, "") - putString(ARG_INFO_TYPE, "") + private fun getInitialMenuId(openTabArg: String): Int { + return when (openTabArg) { + HomeTab.LEARN.name, HomeTab.PROGRAMS.name -> R.id.fragmentLearn + HomeTab.DISCOVER.name -> R.id.fragmentDiscover + HomeTab.DOWNLOADS.name -> if (viewModel.isDownloadsFragmentEnabled) { + R.id.fragmentDownloads + } else { + R.id.fragmentLearn } + + HomeTab.PROFILE.name -> R.id.fragmentProfile + else -> R.id.fragmentLearn } } - private fun initViewPager() { + private fun initViewPager(tabList: List Fragment>>) { binding.viewPager.orientation = ViewPager2.ORIENTATION_HORIZONTAL - binding.viewPager.offscreenPageLimit = 4 - - val discoveryFragment = DiscoveryNavigator(viewModel.isDiscoveryTypeWebView) - .getDiscoveryFragment() - val programFragment = if (viewModel.isProgramTypeWebView) { - ProgramFragment(true) - } else { - InDevelopmentFragment() - } - - adapter = MainNavigationFragmentAdapter(this).apply { - addFragment(discoveryFragment) - addFragment(DashboardFragment()) - addFragment(programFragment) - addFragment(ProfileFragment()) + binding.viewPager.offscreenPageLimit = tabList.size + binding.viewPager.adapter = NavigationFragmentAdapter(this).apply { + tabList.forEach { (_, fragmentFactory) -> + // Use fragment factory to prevent memory leaks + addFragment { fragmentFactory() } + } } - binding.viewPager.adapter = adapter binding.viewPager.isUserInputEnabled = false } @@ -129,14 +197,70 @@ class MainFragment : Fragment(R.layout.fragment_main) { } } + private fun setupBottomPopup() { + binding.composeBottomPopup.setContent { + val appUpgradeEvent by viewModel.appUpgradeEvent.observeAsState() + val wasUpgradeDialogClosed by remember { wasUpgradeDialogClosed } + val appUpgradeParameters = AppUpdateState.AppUpgradeParameters( + appUpgradeEvent = appUpgradeEvent, + wasUpgradeDialogClosed = wasUpgradeDialogClosed, + appUpgradeRecommendedDialog = { + val dialog = AppUpgradeDialogFragment.newInstance() + dialog.show( + requireActivity().supportFragmentManager, + AppUpgradeDialogFragment::class.simpleName + ) + }, + onAppUpgradeRecommendedBoxClick = { + AppUpdateState.openPlayMarket(requireContext()) + }, + onAppUpgradeRequired = { + router.navigateToUpgradeRequired( + requireActivity().supportFragmentManager + ) + } + ) + when (appUpgradeParameters.appUpgradeEvent) { + is AppUpgradeEvent.UpgradeRecommendedEvent -> { + if (appUpgradeParameters.wasUpgradeDialogClosed) { + AppUpgradeRecommendedBox( + modifier = Modifier.fillMaxWidth(), + onClick = appUpgradeParameters.onAppUpgradeRecommendedBoxClick + ) + } else { + if (!AppUpdateState.wasUpdateDialogDisplayed) { + AppUpdateState.wasUpdateDialogDisplayed = true + appUpgradeParameters.appUpgradeRecommendedDialog() + } + } + } + + is AppUpgradeEvent.UpgradeRequiredEvent -> { + if (!AppUpdateState.wasUpdateDialogDisplayed) { + AppUpdateState.wasUpdateDialogDisplayed = true + appUpgradeParameters.onAppUpgradeRequired() + } + } + + else -> {} + } + } + } + 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..8723d6dbe 100644 --- a/app/src/main/java/org/openedx/app/MainViewModel.kt +++ b/app/src/main/java/org/openedx/app/MainViewModel.kt @@ -10,15 +10,20 @@ 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 kotlinx.coroutines.launch import org.openedx.core.config.Config import org.openedx.core.system.notifier.DiscoveryNotifier import org.openedx.core.system.notifier.NavigationToDiscovery +import org.openedx.core.system.notifier.app.AppNotifier +import org.openedx.core.system.notifier.app.AppUpgradeEvent +import org.openedx.discovery.presentation.DiscoveryNavigator +import org.openedx.foundation.presentation.BaseViewModel class MainViewModel( private val config: Config, private val notifier: DiscoveryNotifier, private val analytics: AppAnalytics, + private val appNotifier: AppNotifier, ) : BaseViewModel() { private val _isBottomBarEnabled = MutableLiveData(true) @@ -29,44 +34,71 @@ class MainViewModel( val navigateToDiscovery: SharedFlow get() = _navigateToDiscovery.asSharedFlow() + private val _appUpgradeEvent = MutableLiveData() + val appUpgradeEvent: LiveData + get() = _appUpgradeEvent + val isDiscoveryTypeWebView get() = config.getDiscoveryConfig().isViewTypeWebView() + val getDiscoveryFragment get() = DiscoveryNavigator(isDiscoveryTypeWebView).getDiscoveryFragment() - val isProgramTypeWebView get() = config.getProgramConfig().isViewTypeWebView() + val isDownloadsFragmentEnabled get() = config.getDownloadsConfig().isEnabled override fun onCreate(owner: LifecycleOwner) { super.onCreate(owner) - notifier.notifier.onEach { - if (it is NavigationToDiscovery) { - _navigateToDiscovery.emit(true) - } - }.distinctUntilChanged().launchIn(viewModelScope) + collectDiscoveryEvents() + collectAppUpgradeEvent() } fun enableBottomBar(enable: Boolean) { _isBottomBarEnabled.value = enable } - fun logDiscoveryTabClickedEvent() { - logEvent(AppAnalyticsEvent.DISCOVER) + fun logLearnTabClickedEvent() { + logScreenEvent(AppAnalyticsEvent.LEARN) } - fun logMyCoursesTabClickedEvent() { - logEvent(AppAnalyticsEvent.MY_COURSES) + fun logDiscoveryTabClickedEvent() { + logScreenEvent(AppAnalyticsEvent.DISCOVER) } - fun logMyProgramsTabClickedEvent() { - logEvent(AppAnalyticsEvent.MY_PROGRAMS) + fun logDownloadsTabClickedEvent() { + logScreenEvent(AppAnalyticsEvent.DOWNLOADS) } 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) } ) } + + private fun collectDiscoveryEvents() { + notifier.notifier + .onEach { + if (it is NavigationToDiscovery) { + _navigateToDiscovery.emit(true) + } + } + .distinctUntilChanged() + .launchIn(viewModelScope) + } + + private fun collectAppUpgradeEvent() { + viewModelScope.launch { + appNotifier.notifier + .onEach { event -> + if (event is AppUpgradeEvent) { + _appUpgradeEvent.value = event + } + } + .distinctUntilChanged() + .launchIn(viewModelScope) + } + } } 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/adapter/MainNavigationFragmentAdapter.kt b/app/src/main/java/org/openedx/app/adapter/MainNavigationFragmentAdapter.kt deleted file mode 100644 index ccbe6f715..000000000 --- a/app/src/main/java/org/openedx/app/adapter/MainNavigationFragmentAdapter.kt +++ /dev/null @@ -1,17 +0,0 @@ -package org.openedx.app.adapter - -import androidx.fragment.app.Fragment -import androidx.viewpager2.adapter.FragmentStateAdapter - -class MainNavigationFragmentAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) { - - private val fragments = ArrayList() - - override fun getItemCount(): Int = fragments.size - - override fun createFragment(position: Int): Fragment = fragments[position] - - fun addFragment(fragment: Fragment) { - fragments.add(fragment) - } -} \ No newline at end of file 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..e3add144d 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,34 +4,44 @@ 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.AppUpdateState +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()) val responseCode = response.code val latestAppVersion = response.header(HEADER_APP_LATEST_VERSION) ?: "" val lastSupportedDateString = response.header(HEADER_APP_VERSION_LAST_SUPPORTED_DATE) ?: "" - val lastSupportedDateTime = TimeUtils.iso8601WithTimeZoneToDate(lastSupportedDateString)?.time ?: 0L + val lastSupportedDateTime = + TimeUtils.iso8601WithTimeZoneToDate(lastSupportedDateString)?.time ?: 0L runBlocking { - when { + val appUpgradeEvent = when { responseCode == 426 -> { - appUpgradeNotifier.send(AppUpgradeEvent.UpgradeRequiredEvent) + AppUpgradeEvent.UpgradeRequiredEvent } BuildConfig.VERSION_NAME != latestAppVersion && lastSupportedDateTime > Date().time -> { - appUpgradeNotifier.send(AppUpgradeEvent.UpgradeRecommendedEvent(latestAppVersion)) + AppUpgradeEvent.UpgradeRecommendedEvent(latestAppVersion) } - latestAppVersion.isNotEmpty() && BuildConfig.VERSION_NAME != latestAppVersion && lastSupportedDateTime < Date().time -> { - appUpgradeNotifier.send(AppUpgradeEvent.UpgradeRequiredEvent) + latestAppVersion.isNotEmpty() && + BuildConfig.VERSION_NAME != latestAppVersion && + lastSupportedDateTime < Date().time -> { + AppUpgradeEvent.UpgradeRequiredEvent + } + + else -> { + return@runBlocking } } + AppUpdateState.lastAppUpgradeEvent = appUpgradeEvent + appNotifier.send(appUpgradeEvent) } return response } @@ -41,4 +51,3 @@ class AppUpgradeInterceptor( const val HEADER_APP_VERSION_LAST_SUPPORTED_DATE = "EDX-APP-VERSION-LAST-SUPPORTED-DATE" } } - diff --git a/app/src/main/java/org/openedx/app/data/networking/HandleErrorInterceptor.kt b/app/src/main/java/org/openedx/app/data/networking/HandleErrorInterceptor.kt index bd4aa1920..b2529e06c 100644 --- a/app/src/main/java/org/openedx/app/data/networking/HandleErrorInterceptor.kt +++ b/app/src/main/java/org/openedx/app/data/networking/HandleErrorInterceptor.kt @@ -2,11 +2,11 @@ package org.openedx.app.data.networking import com.google.gson.Gson import com.google.gson.JsonSyntaxException -import org.openedx.core.data.model.ErrorResponse -import org.openedx.core.system.EdxError import okhttp3.Interceptor import okhttp3.Response import okio.IOException +import org.openedx.core.data.model.ErrorResponse +import org.openedx.core.system.EdxError class HandleErrorInterceptor( private val gson: Gson @@ -14,37 +14,41 @@ class HandleErrorInterceptor( override fun intercept(chain: Interceptor.Chain): Response { val response = chain.proceed(chain.request()) - val responseCode = response.code - if (responseCode in 400..500 && response.body != null) { - val jsonStr = response.body!!.string() - - try { - val errorResponse = gson.fromJson(jsonStr, ErrorResponse::class.java) - if (errorResponse?.error != null) { - when (errorResponse.error) { - ERROR_INVALID_GRANT -> { - throw EdxError.InvalidGrantException() - } - ERROR_USER_NOT_ACTIVE -> { - throw EdxError.UserNotActiveException() - } - else -> { - return response - } - } - } else if (errorResponse?.errorDescription != null) { - throw EdxError.ValidationException(errorResponse.errorDescription ?: "") - } - } catch (e: JsonSyntaxException) { - throw IOException("JsonSyntaxException $jsonStr", e) - } + return if (isErrorResponse(response)) { + val jsonStr = response.body?.string() + if (jsonStr != null) handleErrorResponse(response, jsonStr) else response + } else { + response + } + } + + private fun isErrorResponse(response: Response): Boolean { + return response.code in 400..500 && response.body != null + } + + private fun handleErrorResponse(response: Response, jsonStr: String): Response { + return try { + val errorResponse = gson.fromJson(jsonStr, ErrorResponse::class.java) + handleParsedErrorResponse(errorResponse) ?: response + } catch (e: JsonSyntaxException) { + throw IOException("JsonSyntaxException $jsonStr", e) } + } + + private fun handleParsedErrorResponse(errorResponse: ErrorResponse?): Response? { + val exception = when { + errorResponse?.error == ERROR_INVALID_GRANT -> EdxError.InvalidGrantException() + errorResponse?.error == ERROR_USER_NOT_ACTIVE -> EdxError.UserNotActiveException() + errorResponse?.errorDescription != null -> + EdxError.ValidationException(errorResponse.errorDescription.orEmpty()) - return response + else -> return null + } + throw exception } companion object { const val ERROR_INVALID_GRANT = "invalid_grant" const val ERROR_USER_NOT_ACTIVE = "user_not_active" } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/openedx/app/data/networking/HeadersInterceptor.kt b/app/src/main/java/org/openedx/app/data/networking/HeadersInterceptor.kt index c91b27184..a4daf0809 100644 --- a/app/src/main/java/org/openedx/app/data/networking/HeadersInterceptor.kt +++ b/app/src/main/java/org/openedx/app/data/networking/HeadersInterceptor.kt @@ -1,14 +1,13 @@ package org.openedx.app.data.networking -import android.content.Context import okhttp3.Interceptor import okhttp3.Response -import org.openedx.app.BuildConfig import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.presentation.global.AppData class HeadersInterceptor( - private val context: Context, + private val appData: AppData, private val config: Config, private val preferencesManager: CorePreferences, ) : Interceptor { @@ -26,14 +25,8 @@ class HeadersInterceptor( addHeader("Accept", "application/json") val httpAgent = System.getProperty("http.agent") ?: "" - addHeader( - "User-Agent", - httpAgent + " " + - context.getString(org.openedx.core.R.string.app_name) + "/" + - BuildConfig.APPLICATION_ID + "/" + - BuildConfig.VERSION_NAME - ) + addHeader("User-Agent", "$httpAgent ${appData.versionName}") }.build() ) } -} \ No newline at end of file +} 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..a60a3a988 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 @@ -3,14 +3,19 @@ package org.openedx.app.data.networking import android.util.Log import com.google.gson.Gson import kotlinx.coroutines.runBlocking -import okhttp3.* +import okhttp3.Authenticator +import okhttp3.Interceptor import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody import okhttp3.ResponseBody.Companion.toResponseBody +import okhttp3.Route 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.auth.data.api.AuthApi import org.openedx.auth.domain.model.AuthResponse import org.openedx.core.ApiConstants @@ -18,6 +23,8 @@ 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.system.notifier.app.LogoutEvent import org.openedx.core.utils.TimeUtils import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory @@ -45,8 +52,8 @@ class OauthRefreshTokenAuthenticator( init { val okHttpClient = OkHttpClient.Builder().apply { - writeTimeout(60, TimeUnit.SECONDS) - readTimeout(60, TimeUnit.SECONDS) + writeTimeout(timeout = 60, TimeUnit.SECONDS) + readTimeout(timeout = 60, TimeUnit.SECONDS) if (BuildConfig.DEBUG) { addNetworkInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)) } @@ -59,81 +66,83 @@ class OauthRefreshTokenAuthenticator( .create(AuthApi::class.java) } + @Suppress("ReturnCount") @Synchronized override fun authenticate(route: Route?, response: Response): Request? { val accessToken = preferencesManager.accessToken val refreshToken = preferencesManager.refreshToken - if (refreshToken.isEmpty()) { - return null + if (refreshToken.isEmpty()) return null + + val errorCode = getErrorCode(response.peekBody(Long.MAX_VALUE).string()) ?: return null + + return when (errorCode) { + TOKEN_EXPIRED_ERROR_MESSAGE, JWT_TOKEN_EXPIRED -> { + handleTokenExpired(response, refreshToken, accessToken) + } + + TOKEN_NONEXISTENT_ERROR_MESSAGE, TOKEN_INVALID_GRANT_ERROR_MESSAGE, JWT_INVALID_TOKEN -> { + handleInvalidToken(response, accessToken) + } + + DISABLED_USER_ERROR_MESSAGE, JWT_DISABLED_USER_ERROR_MESSAGE, JWT_USER_EMAIL_MISMATCH -> { + handleDisabledUser() + } + + else -> null } + } - val errorCode = getErrorCode(response.peekBody(Long.MAX_VALUE).string()) - if (errorCode != null) { - when (errorCode) { - TOKEN_EXPIRED_ERROR_MESSAGE, - JWT_TOKEN_EXPIRED, - -> { - try { - val newAuth = refreshAccessToken(refreshToken) - if (newAuth != null) { - return response.request.newBuilder() - .header( - HEADER_AUTHORIZATION, - config.getAccessTokenType() + " " + newAuth.accessToken - ) - .build() - } else { - val actualToken = preferencesManager.accessToken - if (actualToken != accessToken) { - return response.request.newBuilder() - .header( - HEADER_AUTHORIZATION, - "${config.getAccessTokenType()} $actualToken" - ) - .build() - } - return null - } - } catch (e: Exception) { - return null - } - } + private fun handleDisabledUser(): Request? { + runBlocking { appNotifier.send(LogoutEvent(true)) } + return null + } - TOKEN_NONEXISTENT_ERROR_MESSAGE, - TOKEN_INVALID_GRANT_ERROR_MESSAGE, - JWT_INVALID_TOKEN, - -> { - // Retry request with the current access_token if the original access_token used in - // request does not match the current access_token. This case can occur when - // asynchronous calls are made and are attempting to refresh the access_token where - // one call succeeds but the other fails. https://github.com/edx/edx-app-android/pull/834 - val authHeaders = response.request.headers[HEADER_AUTHORIZATION] - ?.split(" ".toRegex()) - if (authHeaders?.toTypedArray()?.getOrNull(1) != accessToken) { - return response.request.newBuilder() - .header( - HEADER_AUTHORIZATION, - "${config.getAccessTokenType()} $accessToken" - ).build() - } - - runBlocking { - appNotifier.send(LogoutEvent()) - } + // Helper function for handling token expiration logic + private fun handleTokenExpired(response: Response, refreshToken: String, accessToken: String): Request? { + return try { + val newAuth = refreshAccessToken(refreshToken) + if (newAuth != null) { + response.request.newBuilder() + .header( + HEADER_AUTHORIZATION, + "${config.getAccessTokenType()} ${newAuth.accessToken}" + ) + .build() + } else { + val actualToken = preferencesManager.accessToken + if (actualToken != accessToken) { + response.request.newBuilder() + .header( + HEADER_AUTHORIZATION, + "${config.getAccessTokenType()} $actualToken" + ) + .build() + } else { + null } + } + } catch (e: Exception) { + e.printStackTrace() + null + } + } - DISABLED_USER_ERROR_MESSAGE, - JWT_DISABLED_USER_ERROR_MESSAGE, - JWT_USER_EMAIL_MISMATCH, - -> { - runBlocking { - appNotifier.send(LogoutEvent()) - } - } + // Helper function for handling invalid token logic + private fun handleInvalidToken(response: Response, accessToken: String): Request? { + val authHeaders = response.request.headers[HEADER_AUTHORIZATION]?.split(" ".toRegex()) + return if (authHeaders?.toTypedArray()?.getOrNull(1) != accessToken) { + response.request.newBuilder() + .header( + HEADER_AUTHORIZATION, + "${config.getAccessTokenType()} $accessToken" + ).build() + } else { + runBlocking { + appNotifier.send(LogoutEvent(true)) } + null } - return null } private fun isTokenExpired(): Boolean { @@ -169,8 +178,8 @@ class OauthRefreshTokenAuthenticator( lastTokenRefreshRequestTime = TimeUtils.getCurrentTime() } } else if (response.code() == 400) { - //another refresh already in progress - Thread.sleep(1500) + // another refresh already in progress + Thread.sleep(REFRESH_TOKEN_THREAD_SLEEP) } } @@ -178,29 +187,22 @@ class OauthRefreshTokenAuthenticator( } private fun getErrorCode(responseBody: String): String? { - try { + return try { val jsonObj = JSONObject(responseBody) + if (jsonObj.has(FIELD_ERROR_CODE)) { - return jsonObj.getString(FIELD_ERROR_CODE) + jsonObj.getString(FIELD_ERROR_CODE) + } else if (TOKEN_TYPE_JWT.equals(config.getAccessTokenType(), ignoreCase = true)) { + val errorType = if (jsonObj.has(FIELD_DETAIL)) FIELD_DETAIL else FIELD_DEVELOPER_MESSAGE + jsonObj.getString(errorType) } else { - return if (TOKEN_TYPE_JWT.equals(config.getAccessTokenType(), ignoreCase = true)) { - val errorType = - if (jsonObj.has(FIELD_DETAIL)) FIELD_DETAIL else FIELD_DEVELOPER_MESSAGE - jsonObj.getString(errorType) - } else { - val errorCode = jsonObj - .optJSONObject(FIELD_DEVELOPER_MESSAGE) - ?.optString(FIELD_ERROR_CODE, "") ?: "" - if (errorCode != "") { - errorCode - } else { - null - } - } + jsonObj.optJSONObject(FIELD_DEVELOPER_MESSAGE) + ?.optString(FIELD_ERROR_CODE, "") + ?.takeIf { it.isNotEmpty() } } - } catch (ex: JSONException) { + } catch (_: JSONException) { Log.d("OauthRefreshTokenAuthenticator", "Unable to get error_code from 401 response") - return null + null } } @@ -269,5 +271,7 @@ class OauthRefreshTokenAuthenticator( * unauthorized access token during async requests. */ private const val REFRESH_TOKEN_INTERVAL_MINIMUM = 60 * 1000 + + private const val REFRESH_TOKEN_THREAD_SLEEP = 1500L } } 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..3c8ea881e 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,26 @@ 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 { +class PreferencesManager(context: Context) : + CorePreferences, + ProfilePreferences, + WhatsNewPreferences, + InAppReviewPreferences, + CoursePreferences, + CalendarPreferences { private val sharedPreferences = context.getSharedPreferences(BuildConfig.APPLICATION_ID, Context.MODE_PRIVATE) @@ -37,7 +44,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 +56,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 +86,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 +150,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 +182,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 +222,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 +233,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..2192a6b89 --- /dev/null +++ b/app/src/main/java/org/openedx/app/deeplink/DeepLinkRouter.kt @@ -0,0 +1,506 @@ +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.course.domain.interactor.CourseInteractor +import org.openedx.course.presentation.handouts.HandoutsType +import org.openedx.course.presentation.unit.container.CourseViewMode +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) { + DeepLinkType.DISCOVERY -> navigateToDiscoveryScreen(fm) + DeepLinkType.DISCOVERY_COURSE_DETAIL -> navigateToCourseDetail(fm, deepLink) + DeepLinkType.DISCOVERY_PROGRAM_DETAIL -> navigateToProgramDetail(fm, deepLink) + else -> handleLoggedOutOrUserNavigation(fm, deepLink) + } + } + + private fun handleLoggedOutOrUserNavigation(fm: FragmentManager, deepLink: DeepLink) { + if (!isUserLoggedIn) { + navigateToSignIn(fm) + } else { + handleProgramAndProfileNavigation(fm, deepLink) + } + } + + private fun handleProgramAndProfileNavigation(fm: FragmentManager, deepLink: DeepLink) { + when (deepLink.type) { + DeepLinkType.PROGRAM -> navigateToProgram(fm, deepLink) + DeepLinkType.PROFILE, DeepLinkType.USER_PROFILE -> navigateToProfile(fm) + else -> handleCourseRelatedNavigation(fm, deepLink) + } + } + + private fun handleCourseRelatedNavigation(fm: FragmentManager, deepLink: DeepLink) { + launch(Dispatchers.Main) { + val courseId = deepLink.courseId ?: return@launch navigateToDashboard(fm) + val course = getCourseDetails(courseId) ?: return@launch navigateToDashboard(fm) + if (!course.isEnrolled) return@launch navigateToDashboard(fm) + + handleSpecificCourseNavigation(fm, deepLink, course.name) + } + } + + private fun handleSpecificCourseNavigation(fm: FragmentManager, deepLink: DeepLink, courseTitle: String) { + navigateToDashboard(fm) + when (deepLink.type) { + DeepLinkType.COURSE_DASHBOARD, DeepLinkType.ENROLL, DeepLinkType.ADD_BETA_TESTER -> { + navigateToCourseDashboard(fm, deepLink, courseTitle) + } + + DeepLinkType.UNENROLL, DeepLinkType.REMOVE_BETA_TESTER -> {} // Just navigate to dashboard + DeepLinkType.COURSE_VIDEOS -> navigateToCourseVideos(fm, deepLink) + DeepLinkType.COURSE_DATES -> navigateToCourseDates(fm, deepLink) + DeepLinkType.COURSE_DISCUSSION -> navigateToCourseDiscussion(fm, deepLink) + DeepLinkType.COURSE_HANDOUT -> navigateToCourseHandoutWithMore(fm, deepLink) + DeepLinkType.COURSE_ANNOUNCEMENT -> navigateToCourseAnnouncementWithMore(fm, deepLink) + DeepLinkType.COURSE_COMPONENT -> navigateToCourseComponentWithDashboard(fm, deepLink, courseTitle) + DeepLinkType.DISCUSSION_TOPIC -> navigateToDiscussionTopicWithDiscussion(fm, deepLink) + DeepLinkType.DISCUSSION_POST -> navigateToDiscussionPostWithDiscussion(fm, deepLink) + DeepLinkType.DISCUSSION_COMMENT, DeepLinkType.FORUM_RESPONSE -> { + navigateToDiscussionResponseWithDiscussion(fm, deepLink) + } + + DeepLinkType.FORUM_COMMENT -> navigateToDiscussionCommentWithDiscussion(fm, deepLink) + else -> {} // ignore + } + } + + // Additional helper methods to encapsulate grouped navigation + private fun navigateToCourseHandoutWithMore(fm: FragmentManager, deepLink: DeepLink) { + navigateToCourseMore(fm, deepLink) + navigateToCourseHandout(fm, deepLink) + } + + private fun navigateToCourseAnnouncementWithMore(fm: FragmentManager, deepLink: DeepLink) { + navigateToCourseMore(fm, deepLink) + navigateToCourseAnnouncement(fm, deepLink) + } + + private fun navigateToCourseComponentWithDashboard(fm: FragmentManager, deepLink: DeepLink, courseTitle: String) { + navigateToCourseDashboard(fm, deepLink, courseTitle) + navigateToCourseComponent(fm, deepLink) + } + + private fun navigateToDiscussionTopicWithDiscussion(fm: FragmentManager, deepLink: DeepLink) { + navigateToCourseDiscussion(fm, deepLink) + navigateToDiscussionTopic(fm, deepLink) + } + + private fun navigateToDiscussionPostWithDiscussion(fm: FragmentManager, deepLink: DeepLink) { + navigateToCourseDiscussion(fm, deepLink) + navigateToDiscussionPost(fm, deepLink) + } + + private fun navigateToDiscussionResponseWithDiscussion(fm: FragmentManager, deepLink: DeepLink) { + navigateToCourseDiscussion(fm, deepLink) + navigateToDiscussionResponse(fm, deepLink) + } + + private fun navigateToDiscussionCommentWithDiscussion(fm: FragmentManager, deepLink: DeepLink) { + navigateToCourseDiscussion(fm, deepLink) + navigateToDiscussionComment(fm, deepLink) + } + + // 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..ce72703ad --- /dev/null +++ b/app/src/main/java/org/openedx/app/deeplink/HomeTab.kt @@ -0,0 +1,9 @@ +package org.openedx.app.deeplink + +enum class HomeTab { + LEARN, + PROGRAMS, + DISCOVER, + DOWNLOADS, + 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..cdb240387 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -14,43 +14,55 @@ 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 +import org.openedx.auth.presentation.sso.BrowserAuthHelper 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.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.domain.helper.VideoPreviewHelper 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.DownloadsAnalytics import org.openedx.core.presentation.dialog.appreview.AppReviewAnalytics import org.openedx.core.presentation.dialog.appreview.AppReviewManager +import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager 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.presentation.global.appupgrade.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.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 +70,18 @@ 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.downloads.presentation.DownloadsRouter +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 +92,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 +114,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 +128,9 @@ val appModule = module { single { get() } single { get() } single { get() } + single { DeepLinkRouter(get(), get(), get(), get(), get(), get()) } + single { get() } + single { get() } single { NetworkConnection(get()) } @@ -149,6 +172,11 @@ val appModule = module { room.downloadDao() } + single { + val room = get() + room.calendarDao() + } + single { FileDownloader() } @@ -157,14 +185,20 @@ val appModule = module { DownloadWorkerController(get(), get(), get()) } - single { AppData(versionName = BuildConfig.VERSION_NAME) } + single { + val resourceManager = get() + AppData( + appName = resourceManager.getString(R.string.app_name), + versionName = BuildConfig.VERSION_NAME, + applicationId = BuildConfig.APPLICATION_ID, + ) + } 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() } @@ -175,10 +209,27 @@ val appModule = module { single { get() } single { get() } single { get() } + single { get() } factory { AgreementProvider(get(), get()) } factory { FacebookAuthHelper() } factory { GoogleAuthHelper(get()) } factory { MicrosoftAuthHelper() } + factory { BrowserAuthHelper(get()) } factory { OAuthHelper(get(), get(), get()) } + factory { VideoPreviewHelper(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..6360e7fba 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,9 +54,9 @@ val networkingModule = module { single { provideApi(get()) } single { provideApi(get()) } single { provideApi(get()) } + single { provideApi(get()) } } - inline fun provideApi(retrofit: Retrofit): T { return retrofit.create(T::class.java) } 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..1d3604050 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -12,16 +12,24 @@ 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.assignments.CourseAssignmentViewModel import org.openedx.course.presentation.container.CourseContainerViewModel +import org.openedx.course.presentation.contenttab.ContentTabViewModel import org.openedx.course.presentation.dates.CourseDatesViewModel import org.openedx.course.presentation.handouts.HandoutsViewModel -import org.openedx.course.presentation.outline.CourseOutlineViewModel +import org.openedx.course.presentation.home.CourseHomeViewModel +import org.openedx.course.presentation.offline.CourseOfflineViewModel +import org.openedx.course.presentation.outline.CourseContentAllViewModel +import org.openedx.course.presentation.progress.CourseProgressViewModel import org.openedx.course.presentation.section.CourseSectionViewModel import org.openedx.course.presentation.unit.container.CourseUnitContainerViewModel +import org.openedx.course.presentation.unit.container.CourseViewMode import org.openedx.course.presentation.unit.html.HtmlUnitViewModel import org.openedx.course.presentation.unit.video.BaseVideoViewModel import org.openedx.course.presentation.unit.video.EncodedVideoUnitViewModel @@ -29,9 +37,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 +59,19 @@ 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.downloads.data.repository.DownloadRepository +import org.openedx.downloads.domain.interactor.DownloadInteractor +import org.openedx.downloads.presentation.download.DownloadsViewModel +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,8 +82,21 @@ import org.openedx.whatsnew.presentation.whatsnew.WhatsNewViewModel val screenModule = module { - viewModel { AppViewModel(get(), get(), get(), get(), get(named("IODispatcher")), get()) } - viewModel { MainViewModel(get(), get(), get()) } + viewModel { + AppViewModel( + get(), + get(), + get(), + get(), + get(named("IODispatcher")), + get(), + get(), + get(), + get(), + get(), + ) + } + viewModel { MainViewModel(get(), get(), get(), get()) } factory { AuthRepository(get(), get(), get()) } factory { AuthInteractor(get()) } @@ -76,10 +108,11 @@ val screenModule = module { get(), get(), get(), + get(), ) } - viewModel { (courseId: String?, infoType: String?) -> + viewModel { (courseId: String?, infoType: String?, authCode: String) -> SignInViewModel( get(), get(), @@ -92,8 +125,12 @@ val screenModule = module { get(), get(), get(), + get(), + get(), + get(), courseId, infoType, + authCode, ) } @@ -114,13 +151,30 @@ 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()) } + viewModel { (windowSize: WindowSize) -> + DashboardGalleryViewModel( + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + windowSize + ) + } + viewModel { AllEnrolledCoursesViewModel(get(), get(), get(), get(), get(), get(), get()) } + viewModel { (openTab: String) -> + LearnViewModel(openTab, get(), get(), get()) + } factory { DiscoveryRepository(get(), get(), get()) } factory { DiscoveryInteractor(get()) } - viewModel { NativeDiscoveryViewModel(get(), get(), get(), get(), get(), get(), get()) } + viewModel { NativeDiscoveryViewModel(get(), get(), get(), get(), get(), get()) } viewModel { (querySearch: String) -> WebViewDiscoveryViewModel( querySearch, @@ -129,6 +183,7 @@ val screenModule = module { get(), get(), get(), + get(), ) } @@ -143,16 +198,47 @@ val screenModule = module { profileRouter = get(), ) } - viewModel { (account: Account) -> EditProfileViewModel(get(), get(), get(), get(), account) } + viewModel { (account: Account) -> + EditProfileViewModel( + get(), + get(), + get(), + get(), + get(), + account + ) + } viewModel { VideoSettingsViewModel(get(), get(), get(), get()) } 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()) } + single { get() } + viewModel { (pathId: String, infoType: String) -> CourseInfoViewModel( pathId, @@ -165,6 +251,7 @@ val screenModule = module { get(), get(), get(), + get(), ) } viewModel { (courseId: String) -> @@ -176,14 +263,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,11 +282,10 @@ val screenModule = module { get(), get(), get(), - get() ) } viewModel { (courseId: String, courseTitle: String) -> - CourseOutlineViewModel( + CourseContentAllViewModel( courseId, courseTitle, get(), @@ -211,11 +298,28 @@ val screenModule = module { get(), get(), get(), + get(), + get(), + get(), + get(), ) } - viewModel { (courseId: String) -> - CourseSectionViewModel( + viewModel { (courseId: String, courseTitle: String) -> + ContentTabViewModel( courseId, + courseTitle, + get(), + ) + } + viewModel { (courseId: String, courseTitle: String) -> + CourseHomeViewModel( + courseId, + courseTitle, + get(), + get(), + get(), + get(), + get(), get(), get(), get(), @@ -225,22 +329,38 @@ val screenModule = module { get(), get(), get(), + get() + ) + } + viewModel { (courseId: String) -> + CourseSectionViewModel( + courseId, + get(), + get(), + get(), + get(), ) } - viewModel { (courseId: String, unitId: String) -> + viewModel { (courseId: String, unitId: String, mode: CourseViewMode) -> CourseUnitContainerViewModel( courseId, unitId, + mode, + get(), + get(), get(), get(), get(), get(), ) } - viewModel { (courseId: String, courseTitle: String) -> + viewModel { (courseId: String) -> CourseVideoViewModel( courseId, - courseTitle, + get(), + get(), + get(), + get(), get(), get(), get(), @@ -256,9 +376,11 @@ val screenModule = module { } viewModel { (courseId: String) -> BaseVideoViewModel(courseId, get()) } viewModel { (courseId: String) -> VideoViewModel(courseId, get(), get(), get(), get()) } - viewModel { (courseId: String) -> + viewModel { (courseId: String, videoUrl: String, blockId: String) -> VideoUnitViewModel( courseId, + videoUrl, + blockId, get(), get(), get(), @@ -266,9 +388,10 @@ val screenModule = module { get() ) } - viewModel { (courseId: String, blockId: String) -> + viewModel { (courseId: String, videoUrl: String, blockId: String) -> EncodedVideoUnitViewModel( courseId, + videoUrl, blockId, get(), get(), @@ -279,8 +402,9 @@ val screenModule = module { get(), ) } - viewModel { (enrollmentMode: String) -> + viewModel { (courseId: String, enrollmentMode: String) -> CourseDatesViewModel( + courseId, enrollmentMode, get(), get(), @@ -289,6 +413,9 @@ val screenModule = module { get(), get(), get(), + get(), + get(), + get(), ) } viewModel { (courseId: String, handoutsType: String) -> @@ -305,8 +432,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 +499,88 @@ 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 { ProgramViewModel(get(), get(), get(), get(), get(), get(), get(), get()) } + + viewModel { (courseId: String, courseTitle: String) -> + CourseOfflineViewModel( + courseId, + courseTitle, + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get() + ) + } + viewModel { (courseId: String) -> + CourseProgressViewModel( + courseId, + get(), + get() + ) + } + single { + DownloadRepository( + api = get(), + corePreferences = get(), + dao = get(), + courseDao = get() + ) + } + single { + DownloadInteractor( + repository = get() + ) + } + viewModel { + DownloadsViewModel( + downloadsRouter = get(), + networkConnection = get(), + interactor = get(), + resourceManager = get(), + config = get(), + preferencesManager = get(), + coreAnalytics = get(), + downloadDao = get(), + workerController = get(), + downloadHelper = get(), + downloadDialogManager = get(), + fileUtil = get(), + analytics = get(), + discoveryNotifier = get(), + courseNotifier = get(), + router = get() + ) + } + viewModel { (courseId: String) -> + CourseAssignmentViewModel( + courseId = courseId, + interactor = get(), + courseRouter = get(), + courseNotifier = get(), + analytics = 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..b2f275bb3 100644 --- a/app/src/main/java/org/openedx/app/room/AppDatabase.kt +++ b/app/src/main/java/org/openedx/app/room/AppDatabase.kt @@ -1,31 +1,53 @@ package org.openedx.app.room +import androidx.room.AutoMigration 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.CourseEnrollmentDetailsEntity +import org.openedx.core.data.model.room.CourseProgressEntity import org.openedx.core.data.model.room.CourseStructureEntity +import org.openedx.core.data.model.room.DownloadCoursePreview +import org.openedx.core.data.model.room.OfflineXBlockProgress +import org.openedx.core.data.model.room.VideoProgressEntity import org.openedx.core.data.model.room.discovery.EnrolledCourseEntity +import org.openedx.core.data.storage.CourseDao +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 -import org.openedx.course.data.storage.CourseDao import org.openedx.dashboard.data.DashboardDao import org.openedx.discovery.data.converter.DiscoveryConverter import org.openedx.discovery.data.model.room.CourseEntity import org.openedx.discovery.data.storage.DiscoveryDao -const val DATABASE_VERSION = 1 +const val DATABASE_VERSION = 5 const val DATABASE_NAME = "OpenEdX_db" +@Suppress("MagicNumber") @Database( entities = [ CourseEntity::class, EnrolledCourseEntity::class, CourseStructureEntity::class, - DownloadModelEntity::class + DownloadModelEntity::class, + OfflineXBlockProgress::class, + CourseCalendarEventEntity::class, + CourseCalendarStateEntity::class, + DownloadCoursePreview::class, + CourseEnrollmentDetailsEntity::class, + VideoProgressEntity::class, + CourseProgressEntity::class, ], - version = DATABASE_VERSION, - exportSchema = false + autoMigrations = [ + AutoMigration(1, 2), + AutoMigration(2, 3), + AutoMigration(3, 4), + AutoMigration(4, DATABASE_VERSION), + ], + version = DATABASE_VERSION ) @TypeConverters(DiscoveryConverter::class, CourseConverter::class) abstract class AppDatabase : RoomDatabase() { @@ -33,4 +55,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..0c3087abf --- /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.data.storage.CourseDao +import org.openedx.core.module.db.DownloadDao +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..52caf4de7 --- /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.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(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..6c45a3ab4 --- /dev/null +++ b/app/src/main/java/org/openedx/app/system/push/SyncFirebaseTokenWorker.kt @@ -0,0 +1,45 @@ +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 + +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_book.xml b/app/src/main/res/drawable/app_ic_book.xml deleted file mode 100644 index 4245846af..000000000 --- a/app/src/main/res/drawable/app_ic_book.xml +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/drawable/app_ic_book_fill.xml b/app/src/main/res/drawable/app_ic_book_fill.xml new file mode 100644 index 000000000..eabe550d3 --- /dev/null +++ b/app/src/main/res/drawable/app_ic_book_fill.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/app_ic_book_outline.xml b/app/src/main/res/drawable/app_ic_book_outline.xml new file mode 100644 index 000000000..58021d21f --- /dev/null +++ b/app/src/main/res/drawable/app_ic_book_outline.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/app/src/main/res/drawable/app_ic_discover_selector.xml b/app/src/main/res/drawable/app_ic_discover_selector.xml new file mode 100644 index 000000000..9d2d2a951 --- /dev/null +++ b/app/src/main/res/drawable/app_ic_discover_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/app_ic_download_cloud_fill.xml b/app/src/main/res/drawable/app_ic_download_cloud_fill.xml new file mode 100644 index 000000000..8e623dc60 --- /dev/null +++ b/app/src/main/res/drawable/app_ic_download_cloud_fill.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/app_ic_download_cloud_outline.xml b/app/src/main/res/drawable/app_ic_download_cloud_outline.xml new file mode 100644 index 000000000..193cc1a6a --- /dev/null +++ b/app/src/main/res/drawable/app_ic_download_cloud_outline.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/app_ic_downloads_selector.xml b/app/src/main/res/drawable/app_ic_downloads_selector.xml new file mode 100644 index 000000000..a24c486d5 --- /dev/null +++ b/app/src/main/res/drawable/app_ic_downloads_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/app_ic_home.xml b/app/src/main/res/drawable/app_ic_home.xml deleted file mode 100644 index b703f9f28..000000000 --- a/app/src/main/res/drawable/app_ic_home.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - diff --git a/app/src/main/res/drawable/app_ic_learn_selector.xml b/app/src/main/res/drawable/app_ic_learn_selector.xml new file mode 100644 index 000000000..d3077a298 --- /dev/null +++ b/app/src/main/res/drawable/app_ic_learn_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/app_ic_profile.xml b/app/src/main/res/drawable/app_ic_profile.xml deleted file mode 100644 index 1b241a689..000000000 --- a/app/src/main/res/drawable/app_ic_profile.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - diff --git a/app/src/main/res/drawable/app_ic_profile_fill.xml b/app/src/main/res/drawable/app_ic_profile_fill.xml new file mode 100644 index 000000000..c4ed432a2 --- /dev/null +++ b/app/src/main/res/drawable/app_ic_profile_fill.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/app_ic_profile_outline.xml b/app/src/main/res/drawable/app_ic_profile_outline.xml new file mode 100644 index 000000000..07226fc2b --- /dev/null +++ b/app/src/main/res/drawable/app_ic_profile_outline.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/app_ic_profile_selector.xml b/app/src/main/res/drawable/app_ic_profile_selector.xml new file mode 100644 index 000000000..83708d080 --- /dev/null +++ b/app/src/main/res/drawable/app_ic_profile_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/app_ic_rows.xml b/app/src/main/res/drawable/app_ic_rows.xml deleted file mode 100644 index 41b74e9b4..000000000 --- a/app/src/main/res/drawable/app_ic_rows.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - diff --git a/app/src/main/res/drawable/app_ic_search_fill.xml b/app/src/main/res/drawable/app_ic_search_fill.xml new file mode 100644 index 000000000..6635fc8b1 --- /dev/null +++ b/app/src/main/res/drawable/app_ic_search_fill.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/app_ic_search_outline.xml b/app/src/main/res/drawable/app_ic_search_outline.xml new file mode 100644 index 000000000..4372bd085 --- /dev/null +++ b/app/src/main/res/drawable/app_ic_search_outline.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/splash_inset.xml b/app/src/main/res/drawable/app_splash_inset.xml similarity index 100% rename from app/src/main/res/drawable/splash_inset.xml rename to app/src/main/res/drawable/app_splash_inset.xml diff --git a/app/src/main/res/layout/fragment_main.xml b/app/src/main/res/layout/fragment_main.xml index eb6f37a6f..362793686 100644 --- a/app/src/main/res/layout/fragment_main.xml +++ b/app/src/main/res/layout/fragment_main.xml @@ -14,17 +14,24 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> - + app:layout_constraintStart_toStartOf="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 deleted file mode 100644 index 60ba4f78c..000000000 --- a/app/src/main/res/menu/bottom_view_menu.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png deleted file mode 100644 index d34811e00..000000000 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/app/src/main/res/values-land/dimens.xml b/app/src/main/res/values-land/dimens.xml deleted file mode 100644 index 22d7f0043..000000000 --- a/app/src/main/res/values-land/dimens.xml +++ /dev/null @@ -1,3 +0,0 @@ - - 48dp - \ 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-w1240dp/dimens.xml b/app/src/main/res/values-w1240dp/dimens.xml deleted file mode 100644 index d73f4a359..000000000 --- a/app/src/main/res/values-w1240dp/dimens.xml +++ /dev/null @@ -1,3 +0,0 @@ - - 200dp - \ No newline at end of file diff --git a/app/src/main/res/values-w600dp/dimens.xml b/app/src/main/res/values-w600dp/dimens.xml deleted file mode 100644 index 22d7f0043..000000000 --- a/app/src/main/res/values-w600dp/dimens.xml +++ /dev/null @@ -1,3 +0,0 @@ - - 48dp - \ No newline at end of file diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml deleted file mode 100644 index 125df8711..000000000 --- a/app/src/main/res/values/dimens.xml +++ /dev/null @@ -1,3 +0,0 @@ - - 16dp - \ No newline at end of file diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml deleted file mode 100644 index 7e567b52f..000000000 --- a/app/src/main/res/values/ic_launcher_background.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - #72BB25 - \ No newline at end of file diff --git a/app/src/main/res/values/main_manu_tab_ids.xml b/app/src/main/res/values/main_manu_tab_ids.xml new file mode 100644 index 000000000..f769b5bde --- /dev/null +++ b/app/src/main/res/values/main_manu_tab_ids.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/values/splash.xml b/app/src/main/res/values/splash.xml index e206c4bc1..7865c7d37 100644 --- a/app/src/main/res/values/splash.xml +++ b/app/src/main/res/values/splash.xml @@ -7,7 +7,7 @@ - @drawable/splash_inset + @drawable/app_splash_inset 300 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f24815f30..801ce0c80 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,10 +1,6 @@ - Settings - Next - Previous - Discover - Dashboard - Programs + Learn Profile - \ No newline at end of file + Downloads + diff --git a/app/src/test/java/org/openedx/AppViewModelTest.kt b/app/src/test/java/org/openedx/AppViewModelTest.kt index 40b3e813d..23b1c4120 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 { @@ -35,19 +40,24 @@ class AppViewModelTest { @get:Rule val testInstantTaskExecutorRule: TestRule = InstantTaskExecutorRule() - private val dispatcher = StandardTestDispatcher()//UnconfinedTestDispatcher() + private val dispatcher = StandardTestDispatcher() // UnconfinedTestDispatcher() private val config = mockk() private val notifier = mockk() 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..3bd660c15 100644 --- a/auth/build.gradle +++ b/auth/build.gradle @@ -2,20 +2,21 @@ plugins { id 'com.android.library' id 'org.jetbrains.kotlin.android' id 'kotlin-parcelize' + id "org.jetbrains.kotlin.plugin.compose" } android { - compileSdk 34 + namespace 'org.openedx.auth' + compileSdkVersion compile_sdk_version defaultConfig { - minSdk 24 - targetSdk 34 + minSdk min_sdk_version + targetSdk target_sdk_version testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles "consumer-rules.pro" } - namespace 'org.openedx.auth' flavorDimensions += "env" productFlavors { @@ -32,47 +33,51 @@ android { buildTypes { release { - minifyEnabled true + minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 + sourceCompatibility java_version + targetCompatibility java_version } - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17 + kotlin { + compilerOptions { + jvmTarget = jvm_target_version + freeCompilerArgs = ['-XXLanguage:+PropertyParamAnnotationDefaultTargetMode'] + } } 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 "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.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") + // AndroidX + implementation "androidx.browser:browser:$browser_version" + implementation "androidx.credentials:credentials:$credentials_version" + implementation "androidx.credentials:credentials-play-services-auth:$credentials_version" + + // Social Login + implementation "com.facebook.android:facebook-login:$facebook_login_version" + implementation "com.google.android.gms:play-services-auth:$play_services_auth_version" + implementation "com.google.android.libraries.identity.googleid:googleid:$googleid_version" + implementation("com.microsoft.identity.client:msal:$msal_version") { + exclude group: 'com.microsoft.identity.client', module: 'msal-browser' + exclude group: 'io.opentelemetry', module: 'opentelemetry-bom' } - 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" + + // OpenTelemetry + implementation("io.opentelemetry:opentelemetry-api:$opentelemetry_version") + implementation("io.opentelemetry:opentelemetry-context:$opentelemetry_version") + 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" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlinx_coroutines_test_version" + androidTestImplementation "androidx.test.ext:junit:$test_ext_version" + androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_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/data/api/AuthApi.kt b/auth/src/main/java/org/openedx/auth/data/api/AuthApi.kt index 903cbd62e..b837648fe 100644 --- a/auth/src/main/java/org/openedx/auth/data/api/AuthApi.kt +++ b/auth/src/main/java/org/openedx/auth/data/api/AuthApi.kt @@ -5,9 +5,14 @@ import org.openedx.auth.data.model.PasswordResetResponse import org.openedx.auth.data.model.RegistrationFields import org.openedx.auth.data.model.ValidationFields import org.openedx.core.ApiConstants -import org.openedx.core.data.model.* +import org.openedx.core.data.model.User import retrofit2.Call -import retrofit2.http.* +import retrofit2.http.Field +import retrofit2.http.FieldMap +import retrofit2.http.FormUrlEncoded +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Path interface AuthApi { @@ -32,6 +37,17 @@ interface AuthApi { @Field("asymmetric_jwt") isAsymmetricJwt: Boolean = true, ): AuthResponse + @FormUrlEncoded + @POST(ApiConstants.URL_ACCESS_TOKEN) + suspend fun getAccessTokenFromCode( + @Field("grant_type") grantType: String, + @Field("client_id") clientId: String, + @Field("code") code: String, + @Field("redirect_uri") redirectUri: String, + @Field("token_type") tokenType: String, + @Field("asymmetric_jwt") isAsymmetricJwt: Boolean = true, + ): AuthResponse + @FormUrlEncoded @POST(ApiConstants.URL_ACCESS_TOKEN) fun refreshAccessToken( @@ -59,4 +75,4 @@ interface AuthApi { @FormUrlEncoded @POST(ApiConstants.URL_PASSWORD_RESET) suspend fun passwordReset(@Field("email") email: String): PasswordResetResponse -} \ No newline at end of file +} diff --git a/auth/src/main/java/org/openedx/auth/data/model/AuthType.kt b/auth/src/main/java/org/openedx/auth/data/model/AuthType.kt index 5addd621c..c56ba0cf1 100644 --- a/auth/src/main/java/org/openedx/auth/data/model/AuthType.kt +++ b/auth/src/main/java/org/openedx/auth/data/model/AuthType.kt @@ -13,4 +13,5 @@ enum class AuthType(val postfix: String, val methodName: String) { GOOGLE(ApiConstants.AUTH_TYPE_GOOGLE, "Google"), FACEBOOK(ApiConstants.AUTH_TYPE_FB, "Facebook"), MICROSOFT(ApiConstants.AUTH_TYPE_MICROSOFT, "Microsoft"), + BROWSER(ApiConstants.AUTH_TYPE_BROWSER, "Browser") } diff --git a/auth/src/main/java/org/openedx/auth/data/model/PasswordResetResponse.kt b/auth/src/main/java/org/openedx/auth/data/model/PasswordResetResponse.kt index 1be96a795..f2feeda2b 100644 --- a/auth/src/main/java/org/openedx/auth/data/model/PasswordResetResponse.kt +++ b/auth/src/main/java/org/openedx/auth/data/model/PasswordResetResponse.kt @@ -5,4 +5,4 @@ import com.google.gson.annotations.SerializedName data class PasswordResetResponse( @SerializedName("success") val success: Boolean -) \ No newline at end of file +) diff --git a/auth/src/main/java/org/openedx/auth/data/model/RegistrationFields.kt b/auth/src/main/java/org/openedx/auth/data/model/RegistrationFields.kt index ef300156f..b59ccab2d 100644 --- a/auth/src/main/java/org/openedx/auth/data/model/RegistrationFields.kt +++ b/auth/src/main/java/org/openedx/auth/data/model/RegistrationFields.kt @@ -74,5 +74,4 @@ data class RegistrationFields( ) } } - -} \ No newline at end of file +} diff --git a/auth/src/main/java/org/openedx/auth/data/model/ValidationFields.kt b/auth/src/main/java/org/openedx/auth/data/model/ValidationFields.kt index 29c97ab33..5c335b2cc 100644 --- a/auth/src/main/java/org/openedx/auth/data/model/ValidationFields.kt +++ b/auth/src/main/java/org/openedx/auth/data/model/ValidationFields.kt @@ -7,4 +7,4 @@ data class ValidationFields( val validationResult: Map ) { fun hasValidationError() = validationResult.values.any { it != "" } -} \ No newline at end of file +} diff --git a/auth/src/main/java/org/openedx/auth/data/repository/AuthRepository.kt b/auth/src/main/java/org/openedx/auth/data/repository/AuthRepository.kt index 6cf54a7f1..20499baf9 100644 --- a/auth/src/main/java/org/openedx/auth/data/repository/AuthRepository.kt +++ b/auth/src/main/java/org/openedx/auth/data/repository/AuthRepository.kt @@ -32,7 +32,7 @@ class AuthRepository( } suspend fun socialLogin(token: String?, authType: AuthType) { - if (token.isNullOrBlank()) throw IllegalArgumentException("Token is null") + require(!token.isNullOrBlank()) { "Token is null" } api.exchangeAccessToken( accessToken = token, clientId = config.getOAuthClientId(), @@ -43,6 +43,16 @@ class AuthRepository( .processAuthResponse() } + suspend fun browserAuthCodeLogin(code: String) { + api.getAccessTokenFromCode( + grantType = ApiConstants.GRANT_TYPE_CODE, + clientId = config.getOAuthClientId(), + code = code, + redirectUri = "${config.getAppId()}://${ApiConstants.BrowserLogin.REDIRECT_HOST}", + tokenType = config.getAccessTokenType(), + ).mapToDomain().processAuthResponse() + } + suspend fun getRegistrationFields(): List { return api.getRegistrationFields().fields?.map { it.mapToDomain() } ?: emptyList() } diff --git a/auth/src/main/java/org/openedx/auth/domain/interactor/AuthInteractor.kt b/auth/src/main/java/org/openedx/auth/domain/interactor/AuthInteractor.kt index 00fe509af..727f77a48 100644 --- a/auth/src/main/java/org/openedx/auth/domain/interactor/AuthInteractor.kt +++ b/auth/src/main/java/org/openedx/auth/domain/interactor/AuthInteractor.kt @@ -18,6 +18,10 @@ class AuthInteractor(private val repository: AuthRepository) { repository.socialLogin(token, authType) } + suspend fun loginAuthCode(authCode: String) { + repository.browserAuthCodeLogin(authCode) + } + suspend fun getRegistrationFields(): List { return repository.getRegistrationFields() } @@ -33,5 +37,4 @@ class AuthInteractor(private val repository: AuthRepository) { suspend fun passwordReset(email: String): Boolean { return repository.passwordReset(email) } - -} \ No newline at end of file +} 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..f8dbba635 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 @@ -41,6 +42,7 @@ import androidx.fragment.app.Fragment import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.auth.R +import org.openedx.core.ApiConstants import org.openedx.core.ui.AuthButtonsPanel import org.openedx.core.ui.SearchBar import org.openedx.core.ui.displayCutoutForLandscape @@ -49,6 +51,7 @@ 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.theme.compose.LogistrationLogoView +import org.openedx.foundation.utils.UrlUtils class LogistrationFragment : Fragment() { @@ -66,14 +69,27 @@ class LogistrationFragment : Fragment() { OpenEdXTheme { LogistrationScreen( onSignInClick = { - viewModel.navigateToSignIn(parentFragmentManager) + if (viewModel.isBrowserLoginEnabled) { + viewModel.signInBrowser(requireActivity()) + } else { + viewModel.navigateToSignIn(parentFragmentManager) + } }, onRegisterClick = { - viewModel.navigateToSignUp(parentFragmentManager) + if (viewModel.isBrowserRegistrationEnabled) { + UrlUtils.openInBrowser( + activity = context, + apiHostUrl = viewModel.apiHostUrl, + url = ApiConstants.URL_REGISTER_BROWSER, + ) + } else { + viewModel.navigateToSignUp(parentFragmentManager) + } }, onSearchClick = { querySearch -> viewModel.navigateToDiscovery(parentFragmentManager, querySearch) - } + }, + isRegistrationEnabled = viewModel.isRegistrationEnabled ) } } @@ -97,8 +113,8 @@ private fun LogistrationScreen( onSearchClick: (String) -> Unit, onRegisterClick: () -> Unit, onSignInClick: () -> Unit, + isRegistrationEnabled: Boolean, ) { - var textFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) } @@ -131,7 +147,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 +192,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 +218,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..d7ca6e894 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 @@ -1,28 +1,54 @@ package org.openedx.auth.presentation.logistration +import android.app.Activity import androidx.fragment.app.FragmentManager +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch 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.auth.presentation.sso.BrowserAuthHelper import org.openedx.core.config.Config -import org.openedx.core.extension.takeIfNotEmpty +import org.openedx.core.utils.Logger +import org.openedx.foundation.extension.takeIfNotEmpty +import org.openedx.foundation.presentation.BaseViewModel class LogistrationViewModel( private val courseId: String, private val router: AuthRouter, private val config: Config, private val analytics: AuthAnalytics, + private val browserAuthHelper: BrowserAuthHelper, ) : BaseViewModel() { + private val logger = Logger("LogistrationViewModel") + private val discoveryTypeWebView get() = config.getDiscoveryConfig().isViewTypeWebView() + val isRegistrationEnabled get() = config.isRegistrationEnabled() + val isBrowserRegistrationEnabled get() = config.isBrowserRegistrationEnabled() + val isBrowserLoginEnabled get() = config.isBrowserLoginEnabled() + val apiHostUrl get() = config.getApiHostURL() + + init { + logLogistrationScreenEvent() + } fun navigateToSignIn(parentFragmentManager: FragmentManager) { router.navigateToSignIn(parentFragmentManager, courseId, null) logEvent(AuthAnalyticsEvent.SIGN_IN_CLICKED) } + fun signInBrowser(activityContext: Activity) { + viewModelScope.launch { + runCatching { + browserAuthHelper.signIn(activityContext) + }.onFailure { + logger.e { "Browser auth error: $it" } + } + } + } + fun navigateToSignUp(parentFragmentManager: FragmentManager) { router.navigateToSignUp(parentFragmentManager, courseId, null) logEvent(AuthAnalyticsEvent.REGISTER_CLICKED) @@ -62,4 +88,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..81d216c39 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.presentation.global.appupgrade.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, @@ -185,7 +186,7 @@ private fun RestorePasswordScreen( modifier = Modifier .fillMaxWidth() .height(200.dp), - painter = painterResource(id = org.openedx.core.R.drawable.core_top_header), + painter = painterResource(id = R.drawable.core_top_header), contentScale = ContentScale.FillBounds, contentDescription = null ) @@ -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() + } } ) } @@ -352,7 +366,6 @@ private fun RestorePasswordScreen( } } - @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) @@ -383,4 +396,4 @@ fun RestorePasswordTabletPreview() { onRestoreButtonClick = {} ) } -} \ No newline at end of file +} diff --git a/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordUIState.kt b/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordUIState.kt index 779adaf12..cfec43ad3 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordUIState.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordUIState.kt @@ -1,7 +1,7 @@ package org.openedx.auth.presentation.restore sealed class RestorePasswordUIState { - object Initial : RestorePasswordUIState() - object Loading : RestorePasswordUIState() + data object Initial : RestorePasswordUIState() + data object Loading : RestorePasswordUIState() class Success(val email: String) : RestorePasswordUIState() -} \ No newline at end of file +} 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..6c5e3adf1 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() @@ -60,7 +60,9 @@ class RestorePasswordViewModel( } else { _uiState.value = RestorePasswordUIState.Initial _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(org.openedx.auth.R.string.auth_invalid_email)) + UIMessage.SnackBarMessage( + resourceManager.getString(org.openedx.auth.R.string.auth_invalid_email) + ) logResetPasswordEvent(false) } } catch (e: Exception) { @@ -70,10 +72,14 @@ class RestorePasswordViewModel( _uiMessage.value = UIMessage.SnackBarMessage(e.error) } else if (e.isInternetError()) { _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) + UIMessage.SnackBarMessage( + resourceManager.getString(R.string.core_error_no_connection) + ) } else { _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) + UIMessage.SnackBarMessage( + resourceManager.getString(R.string.core_error_unknown_error) + ) } } } @@ -81,8 +87,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..e5da6fbd9 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 @@ -16,16 +16,17 @@ import org.koin.core.parameter.parametersOf 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.presentation.global.appupgrade.AppUpgradeRequiredScreen import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.foundation.presentation.rememberWindowSize class SignInFragment : Fragment() { private val viewModel: SignInViewModel by viewModel { parametersOf( requireArguments().getString(ARG_COURSE_ID, ""), - requireArguments().getString(ARG_INFO_TYPE, "") + requireArguments().getString(ARG_INFO_TYPE, ""), + requireArguments().getString(ARG_AUTH_CODE, ""), ) } @@ -43,6 +44,9 @@ class SignInFragment : Fragment() { val appUpgradeEvent by viewModel.appUpgradeEvent.observeAsState(null) if (appUpgradeEvent == null) { + if (viewModel.authCode != "" && !state.loginFailure && !state.loginSuccess) { + viewModel.signInAuthCode(viewModel.authCode) + } LoginScreen( windowSize = windowSize, state = state, @@ -59,6 +63,10 @@ class SignInFragment : Fragment() { viewModel.navigateToForgotPassword(parentFragmentManager) } + AuthEvent.SignInBrowser -> { + viewModel.signInBrowser(requireActivity()) + } + AuthEvent.RegisterClick -> { viewModel.navigateToSignUp(parentFragmentManager) } @@ -92,11 +100,13 @@ class SignInFragment : Fragment() { companion object { private const val ARG_COURSE_ID = "courseId" private const val ARG_INFO_TYPE = "info_type" - fun newInstance(courseId: String?, infoType: String?): SignInFragment { + private const val ARG_AUTH_CODE = "auth_code" + fun newInstance(courseId: String?, infoType: String?, authCode: String? = null): SignInFragment { val fragment = SignInFragment() fragment.arguments = bundleOf( ARG_COURSE_ID to courseId, - ARG_INFO_TYPE to infoType + ARG_INFO_TYPE to infoType, + ARG_AUTH_CODE to authCode, ) return fragment } @@ -107,6 +117,7 @@ internal sealed interface AuthEvent { data class SignIn(val login: String, val password: String) : AuthEvent data class SocialSignIn(val authType: AuthType) : AuthEvent data class OpenLink(val links: Map, val link: String) : AuthEvent + object SignInBrowser : AuthEvent object RegisterClick : AuthEvent object ForgotPasswordClick : AuthEvent object BackClick : AuthEvent 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..c2a5f915c 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 @@ -17,8 +17,12 @@ internal data class SignInUIState( val isGoogleAuthEnabled: Boolean = false, val isMicrosoftAuthEnabled: Boolean = false, val isSocialAuthEnabled: Boolean = false, + val isBrowserLoginEnabled: Boolean = false, + val isBrowserRegistrationEnabled: Boolean = false, val isLogistrationEnabled: Boolean = false, + val isRegistrationEnabled: Boolean = true, val showProgress: Boolean = false, val loginSuccess: Boolean = false, val agreement: RegistrationField? = null, + val loginFailure: Boolean = false, ) 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..f271927e1 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 @@ -1,5 +1,6 @@ package org.openedx.auth.presentation.signin +import android.app.Activity import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.lifecycle.LiveData @@ -20,21 +21,25 @@ 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.auth.presentation.sso.BrowserAuthHelper 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,15 +47,19 @@ 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, + private val browserAuthHelper: BrowserAuthHelper, + val config: Config, val courseId: String?, val infoType: String?, + val authCode: String, ) : BaseViewModel() { private val logger = Logger("SignInViewModel") @@ -60,8 +69,11 @@ class SignInViewModel( isFacebookAuthEnabled = config.getFacebookConfig().isEnabled(), isGoogleAuthEnabled = config.getGoogleConfig().isEnabled(), isMicrosoftAuthEnabled = config.getMicrosoftConfig().isEnabled(), + isBrowserLoginEnabled = config.isBrowserLoginEnabled(), + isBrowserRegistrationEnabled = config.isBrowserRegistrationEnabled(), isSocialAuthEnabled = config.isSocialAuthEnabled(), isLogistrationEnabled = config.isPreLoginExperienceEnabled(), + isRegistrationEnabled = config.isRegistrationEnabled(), agreement = agreementProvider.getAgreement(isSignIn = true)?.createHonorCodeField(), ) ) @@ -77,6 +89,7 @@ class SignInViewModel( init { collectAppUpgradeEvent() + logSignInScreenEvent() } fun login(username: String, password: String) { @@ -98,6 +111,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 +124,7 @@ class SignInViewModel( ) } ) + appNotifier.send(SignInEvent()) } catch (e: Exception) { if (e is EdxError.InvalidGrantException) { _uiMessage.value = @@ -125,8 +143,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 + } } } } @@ -144,11 +164,41 @@ class SignInViewModel( } } + fun signInBrowser(activityContext: Activity) { + _uiState.update { it.copy(showProgress = true) } + viewModelScope.launch { + runCatching { + browserAuthHelper.signIn(activityContext) + }.onFailure { + logger.e { "Browser auth error: $it" } + } + } + } + fun navigateToSignUp(parentFragmentManager: FragmentManager) { router.navigateToSignUp(parentFragmentManager, null, null) logEvent(AuthAnalyticsEvent.REGISTER_CLICKED) } + fun signInAuthCode(authCode: String) { + _uiState.update { it.copy(showProgress = true) } + viewModelScope.launch { + runCatching { + interactor.loginAuthCode(authCode) + } + .onFailure { + logger.e { "OAuth2 code error: $it" } + onUnknownError() + _uiState.update { it.copy(loginFailure = true) } + }.onSuccess { + _uiState.update { it.copy(loginSuccess = true) } + setUserId() + appNotifier.send(SignInEvent()) + _uiState.update { it.copy(showProgress = false) } + } + } + } + fun navigateToForgotPassword(parentFragmentManager: FragmentManager) { router.navigateToRestorePassword(parentFragmentManager) logEvent(AuthAnalyticsEvent.FORGOT_PASSWORD_CLICKED) @@ -170,6 +220,7 @@ class SignInViewModel( _uiState.update { it.copy(loginSuccess = true) } setUserId() _uiState.update { it.copy(showProgress = false) } + appNotifier.send(SignInEvent()) } } @@ -240,4 +291,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..e182f51d7 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) @@ -124,7 +128,7 @@ internal fun LoginScreen( Image( modifier = Modifier .fillMaxWidth() - .fillMaxHeight(0.3f), + .fillMaxHeight(fraction = 0.3f), painter = painterResource(id = coreR.drawable.core_top_header), contentScale = ContentScale.FillBounds, contentDescription = null @@ -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,57 +221,78 @@ 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( - modifier = Modifier - .fillMaxWidth(), - title = stringResource(id = R.string.auth_email_username), - description = stringResource(id = R.string.auth_enter_email_username), - onValueChanged = { - login = it - }) + if (!state.isBrowserLoginEnabled) { + LoginTextField( + modifier = Modifier + .fillMaxWidth(), + title = stringResource(id = R.string.auth_email_username), + 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( - modifier = Modifier - .fillMaxWidth(), - onValueChanged = { - password = it - }, - onPressDone = { - onEvent(AuthEvent.SignIn(login = login, password = password)) - } - ) + Spacer(modifier = Modifier.height(18.dp)) + PasswordTextField( + modifier = Modifier + .fillMaxWidth(), + onValueChanged = { + password = it + isPasswordError = false + }, + onPressDone = { + keyboardController?.hide() + if (password.isNotEmpty()) { + onEvent(AuthEvent.SignIn(login = login, password = password)) + } else { + isEmailError = login.isEmpty() + isPasswordError = password.isEmpty() + } + }, + isError = isPasswordError, + ) + } else { + Spacer(modifier = Modifier.height(40.dp)) + } Row( Modifier .fillMaxWidth() .padding(top = 20.dp, bottom = 36.dp) ) { - if (state.isLogistrationEnabled.not()) { + if (!state.isBrowserLoginEnabled) { + if (state.isLogistrationEnabled.not() && state.isRegistrationEnabled) { + Text( + modifier = Modifier + .testTag("txt_register") + .noRippleClickable { + onEvent(AuthEvent.RegisterClick) + }, + text = stringResource(id = coreR.string.core_register), + color = MaterialTheme.appColors.primary, + style = MaterialTheme.appTypography.labelLarge + ) + } + Spacer(modifier = Modifier.weight(1f)) Text( modifier = Modifier - .testTag("txt_register") + .testTag("txt_forgot_password") .noRippleClickable { - onEvent(AuthEvent.RegisterClick) + onEvent(AuthEvent.ForgotPasswordClick) }, - text = stringResource(id = coreR.string.core_register), - color = MaterialTheme.appColors.primary, + text = stringResource(id = R.string.auth_forgot_password), + color = MaterialTheme.appColors.infoVariant, style = MaterialTheme.appTypography.labelLarge ) } - Spacer(modifier = Modifier.weight(1f)) - Text( - modifier = Modifier - .testTag("txt_forgot_password") - .noRippleClickable { - onEvent(AuthEvent.ForgotPasswordClick) - }, - text = stringResource(id = R.string.auth_forgot_password), - color = MaterialTheme.appColors.primary, - style = MaterialTheme.appTypography.labelLarge - ) } if (state.showProgress) { @@ -275,8 +301,20 @@ 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)) + if (state.isBrowserLoginEnabled) { + onEvent(AuthEvent.SignInBrowser) + } else { + keyboardController?.hide() + if (login.isNotEmpty() && password.isNotEmpty()) { + onEvent(AuthEvent.SignIn(login = login, password = password)) + } else { + isEmailError = login.isEmpty() + isPasswordError = password.isEmpty() + } + } } ) } @@ -288,6 +326,7 @@ private fun AuthForm( isMicrosoftAuthEnabled = state.isMicrosoftAuthEnabled, isSignIn = true, ) { + keyboardController?.hide() onEvent(AuthEvent.SocialSignIn(it)) } } @@ -297,15 +336,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 +354,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 +365,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 +379,40 @@ 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) @@ -365,6 +431,24 @@ private fun SignInScreenPreview() { } } +@Preview(uiMode = UI_MODE_NIGHT_NO) +@Preview(uiMode = UI_MODE_NIGHT_YES) +@Preview(name = "NEXUS_5_Light", device = Devices.NEXUS_5, uiMode = UI_MODE_NIGHT_NO) +@Preview(name = "NEXUS_5_Dark", device = Devices.NEXUS_5, uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun SignInUsingBrowserScreenPreview() { + OpenEdXTheme { + LoginScreen( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + state = SignInUIState().copy( + isBrowserLoginEnabled = true, + ), + uiMessage = null, + onEvent = {}, + ) + } +} + @Preview(name = "NEXUS_9_Light", device = Devices.NEXUS_9, uiMode = UI_MODE_NIGHT_NO) @Preview(name = "NEXUS_9_Night", device = Devices.NEXUS_9, uiMode = UI_MODE_NIGHT_YES) @Composable 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..a87ffef3e 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 @@ -17,9 +17,9 @@ import org.openedx.auth.data.model.AuthType 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.presentation.global.appupgrade.AppUpgradeRequiredScreen import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.foundation.presentation.rememberWindowSize class SignUpFragment : Fragment() { @@ -66,6 +66,7 @@ class SignUpFragment : Fragment() { this@SignUpFragment, authType ) + AuthType.BROWSER -> null } }, onFieldUpdated = { key, value -> 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..21e12029e 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() { @@ -134,70 +137,89 @@ class SignUpViewModel( fun register() { logEvent(AuthAnalyticsEvent.CREATE_ACCOUNT_CLICKED) - val mapFields = uiState.value.allFields.associate { it.name to it.placeholder } + - mapOf(ApiConstants.RegistrationFields.HONOR_CODE to true.toString()) - val resultMap = mapFields.toMutableMap() - uiState.value.allFields.filter { !it.required }.forEach { (k, _) -> - if (mapFields[k].isNullOrEmpty()) { - resultMap.remove(k) - } - } + val mapFields = prepareMapFields() _uiState.update { it.copy(isButtonLoading = true, validationError = false) } + viewModelScope.launch { try { setErrorInstructions(emptyMap()) val validationFields = interactor.validateRegistrationFields(mapFields) setErrorInstructions(validationFields.validationResult) + if (validationFields.hasValidationError()) { _uiState.update { it.copy(validationError = true, isButtonLoading = false) } } else { - val socialAuth = uiState.value.socialAuth - if (socialAuth?.accessToken != null) { - resultMap[ApiConstants.ACCESS_TOKEN] = socialAuth.accessToken - resultMap[ApiConstants.PROVIDER] = socialAuth.authType.postfix - resultMap[ApiConstants.CLIENT_ID] = config.getOAuthClientId() - } - interactor.register(resultMap.toMap()) - logEvent( - event = AuthAnalyticsEvent.REGISTER_SUCCESS, - params = buildMap { - put( - AuthAnalyticsKey.METHOD.key, - (socialAuth?.authType?.methodName - ?: AuthType.PASSWORD.methodName).lowercase() - ) - } - ) - if (socialAuth == null) { - interactor.login( - resultMap.getValue(ApiConstants.EMAIL), - resultMap.getValue(ApiConstants.PASSWORD) - ) - setUserId() - _uiState.update { it.copy(successLogin = true, isButtonLoading = false) } - } else { - exchangeToken(socialAuth) - } + handleRegistration(mapFields) } } catch (e: Exception) { - _uiState.update { it.copy(isButtonLoading = false) } - 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) - ) - ) + handleRegistrationError(e) + } + } + } + + private fun prepareMapFields(): MutableMap { + val mapFields = uiState.value.allFields.associate { it.name to it.placeholder } + + mapOf(ApiConstants.RegistrationFields.HONOR_CODE to true.toString()) + + return mapFields.toMutableMap().apply { + uiState.value.allFields.filter { !it.required }.forEach { (key, _) -> + if (mapFields[key].isNullOrEmpty()) { + remove(key) } } } } + private suspend fun handleRegistration(mapFields: MutableMap) { + val resultMap = mapFields.toMutableMap() + uiState.value.socialAuth?.let { socialAuth -> + resultMap[ApiConstants.ACCESS_TOKEN] = socialAuth.accessToken + resultMap[ApiConstants.PROVIDER] = socialAuth.authType.postfix + resultMap[ApiConstants.CLIENT_ID] = config.getOAuthClientId() + } + + interactor.register(resultMap) + logRegisterSuccess() + + if (uiState.value.socialAuth == null) { + loginWithCredentials(resultMap) + } else { + exchangeToken(uiState.value.socialAuth!!) + } + } + + private fun logRegisterSuccess() { + logEvent( + AuthAnalyticsEvent.REGISTER_SUCCESS, + buildMap { + put( + AuthAnalyticsKey.METHOD.key, + (uiState.value.socialAuth?.authType?.methodName ?: AuthType.PASSWORD.methodName).lowercase() + ) + } + ) + } + + private suspend fun loginWithCredentials(resultMap: Map) { + interactor.login( + resultMap.getValue(ApiConstants.EMAIL), + resultMap.getValue(ApiConstants.PASSWORD) + ) + setUserId() + _uiState.update { it.copy(successLogin = true, isButtonLoading = false) } + appNotifier.send(SignInEvent()) + } + + private suspend fun handleRegistrationError(e: Exception) { + _uiState.update { it.copy(isButtonLoading = false) } + val errorMessage = if (e.isInternetError()) { + coreR.string.core_error_no_connection + } else { + coreR.string.core_error_unknown_error + } + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(errorMessage))) + } + fun socialAuth(fragment: Fragment, authType: AuthType) { _uiState.update { it.copy(isLoading = true) } viewModelScope.launch { @@ -226,9 +248,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 +277,7 @@ class SignUpViewModel( ) _uiState.update { it.copy(successLogin = true) } logger.d { "Social login (${socialAuth.authType.methodName}) success" } + appNotifier.send(SignInEvent()) } } @@ -269,8 +297,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 +343,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..8b917ebaa 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, @@ -141,7 +141,7 @@ internal fun SignUpView( LaunchedEffect(uiState.validationError) { if (uiState.validationError) { coroutine.launch { - scrollState.animateScrollTo(0, tween(300)) + scrollState.animateScrollTo(0, tween(durationMillis = 300)) haptic.performHapticFeedback(HapticFeedbackType.LongPress) } } @@ -151,7 +151,7 @@ internal fun SignUpView( if (uiState.socialAuth != null) { coroutine.launch { showErrorMap.clear() - scrollState.animateScrollTo(0, tween(300)) + scrollState.animateScrollTo(0, tween(durationMillis = 300)) } } } @@ -173,7 +173,6 @@ internal fun SignUpView( .navigationBarsPadding(), backgroundColor = MaterialTheme.appColors.background ) { - val topBarPadding by remember { mutableStateOf( windowSize.windowSizeValue( @@ -246,7 +245,7 @@ internal fun SignUpView( Image( modifier = Modifier .fillMaxWidth() - .fillMaxHeight(0.3f), + .fillMaxHeight(fraction = 0.3f), painter = painterResource(id = coreR.drawable.core_top_header), contentScale = ContentScale.FillBounds, contentDescription = null @@ -296,8 +295,8 @@ internal fun SignUpView( ) { if (uiState.isLoading) { Box( - Modifier - .fillMaxSize(), contentAlignment = Alignment.Center + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center ) { CircularProgressIndicator(color = MaterialTheme.appColors.primary) } @@ -317,10 +316,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 +329,7 @@ internal fun SignUpView( modifier = Modifier .testTag("txt_sign_up_title") .fillMaxWidth(), - text = stringResource(id = R.string.auth_sign_up), + text = stringResource(id = coreR.string.core_register), color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.displaySmall ) @@ -437,7 +437,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 +454,7 @@ internal fun SignUpView( isMicrosoftAuthEnabled = uiState.isMicrosoftAuthEnabled, isSignIn = false, ) { + keyboardController?.hide() onRegisterClick(it) } } @@ -474,7 +478,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..2045297a5 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.core_ic_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/BrowserAuthHelper.kt b/auth/src/main/java/org/openedx/auth/presentation/sso/BrowserAuthHelper.kt new file mode 100644 index 000000000..cd3233b39 --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/sso/BrowserAuthHelper.kt @@ -0,0 +1,35 @@ +package org.openedx.auth.presentation.sso + +import android.app.Activity +import android.content.Intent.FLAG_ACTIVITY_NEW_TASK +import android.net.Uri +import androidx.annotation.WorkerThread +import androidx.browser.customtabs.CustomTabsIntent +import org.openedx.core.ApiConstants +import org.openedx.core.config.Config +import org.openedx.core.utils.Logger + +class BrowserAuthHelper(private val config: Config) { + + private val logger = Logger(TAG) + + @WorkerThread + suspend fun signIn(activityContext: Activity) { + logger.d { "Browser-based auth initiated" } + val uri = Uri.parse("${config.getApiHostURL()}${ApiConstants.URL_AUTHORIZE}").buildUpon() + .appendQueryParameter("client_id", config.getOAuthClientId()) + .appendQueryParameter( + "redirect_uri", + "${activityContext.packageName}://${ApiConstants.BrowserLogin.REDIRECT_HOST}" + ) + .appendQueryParameter("response_type", ApiConstants.BrowserLogin.RESPONSE_TYPE).build() + val intent = + CustomTabsIntent.Builder().setUrlBarHidingEnabled(true).setShowTitle(true).build() + intent.intent.flags = FLAG_ACTIVITY_NEW_TASK + intent.launchUrl(activityContext, uri) + } + + private companion object { + const val TAG = "BrowserAuthHelper" + } +} 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/sso/OAuthHelper.kt b/auth/src/main/java/org/openedx/auth/presentation/sso/OAuthHelper.kt index 776df7c46..ccb094fae 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/sso/OAuthHelper.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/sso/OAuthHelper.kt @@ -21,6 +21,7 @@ class OAuthHelper( AuthType.GOOGLE -> googleAuthHelper.socialAuth(fragment.requireActivity()) AuthType.FACEBOOK -> facebookAuthHelper.socialAuth(fragment) AuthType.MICROSOFT -> microsoftAuthHelper.socialAuth(fragment.requireActivity()) + AuthType.BROWSER -> null } } 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..61d8f7450 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,17 +12,21 @@ 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 import androidx.compose.material.TextFieldDefaults import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight 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, @@ -127,9 +136,7 @@ fun RequiredFields( ) } - RegistrationFieldType.UNKNOWN -> { - - } + RegistrationFieldType.UNKNOWN -> {} } } } @@ -146,7 +153,8 @@ fun OptionalFields( Column { 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] @@ -170,7 +178,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) }, @@ -192,7 +201,8 @@ fun OptionalFields( ?: "", onClick = { serverName, list -> onSelectClick(serverName, field, list) - }) + } + ) } RegistrationFieldType.TEXTAREA -> { @@ -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 = "") { @@ -477,7 +524,7 @@ fun ExpandableText( } else { stringResource(id = R.string.auth_show_optional_fields) } - val icon = Icons.Filled.ChevronRight + val icon = Icons.AutoMirrored.Filled.KeyboardArrowRight Row( modifier = modifier @@ -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 @@ -513,9 +579,7 @@ fun SelectRegistrationFieldPreview() { field, false, initialValue = "", - onClick = { _, _ -> - - } + onClick = { _, _ -> } ) } } @@ -531,9 +595,7 @@ fun InputRegistrationFieldPreview() { modifier = Modifier.fillMaxWidth(), isErrorShown = false, registrationField = field, - onValueChanged = { _, _, _ -> - - } + onValueChanged = { _, _, _ -> } ) } } @@ -547,7 +609,7 @@ private fun OptionalFieldsPreview() { Column(Modifier.background(MaterialTheme.appColors.background)) { val optionalField = field.copy(required = false) OptionalFields( - fields = List(3) { optionalField }, + fields = List(size = 3) { optionalField }, showErrorMap = SnapshotStateMap(), selectableNamesMap = SnapshotStateMap(), onSelectClick = { _, _, _ -> }, 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..e4962d072 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 = { @@ -54,7 +54,7 @@ internal fun SocialAuthView( ) { Row(verticalAlignment = Alignment.CenterVertically) { Icon( - painter = painterResource(id = R.drawable.ic_auth_google), + painter = painterResource(id = R.drawable.auth_ic_google), contentDescription = null, tint = Color.Unspecified, ) @@ -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, ) } } @@ -85,15 +86,15 @@ internal fun SocialAuthView( ) { Row(verticalAlignment = Alignment.CenterVertically) { Icon( - painter = painterResource(id = R.drawable.ic_auth_facebook), + painter = painterResource(id = R.drawable.auth_ic_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) ) } @@ -117,7 +118,7 @@ internal fun SocialAuthView( ) { Row(verticalAlignment = Alignment.CenterVertically) { Icon( - painter = painterResource(id = R.drawable.ic_auth_microsoft), + painter = painterResource(id = R.drawable.auth_ic_microsoft), contentDescription = null, tint = Color.Unspecified, ) @@ -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) ) } @@ -139,6 +140,6 @@ internal fun SocialAuthView( @Composable private fun SocialAuthViewPreview() { OpenEdXTheme { - SocialAuthView() {} + SocialAuthView {} } } diff --git a/auth/src/main/res/drawable/ic_auth_facebook.xml b/auth/src/main/res/drawable/auth_ic_facebook.xml similarity index 100% rename from auth/src/main/res/drawable/ic_auth_facebook.xml rename to auth/src/main/res/drawable/auth_ic_facebook.xml diff --git a/auth/src/main/res/drawable/ic_auth_google.xml b/auth/src/main/res/drawable/auth_ic_google.xml similarity index 100% rename from auth/src/main/res/drawable/ic_auth_google.xml rename to auth/src/main/res/drawable/auth_ic_google.xml diff --git a/auth/src/main/res/drawable/auth_ic_microsoft.xml b/auth/src/main/res/drawable/auth_ic_microsoft.xml new file mode 100644 index 000000000..30170272a --- /dev/null +++ b/auth/src/main/res/drawable/auth_ic_microsoft.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/auth/src/main/res/drawable/ic_auth_microsoft.xml b/auth/src/main/res/drawable/ic_auth_microsoft.xml deleted file mode 100644 index ce31faab7..000000000 --- a/auth/src/main/res/drawable/ic_auth_microsoft.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - diff --git a/auth/src/main/res/values-uk/strings.xml b/auth/src/main/res/values-uk/strings.xml deleted file mode 100644 index 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..77401c27f 100644 --- a/auth/src/main/res/values/strings.xml +++ b/auth/src/main/res/values/strings.xml @@ -4,14 +4,13 @@ What do you want to learn? Search our 3000+ courses Explore all courses - Sign up Forgot password? Email Invalid email 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 +21,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 +41,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..4e780121d 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 @@ -176,18 +176,17 @@ class RestorePasswordViewModelTest { assertEquals(somethingWrong, message?.message) } - @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..52c9e96a7 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 @@ -26,19 +26,23 @@ import org.openedx.auth.domain.interactor.AuthInteractor import org.openedx.auth.presentation.AgreementProvider import org.openedx.auth.presentation.AuthAnalytics import org.openedx.auth.presentation.AuthRouter +import org.openedx.auth.presentation.sso.BrowserAuthHelper 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 +60,14 @@ 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 browserAuthHelper = mockk() private val invalidCredential = "Invalid credentials" private val noInternet = "Slow or no internet connection" @@ -78,13 +85,20 @@ 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 + every { config.isBrowserLoginEnabled() } returns false + every { config.isBrowserRegistrationEnabled() } returns false } @After @@ -104,19 +118,24 @@ class SignInViewModelTest { preferencesManager = preferencesManager, validator = validator, analytics = analytics, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, router = router, whatsNewGlobalManager = whatsNewGlobalManager, + browserAuthHelper = browserAuthHelper, courseId = "", infoType = "", + calendarInteractor = calendarInteractor, + calendarPreferences = calendarPreferences, + authCode = "", ) 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,14 +156,18 @@ class SignInViewModelTest { preferencesManager = preferencesManager, validator = validator, analytics = analytics, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, router = router, whatsNewGlobalManager = whatsNewGlobalManager, + browserAuthHelper = browserAuthHelper, courseId = "", infoType = "", + calendarInteractor = calendarInteractor, + calendarPreferences = calendarPreferences, + authCode = "", ) viewModel.login("acc@test.o", "") coVerify(exactly = 0) { interactor.login(any(), any()) } @@ -171,14 +194,18 @@ class SignInViewModelTest { preferencesManager = preferencesManager, validator = validator, analytics = analytics, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, router = router, whatsNewGlobalManager = whatsNewGlobalManager, + browserAuthHelper = browserAuthHelper, courseId = "", infoType = "", + calendarInteractor = calendarInteractor, + calendarPreferences = calendarPreferences, + authCode = "", ) viewModel.login("acc@test.org", "") @@ -204,20 +231,25 @@ class SignInViewModelTest { preferencesManager = preferencesManager, validator = validator, analytics = analytics, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, router = router, whatsNewGlobalManager = whatsNewGlobalManager, + browserAuthHelper = browserAuthHelper, courseId = "", infoType = "", + calendarInteractor = calendarInteractor, + calendarPreferences = calendarPreferences, + authCode = "", ) 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,20 +265,25 @@ 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, router = router, whatsNewGlobalManager = whatsNewGlobalManager, + browserAuthHelper = browserAuthHelper, courseId = "", infoType = "", + calendarInteractor = calendarInteractor, + calendarPreferences = calendarPreferences, + authCode = "", ) coEvery { interactor.login("acc@test.org", "edx") } returns Unit viewModel.login("acc@test.org", "edx") @@ -255,7 +292,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,14 +313,18 @@ class SignInViewModelTest { preferencesManager = preferencesManager, validator = validator, analytics = analytics, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, router = router, whatsNewGlobalManager = whatsNewGlobalManager, + browserAuthHelper = browserAuthHelper, courseId = "", infoType = "", + calendarInteractor = calendarInteractor, + calendarPreferences = calendarPreferences, + authCode = "", ) coEvery { interactor.login("acc@test.org", "edx") } throws UnknownHostException() viewModel.login("acc@test.org", "edx") @@ -291,7 +333,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,14 +356,18 @@ class SignInViewModelTest { preferencesManager = preferencesManager, validator = validator, analytics = analytics, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, router = router, whatsNewGlobalManager = whatsNewGlobalManager, + browserAuthHelper = browserAuthHelper, courseId = "", infoType = "", + calendarInteractor = calendarInteractor, + calendarPreferences = calendarPreferences, + authCode = "", ) coEvery { interactor.login("acc@test.org", "edx") } throws EdxError.InvalidGrantException() viewModel.login("acc@test.org", "edx") @@ -328,8 +375,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,14 +399,18 @@ class SignInViewModelTest { preferencesManager = preferencesManager, validator = validator, analytics = analytics, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, router = router, whatsNewGlobalManager = whatsNewGlobalManager, + browserAuthHelper = browserAuthHelper, courseId = "", infoType = "", + calendarInteractor = calendarInteractor, + calendarPreferences = calendarPreferences, + authCode = "", ) coEvery { interactor.login("acc@test.org", "edx") } throws IllegalStateException() viewModel.login("acc@test.org", "edx") @@ -366,8 +418,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..7426f752b 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,9 +383,9 @@ 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 + // val fields = viewModel.uiState.value as? SignUpUIState.Fields assertFalse(viewModel.uiState.value.isLoading) } diff --git a/build.gradle b/build.gradle index ef9ca662c..674a1057f 100644 --- a/build.gradle +++ b/build.gradle @@ -1,23 +1,78 @@ +import io.gitlab.arturbosch.detekt.Detekt import org.edx.builder.ConfigHelper +import org.jetbrains.kotlin.gradle.dsl.JvmTarget import java.util.regex.Matcher 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' + // Plugin versions + android_gradle_plugin_version = '8.12.2' + google_services_version = '4.4.3' + firebase_crashlytics_version = '3.0.6' + ksp_version = '2.2.10-2.0.2' + + //Depends on versions in OEXFoundation + kotlin_version = '2.2.10' + room_version = '2.7.2' + detekt_version = '1.23.8' + + // Library versions + media3_version = "1.8.0" + youtubeplayer_version = "13.0.0" + firebase_version = "33.0.0" + jsoup_version = '1.21.2' + in_app_review = '2.0.2' + extented_spans_version = "1.4.0" + zip_version = '2.11.5' + + // Third-party library versions + branch_sdk_version = '5.20.0' + play_services_ads_identifier_version = '18.2.0' + install_referrer_version = '2.2' + snakeyaml_version = '2.4' + openedx_foundation_version = '1.0.2' + openedx_firebase_analytics_version = '1.0.1' + braze_sdk_version = '37.0.0' + + // AndroidX library versions + core_splashscreen_version = '1.0.1' + activity_compose_version = '1.10.1' + browser_version = '1.9.0' + credentials_version = '1.5.0' + + // Social login versions + facebook_login_version = '18.1.3' + play_services_auth_version = '21.4.0' + googleid_version = '1.1.1' + msal_version = '7.0.0' + + // OpenTelemetry versions + opentelemetry_version = '1.53.0' + + // Testing versions + compose_ui_tooling = '1.7.8' + mockk_version = '1.14.5' + android_arch_version = '2.2.0' + junit_version = '4.13.2' + test_ext_version = '1.3.0' + espresso_version = '3.7.0' + kotlinx_coroutines_test_version = '1.10.2' } } plugins { - id 'com.android.application' version '8.4.0' apply false - id 'com.android.library' version '8.4.0' apply false + //noinspection GradlePluginVersion + id 'com.android.application' version "$android_gradle_plugin_version" apply false + //noinspection GradlePluginVersion + id 'com.android.library' version "$android_gradle_plugin_version" 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 "$google_services_version" apply false + id "com.google.firebase.crashlytics" version "$firebase_crashlytics_version" apply false + id "com.google.devtools.ksp" version "$ksp_version" apply false + id "org.jetbrains.kotlin.plugin.compose" version "$kotlin_version" apply false + id 'io.gitlab.arturbosch.detekt' version "$detekt_version" apply false } tasks.register('clean', Delete) { @@ -25,43 +80,14 @@ 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" - 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' - - 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" + // Android SDK versions + compile_sdk_version = 36 + target_sdk_version = 36 + min_sdk_version = 24 + java_version = JavaVersion.VERSION_17 + jvm_target_version = JvmTarget.JVM_17 configHelper = new ConfigHelper(projectDir, getCurrentFlavor()) - - //testing - mockk_version = '1.13.3' - android_arch_version = '2.2.0' - junit_version = '4.13.2' } def getCurrentFlavor() { @@ -81,6 +107,40 @@ def getCurrentFlavor() { } } -task generateMockedRawFile() { +tasks.register('generateMockedRawFile') { doLast { configHelper.generateMicrosoftConfig() } } + +def projectSource = file(projectDir) +def configFile = files("$rootDir/config/detekt.yml") +def basePathFile = rootProject.projectDir.absolutePath +def kotlinFiles = "**/*.kt" +def resourceFiles = "**/resources/**" +def buildFiles = "**/build/**" + +apply plugin: 'io.gitlab.arturbosch.detekt' + +tasks.register("detektAll", Detekt) { + def autoFix = project.hasProperty('detektAutoFix') + + description = "Custom DETEKT build for all modules" + parallel = true + ignoreFailures = false + autoCorrect = autoFix + buildUponDefaultConfig = true + setSource(projectSource) + config.setFrom(configFile) + include(kotlinFiles) + basePath(basePathFile) + exclude(resourceFiles, buildFiles) + reports { + html.enabled(true) + xml.enabled(true) + txt.enabled(false) + sarif.enabled(true) + } +} + +dependencies { + detektPlugins "io.gitlab.arturbosch.detekt:detekt-formatting:$detekt_version" +} diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle index f1d8de5cb..4532d0758 100644 --- a/buildSrc/build.gradle +++ b/buildSrc/build.gradle @@ -14,5 +14,5 @@ java { dependencies { implementation localGroovy() implementation gradleApi() - implementation 'org.yaml:snakeyaml:1.33' + implementation "org.yaml:snakeyaml:2.4" } diff --git a/config/detekt.yml b/config/detekt.yml new file mode 100644 index 000000000..37d257629 --- /dev/null +++ b/config/detekt.yml @@ -0,0 +1,90 @@ +build: + maxIssues: 0 + weights: + complexity: 2 + LongParameterList: 1 + style: 1 + +config: + validation: true + +processors: + active: true + exclude: + - 'FunctionCountProcessor' + - 'PropertyCountProcessor' + +console-reports: + active: true + +naming: + active: true + TopLevelPropertyNaming: + active: true + constantPattern: '[A-Z][_A-Za-z0-9]*' + propertyPattern: '[A-Za-z][_A-Za-z0-9]*' + privatePropertyPattern: '(_)?[A-Za-z][A-Za-z0-9]*' + FunctionNaming: + active: true + functionPattern: '[a-zA-Z][a-zA-Z0-9]*' + excludeClassPattern: '$^' + ignoreAnnotated: [ 'Composable' ] + +style: + active: true + MagicNumber: + active: true + ignorePropertyDeclaration: true + ignoreCompanionObjectPropertyDeclaration: true + ignoreNumbers: [ '-1', '0', '1', '2', '10', '100', '90', '-90', '180', '1000', '400', '402', '401', '403', '404', '426', '500' ] + ignoreNamedArgument: true + ignoreEnums: true + UnusedPrivateMember: + active: true + ignoreAnnotated: + - 'Preview' + +complexity: + active: true + LongMethod: + active: true + ignoreAnnotated: [ 'Composable' ] + ignoreFunction: [ 'onCreateView' ] + LongParameterList: + active: true + functionThreshold: 15 + constructorThreshold: 20 + ignoreDataClasses: true + ignoreAnnotated: [ 'Composable' ] + TooManyFunctions: + active: true + thresholdInClasses: 21 + thresholdInInterfaces: 20 + ignoreAnnotatedFunctions: [ 'Composable' ] + ignoreOverridden: true + ignorePrivate: true + CyclomaticComplexMethod: + active: true + ignoreAnnotated: [ 'Composable' ] + ComplexCondition: + active: true + threshold: 6 + +exceptions: + active: true + TooGenericExceptionCaught: + active: false + PrintStackTrace: + active: false + InstanceOfCheckForException: + active: false + +performance: + active: true + SpreadOperator: + active: false + +formatting: + active: true + Indentation: + active: false diff --git a/core/build.gradle b/core/build.gradle index f1f091823..19be1f57a 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:$snakeyaml_version" } } @@ -12,25 +12,26 @@ 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 + namespace 'org.openedx.core' + compileSdkVersion compile_sdk_version defaultConfig { - minSdk 24 - targetSdk 34 + minSdk min_sdk_version + targetSdk target_sdk_version testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles "consumer-rules.pro" } - namespace 'org.openedx.core' flavorDimensions += "env" productFlavors { @@ -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 { @@ -75,88 +76,56 @@ android { } } compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 + sourceCompatibility java_version + targetCompatibility java_version } - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17 + kotlin { + compilerOptions { + jvmTarget = jvm_target_version + freeCompilerArgs = ['-XXLanguage:+PropertyParamAnnotationDefaultTargetMode'] + } } - buildFeatures { viewBinding true 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" - testImplementation 'junit:junit:4.13.2' - androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + // Branch SDK Integration + api "io.branch.sdk.android:library:$branch_sdk_version" + api "com.google.android.gms:play-services-ads-identifier:$play_services_ads_identifier_version" + api "com.android.installreferrer:installreferrer:$install_referrer_version" + + // Zip + api "net.lingala.zip4j:zip4j:$zip_version" + + // OpenEdx libs + api("com.github.openedx:openedx-app-foundation-android:$openedx_foundation_version") + + // Preview + debugApi "androidx.compose.ui:ui-tooling:$compose_ui_tooling" + + testImplementation "junit:junit:$junit_version" + androidTestImplementation "androidx.test.ext:junit:$test_ext_version" + androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_version" } 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/androidTest/java/org/openedx/core/ExampleInstrumentedTest.kt b/core/src/androidTest/java/org/openedx/core/ExampleInstrumentedTest.kt index 0c5df88c3..a3fa4cf52 100644 --- a/core/src/androidTest/java/org/openedx/core/ExampleInstrumentedTest.kt +++ b/core/src/androidTest/java/org/openedx/core/ExampleInstrumentedTest.kt @@ -1,13 +1,11 @@ package org.openedx.core -import androidx.test.platform.app.InstrumentationRegistry import androidx.test.ext.junit.runners.AndroidJUnit4 - +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith -import org.junit.Assert.* - /** * Instrumented test, which will execute on an Android device. * @@ -21,4 +19,4 @@ class ExampleInstrumentedTest { val appContext = InstrumentationRegistry.getInstrumentation().targetContext assertEquals("org.openedx.core.test", appContext.packageName) } -} \ No newline at end of file +} diff --git a/core/src/main/java/org/openedx/core/ApiConstants.kt b/core/src/main/java/org/openedx/core/ApiConstants.kt index 786d63cc4..959d3c224 100644 --- a/core/src/main/java/org/openedx/core/ApiConstants.kt +++ b/core/src/main/java/org/openedx/core/ApiConstants.kt @@ -2,6 +2,7 @@ package org.openedx.core object ApiConstants { const val URL_LOGIN = "/oauth2/login/" + const val URL_AUTHORIZE = "/oauth2/authorize/" const val URL_ACCESS_TOKEN = "/oauth2/access_token/" const val URL_EXCHANGE_TOKEN = "/oauth2/exchange_access_token/{auth_type}/" const val GET_USER_PROFILE = "/api/mobile/v0.5/my_user_info" @@ -9,15 +10,18 @@ object ApiConstants { const val URL_REGISTRATION_FIELDS = "/user_api/v1/account/registration" const val URL_VALIDATE_REGISTRATION_FIELDS = "/api/user/v1/validation/registration" const val URL_REGISTER = "/api/user/v1/account/registration/" + const val URL_REGISTER_BROWSER = "/register" const val URL_PASSWORD_RESET = "/password_reset/" const val GRANT_TYPE_PASSWORD = "password" + const val GRANT_TYPE_CODE = "authorization_code" const val TOKEN_TYPE_BEARER = "Bearer" const val TOKEN_TYPE_JWT = "jwt" const val TOKEN_TYPE_REFRESH = "refresh_token" const val ACCESS_TOKEN = "access_token" + const val CLIENT_ID = "client_id" const val EMAIL = "email" const val NAME = "name" @@ -27,6 +31,7 @@ object ApiConstants { const val AUTH_TYPE_GOOGLE = "google-oauth2" const val AUTH_TYPE_FB = "facebook" const val AUTH_TYPE_MICROSOFT = "azuread-oauth2" + const val AUTH_TYPE_BROWSER = "browser" const val COURSE_KEY = "course_key" @@ -34,4 +39,10 @@ object ApiConstants { const val HONOR_CODE = "honor_code" const val MARKETING_EMAILS = "marketing_emails_opt_in" } + + object BrowserLogin { + const val REDIRECT_HOST = "oauth2Callback" + const val CODE_QUERY_PARAM = "code" + const val RESPONSE_TYPE = "code" + } } diff --git a/core/src/main/java/org/openedx/core/AppDataConstants.kt b/core/src/main/java/org/openedx/core/AppDataConstants.kt index eb2580e99..cf6766ac1 100644 --- a/core/src/main/java/org/openedx/core/AppDataConstants.kt +++ b/core/src/main/java/org/openedx/core/AppDataConstants.kt @@ -1,12 +1,12 @@ package org.openedx.core -import java.util.* +import java.util.Locale object AppDataConstants { const val USER_MIN_YEAR = 13 const val USER_MAX_YEAR = 77 const val DEFAULT_MIME_TYPE = "image/jpeg" - val defaultLocale = Locale("en") + val defaultLocale: Locale = Locale.Builder().setLanguage("en").build() const val VIDEO_FORMAT_M3U8 = ".m3u8" const val VIDEO_FORMAT_MP4 = ".mp4" diff --git a/core/src/main/java/org/openedx/core/AppUpdateState.kt b/core/src/main/java/org/openedx/core/AppUpdateState.kt index bf347cd29..9c016581d 100644 --- a/core/src/main/java/org/openedx/core/AppUpdateState.kt +++ b/core/src/main/java/org/openedx/core/AppUpdateState.kt @@ -3,22 +3,29 @@ package org.openedx.core import android.content.ActivityNotFoundException 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 androidx.core.net.toUri +import org.openedx.core.system.notifier.app.AppUpgradeEvent object AppUpdateState { var wasUpdateDialogDisplayed = false - var wasUpdateDialogClosed = mutableStateOf(false) + var wasUpgradeDialogClosed = mutableStateOf(false) + var lastAppUpgradeEvent: AppUpgradeEvent? = null fun openPlayMarket(context: Context) { try { - context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=${context.packageName}"))) + context.startActivity( + Intent( + Intent.ACTION_VIEW, + "market://details?id=${context.packageName}".toUri() + ) + ) } catch (e: ActivityNotFoundException) { + e.printStackTrace() context.startActivity( Intent( Intent.ACTION_VIEW, - Uri.parse("https://play.google.com/store/apps/details?id=${context.packageName}") + "https://play.google.com/store/apps/details?id=${context.packageName}".toUri() ) ) } @@ -26,9 +33,9 @@ object AppUpdateState { data class AppUpgradeParameters( val appUpgradeEvent: AppUpgradeEvent? = null, - val wasUpdateDialogClosed: Boolean = AppUpdateState.wasUpdateDialogClosed.value, + val wasUpgradeDialogClosed: Boolean = AppUpdateState.wasUpgradeDialogClosed.value, val appUpgradeRecommendedDialog: () -> Unit = {}, val onAppUpgradeRecommendedBoxClick: () -> Unit = {}, val onAppUpgradeRequired: () -> Unit = {}, ) -} \ No newline at end of file +} 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/BlockType.kt b/core/src/main/java/org/openedx/core/BlockType.kt index 07a7bf882..b680c68e2 100644 --- a/core/src/main/java/org/openedx/core/BlockType.kt +++ b/core/src/main/java/org/openedx/core/BlockType.kt @@ -1,42 +1,80 @@ package org.openedx.core enum class BlockType { - CHAPTER{ override fun isContainer() = true }, - COURSE{ override fun isContainer() = true }, - DISCUSSION{ override fun isContainer() = false }, - DRAG_AND_DROP_V2{ override fun isContainer() = false }, - HTML{ override fun isContainer() = false }, - LTI_CONSUMER{ override fun isContainer() = false }, - OPENASSESSMENT{ override fun isContainer() = false }, - OTHERS{ override fun isContainer() = false }, - PROBLEM{ override fun isContainer() = false }, - SECTION{ override fun isContainer() = true }, - SEQUENTIAL{ override fun isContainer() = true }, - VERTICAL{ override fun isContainer() = true }, - VIDEO{ override fun isContainer() = false }, - WORD_CLOUD{ override fun isContainer() = false }, - SURVEY{ override fun isContainer() = false }; + CHAPTER { + override fun isContainer() = true + }, + COURSE { + override fun isContainer() = true + }, + DISCUSSION { + override fun isContainer() = false + }, + DRAG_AND_DROP_V2 { + override fun isContainer() = false + }, + HTML { + override fun isContainer() = false + }, + LTI_CONSUMER { + override fun isContainer() = false + }, + OPENASSESSMENT { + override fun isContainer() = false + }, + OTHERS { + override fun isContainer() = false + }, + PROBLEM { + override fun isContainer() = false + }, + SECTION { + override fun isContainer() = true + }, + SEQUENTIAL { + override fun isContainer() = true + }, + VERTICAL { + override fun isContainer() = true + }, + VIDEO { + override fun isContainer() = false + }, + WORD_CLOUD { + override fun isContainer() = false + }, + SURVEY { + override fun isContainer() = false + }; - abstract fun isContainer() : Boolean + abstract fun isContainer(): Boolean + + companion object { + private const val PROBLEM_PRIORITY = 1 + private const val VIDEO_PRIORITY = 2 + private const val DISCUSSION_PRIORITY = 3 + private const val HTML_PRIORITY = 4 - companion object{ fun getBlockType(type: String): BlockType { - val actualType = if (type.contains("-")){ + val actualType = if (type.contains("-")) { type.replace("-", "_") - } else type + } else { + type + } return try { BlockType.valueOf(actualType.uppercase()) - } catch (e : Exception){ + } catch (e: Exception) { + e.printStackTrace() OTHERS } } fun sortByPriority(blockTypes: List): List { val priorityMap = mapOf( - PROBLEM to 1, - VIDEO to 2, - DISCUSSION to 3, - HTML to 4 + PROBLEM to PROBLEM_PRIORITY, + VIDEO to VIDEO_PRIORITY, + DISCUSSION to DISCUSSION_PRIORITY, + HTML to HTML_PRIORITY ) val comparator = Comparator { blockType1, blockType2 -> val priority1 = priorityMap[blockType1] ?: Int.MAX_VALUE @@ -47,4 +85,3 @@ enum class BlockType { } } } - 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/FragmentViewType.kt b/core/src/main/java/org/openedx/core/FragmentViewType.kt index 97ebfeed5..e66618bb8 100644 --- a/core/src/main/java/org/openedx/core/FragmentViewType.kt +++ b/core/src/main/java/org/openedx/core/FragmentViewType.kt @@ -1,5 +1,5 @@ package org.openedx.core enum class FragmentViewType { - MAIN_CONTENT, FULL_CONTENT; -} \ No newline at end of file + MAIN_CONTENT, FULL_CONTENT +} diff --git a/core/src/main/java/org/openedx/core/Mock.kt b/core/src/main/java/org/openedx/core/Mock.kt new file mode 100644 index 000000000..445fc7a05 --- /dev/null +++ b/core/src/main/java/org/openedx/core/Mock.kt @@ -0,0 +1,263 @@ +package org.openedx.core + +import org.openedx.core.data.model.room.VideoProgressEntity +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 +import org.openedx.core.domain.model.CourseDatesBannerInfo +import org.openedx.core.domain.model.CourseDatesResult +import org.openedx.core.domain.model.CourseProgress +import org.openedx.core.domain.model.CourseStructure +import org.openedx.core.domain.model.CoursewareAccess +import org.openedx.core.domain.model.EncodedVideos +import org.openedx.core.domain.model.OfflineDownload +import org.openedx.core.domain.model.Progress +import org.openedx.core.domain.model.ResetCourseDates +import org.openedx.core.domain.model.StudentViewData +import org.openedx.core.domain.model.VideoInfo +import org.openedx.core.module.db.DownloadModel +import org.openedx.core.module.db.DownloadedState +import org.openedx.core.module.db.FileType +import java.util.Date + +object Mock { + private val mockAssignmentProgress = AssignmentProgress( + assignmentType = "Home", + numPointsEarned = 1f, + numPointsPossible = 3f, + shortLabel = "HM1" + ) + val mockChapterBlock = Block( + id = "id", + blockId = "blockId", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.CHAPTER, + displayName = "Chapter", + graded = false, + studentViewData = null, + studentViewMultiDevice = false, + blockCounts = BlockCounts(1), + descendants = emptyList(), + descendantsType = BlockType.CHAPTER, + completion = 0.0, + containsGatedContent = false, + assignmentProgress = mockAssignmentProgress, + due = Date(), + offlineDownload = null + ) + private val mockSequentialBlock = Block( + id = "id", + blockId = "blockId", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.SEQUENTIAL, + displayName = "Sequential", + graded = false, + studentViewData = null, + studentViewMultiDevice = false, + blockCounts = BlockCounts(1), + descendants = emptyList(), + descendantsType = BlockType.CHAPTER, + completion = 0.0, + containsGatedContent = false, + assignmentProgress = mockAssignmentProgress, + due = Date(), + offlineDownload = OfflineDownload("fileUrl", "", 1), + ) + + val mockCourseStructure = CourseStructure( + root = "", + blockData = listOf(mockSequentialBlock, mockSequentialBlock), + id = "id", + name = "Course name", + number = "", + org = "Org", + start = Date(), + startDisplay = "", + startType = "", + end = Date(), + coursewareAccess = CoursewareAccess( + true, + "", + "", + "", + "", + "" + ), + media = null, + certificate = null, + isSelfPaced = false, + progress = Progress(1, 3), + ) + + val mockCourseComponentStatus = CourseComponentStatus( + lastVisitedBlockId = "video1" + ) + + val mockCourseDatesBannerInfo = CourseDatesBannerInfo( + missedDeadlines = false, + missedGatedContent = false, + contentTypeGatingEnabled = false, + verifiedUpgradeLink = "", + hasEnded = false + ) + + val mockCourseDatesResult = CourseDatesResult( + datesSection = linkedMapOf(), + courseBanner = mockCourseDatesBannerInfo + ) + + val mockCourseProgress = CourseProgress( + verifiedMode = "audit", + accessExpiration = "", + certificateData = null, + completionSummary = null, + courseGrade = null, + creditCourseRequirements = "", + end = "", + enrollmentMode = "audit", + gradingPolicy = null, + hasScheduledContent = false, + sectionScores = emptyList(), + studioUrl = "", + username = "testuser", + userHasPassingGrade = false, + verificationData = null, + disableProgressGraph = false + ) + + val mockVideoProgress = VideoProgressEntity( + blockId = "video1", + videoUrl = "test-video-url", + videoTime = 1000L, + duration = 5000L + ) + + val mockResetCourseDates = ResetCourseDates( + message = "Dates reset successfully", + body = "Your course dates have been reset", + header = "Success", + link = "", + linkText = "" + ) + + val mockDownloadModel = DownloadModel( + id = "video1", + title = "Video 1", + courseId = "test-course-id", + size = 1000L, + path = "/test/path/video1", + url = "test-url", + type = FileType.VIDEO, + downloadedState = DownloadedState.NOT_DOWNLOADED, + lastModified = null + ) + + val mockVideoBlock = Block( + id = "video1", + blockId = "video1", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.VIDEO, + displayName = "Video 1", + graded = false, + studentViewData = StudentViewData( + onlyOnWeb = false, + duration = "", + transcripts = null, + encodedVideos = EncodedVideos( + youtube = null, + hls = null, + fallback = null, + desktopMp4 = null, + mobileHigh = null, + mobileLow = VideoInfo( + url = "test-url", + fileSize = 1000L + ) + ), + topicId = "" + ), + studentViewMultiDevice = false, + blockCounts = BlockCounts(0), + descendants = emptyList(), + descendantsType = BlockType.VIDEO, + completion = 0.0, + containsGatedContent = false, + assignmentProgress = null, + due = null, + offlineDownload = null, + ) + + val mockSequentialBlockForDownload = Block( + id = "sequential1", + blockId = "sequential1", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.SEQUENTIAL, + displayName = "Sequential 1", + graded = false, + studentViewData = null, + studentViewMultiDevice = false, + blockCounts = BlockCounts(0), + descendants = listOf("vertical1"), + descendantsType = BlockType.VERTICAL, + completion = 0.0, + containsGatedContent = false, + assignmentProgress = null, + due = null, + offlineDownload = null, + ) + + val mockVerticalBlock = Block( + id = "vertical1", + blockId = "vertical1", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.VERTICAL, + displayName = "Vertical 1", + graded = false, + studentViewData = null, + studentViewMultiDevice = false, + blockCounts = BlockCounts(0), + descendants = listOf("video1"), + descendantsType = BlockType.VIDEO, + completion = 0.0, + containsGatedContent = false, + assignmentProgress = null, + due = null, + offlineDownload = null, + ) + + val mockCourseStructureForDownload = CourseStructure( + root = "sequential1", + blockData = listOf(mockSequentialBlockForDownload, mockVerticalBlock, mockVideoBlock), + id = "test-course-id", + name = "Test Course", + number = "CS101", + org = "TestOrg", + start = Date(), + startDisplay = "2024-01-01", + startType = "timestamped", + end = Date(), + coursewareAccess = CoursewareAccess( + true, + "", + "", + "", + "", + "" + ), + media = null, + certificate = null, + isSelfPaced = false, + progress = null + ) +} 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..559cf05d1 --- /dev/null +++ b/core/src/main/java/org/openedx/core/NoContentScreenType.kt @@ -0,0 +1,39 @@ +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_ASSIGNMENT( + iconResId = R.drawable.core_ic_no_content, + messageResId = R.string.core_no_assignments + ), + 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 + ), + COURSE_PROGRESS( + iconResId = R.drawable.core_ic_no_content, + messageResId = R.string.core_no_progress + ), +} diff --git a/core/src/main/java/org/openedx/core/SingleEventLiveData.kt b/core/src/main/java/org/openedx/core/SingleEventLiveData.kt deleted file mode 100644 index dfa53c6dd..000000000 --- a/core/src/main/java/org/openedx/core/SingleEventLiveData.kt +++ /dev/null @@ -1,39 +0,0 @@ -package org.openedx.core - -import androidx.annotation.MainThread -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.Observer - -class SingleEventLiveData : MutableLiveData() { - - @MainThread - override fun observe(owner: LifecycleOwner, observer: Observer) { - - // Being strict about the observer numbers is up to you - // I thought it made sense to only allow one to handle the event - if (hasActiveObservers()) { - throw IllegalAccessException("Only one observer at a time may observe to a SingleEventLiveData") - } - - super.observe(owner, Observer { data -> - // We ignore any null values and early return - if (data == null) return@Observer - observer.onChanged(data) - // We set the value to null straight after emitting the change to the observer - value = null - // This means that the state of the data will always be null / non existent - // It will only be available to the observer in its callback and since we do not emit null values - // the observer never receives a null value and any observers resuming do not receive the last event. - // Therefore it only emits to the observer the single action - // so you are free to show messages over and over again - // Or launch an activity/dialog or anything that should only happen once per action / click :). - }) - } - - // Just a nicely named method that wraps setting the value - @MainThread - fun sendAction(data: T) { - value = data - } -} \ No newline at end of file diff --git a/core/src/main/java/org/openedx/core/UIMessage.kt b/core/src/main/java/org/openedx/core/UIMessage.kt deleted file mode 100644 index 8a9267f36..000000000 --- a/core/src/main/java/org/openedx/core/UIMessage.kt +++ /dev/null @@ -1,12 +0,0 @@ -package org.openedx.core - -import androidx.compose.material.SnackbarDuration - -open class UIMessage { - class SnackBarMessage( - val message: String, - val duration: SnackbarDuration = SnackbarDuration.Long, - ) : UIMessage() - - class ToastMessage(val message: String) : UIMessage() -} diff --git a/core/src/main/java/org/openedx/core/Validator.kt b/core/src/main/java/org/openedx/core/Validator.kt index cb3a66ae6..9ae9f38a6 100644 --- a/core/src/main/java/org/openedx/core/Validator.kt +++ b/core/src/main/java/org/openedx/core/Validator.kt @@ -7,7 +7,8 @@ class Validator { fun isEmailOrUserNameValid(input: String): Boolean { return if (input.contains("@")) { val validEmailAddressRegex = Pattern.compile( - "^[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,6}$", Pattern.CASE_INSENSITIVE + "^[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,6}$", + Pattern.CASE_INSENSITIVE ) validEmailAddressRegex.matcher(input).find() } else { @@ -18,5 +19,4 @@ class Validator { fun isPasswordValid(password: String): Boolean { return password.length >= 2 } - } diff --git a/core/src/main/java/org/openedx/core/adapter/NavigationFragmentAdapter.kt b/core/src/main/java/org/openedx/core/adapter/NavigationFragmentAdapter.kt new file mode 100644 index 000000000..f3d210449 --- /dev/null +++ b/core/src/main/java/org/openedx/core/adapter/NavigationFragmentAdapter.kt @@ -0,0 +1,17 @@ +package org.openedx.core.adapter + +import androidx.fragment.app.Fragment +import androidx.viewpager2.adapter.FragmentStateAdapter + +class NavigationFragmentAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) { + + private val fragmentFactories = ArrayList<() -> Fragment>() + + override fun getItemCount(): Int = fragmentFactories.size + + override fun createFragment(position: Int): Fragment = fragmentFactories[position].invoke() + + fun addFragment(fragmentFactory: () -> Fragment) { + fragmentFactories.add(fragmentFactory) + } +} 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/AppLevelDownloadsConfig.kt b/core/src/main/java/org/openedx/core/config/AppLevelDownloadsConfig.kt new file mode 100644 index 000000000..577f297c6 --- /dev/null +++ b/core/src/main/java/org/openedx/core/config/AppLevelDownloadsConfig.kt @@ -0,0 +1,8 @@ +package org.openedx.core.config + +import com.google.gson.annotations.SerializedName + +data class AppLevelDownloadsConfig( + @SerializedName("ENABLED") + val isEnabled: Boolean = true, +) 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..d26741699 100644 --- a/core/src/main/java/org/openedx/core/config/Config.kt +++ b/core/src/main/java/org/openedx/core/config/Config.kt @@ -8,47 +8,48 @@ import com.google.gson.JsonParser import org.openedx.core.domain.model.AgreementUrls import java.io.InputStreamReader +@Suppress("TooManyFunctions") class Config(context: Context) { - private var configProperties: 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) { + e.printStackTrace() + 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() - } + 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 +62,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 +88,14 @@ class Config(context: Context) { return getObjectOrNewInstance(PROGRAM, ProgramConfig::class.java) } + fun getDashboardConfig(): DashboardConfig { + return getObjectOrNewInstance(DASHBOARD, DashboardConfig::class.java) + } + + fun getDownloadsConfig(): AppLevelDownloadsConfig { + return getExperimentalFeaturesConfig().appLevelDownloadsConfig + } + fun getBranchConfig(): BranchConfig { return getObjectOrNewInstance(BRANCH, BranchConfig::class.java) } @@ -103,15 +108,27 @@ 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 { + fun isBrowserLoginEnabled(): Boolean { + return getBoolean(BROWSER_LOGIN, false) + } + + fun isBrowserRegistrationEnabled(): Boolean { + return getBoolean(BROWSER_REGISTRATION, false) + } + + private fun getExperimentalFeaturesConfig(): ExperimentalFeaturesConfig { + return getObjectOrNewInstance(EXPERIMENTAL_FEATURES, ExperimentalFeaturesConfig::class.java) + } + + private fun getString(key: String, defaultValue: String = ""): String { val element = getObject(key) return if (element != null) { element.asString @@ -134,18 +151,21 @@ class Config(context: Context) { try { cls.getDeclaredConstructor().newInstance() } catch (e: InstantiationException) { - throw RuntimeException(e) + throw ConfigParsingException(e) } catch (e: IllegalAccessException) { - throw RuntimeException(e) + throw ConfigParsingException(e) } } } + class ConfigParsingException(cause: Throwable) : Exception(cause) + private fun getObject(key: String): JsonElement? { return configProperties.get(key) } 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 +176,20 @@ 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 BROWSER_LOGIN = "BROWSER_LOGIN" + private const val BROWSER_REGISTRATION = "BROWSER_REGISTRATION" private const val DISCOVERY = "DISCOVERY" private const val PROGRAM = "PROGRAM" + private const val DASHBOARD = "DASHBOARD" + private const val EXPERIMENTAL_FEATURES = "EXPERIMENTAL_FEATURES" 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/ExperimentalFeaturesConfig.kt b/core/src/main/java/org/openedx/core/config/ExperimentalFeaturesConfig.kt new file mode 100644 index 000000000..03dd43150 --- /dev/null +++ b/core/src/main/java/org/openedx/core/config/ExperimentalFeaturesConfig.kt @@ -0,0 +1,8 @@ +package org.openedx.core.config + +import com.google.gson.annotations.SerializedName + +data class ExperimentalFeaturesConfig( + @SerializedName("APP_LEVEL_DOWNLOADS") + val appLevelDownloadsConfig: AppLevelDownloadsConfig = AppLevelDownloadsConfig(), +) 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..869dfb93a 100644 --- a/core/src/main/java/org/openedx/core/config/ProgramConfig.kt +++ b/core/src/main/java/org/openedx/core/config/ProgramConfig.kt @@ -7,15 +7,15 @@ data class ProgramConfig( private val viewType: String = Config.ViewType.NATIVE.name, @SerializedName("WEBVIEW") val webViewConfig: ProgramWebViewConfig = ProgramWebViewConfig(), -){ +) { fun isViewTypeWebView(): Boolean { return Config.ViewType.WEBVIEW.name.equals(viewType, ignoreCase = true) } } 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/CookiesApi.kt b/core/src/main/java/org/openedx/core/data/api/CookiesApi.kt index c25cad2c0..276f2eda3 100644 --- a/core/src/main/java/org/openedx/core/data/api/CookiesApi.kt +++ b/core/src/main/java/org/openedx/core/data/api/CookiesApi.kt @@ -1,13 +1,11 @@ package org.openedx.core.data.api -import org.openedx.core.ApiConstants import okhttp3.RequestBody +import org.openedx.core.ApiConstants import retrofit2.Response import retrofit2.http.POST interface CookiesApi { - @POST(ApiConstants.URL_LOGIN) suspend fun userCookies(): Response - -} \ No newline at end of file +} 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..d6e44cfe2 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,25 @@ 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.CourseProgressResponse import org.openedx.core.data.model.CourseStructureModel +import org.openedx.core.data.model.DownloadCoursePreview +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 @@ -29,7 +36,8 @@ interface CourseApi { @GET( "/api/mobile/{api_version}/course_info/blocks/?" + "depth=all&" + - "requested_fields=contains_gated_content,show_gated_sections,special_exam_info,graded,format,student_view_multi_device,due,completion&" + + "requested_fields=contains_gated_content,show_gated_sections,special_exam_info,graded,format," + + "student_view_multi_device,due,completion&" + "student_view_data=video,discussion&" + "block_counts=video&" + "nav_depth=3" @@ -54,7 +62,10 @@ interface CourseApi { ) @GET("/api/course_home/v1/dates/{course_id}") - suspend fun getCourseDates(@Path("course_id") courseId: String): CourseDates + suspend fun getCourseDates( + @Path("course_id") courseId: String, + @Query("allow_not_started_courses") allowNotStartedCourses: Boolean = true + ): CourseDates @POST("/api/course_experience/v1/reset_course_deadlines") suspend fun resetCourseDates(@Body courseBody: Map): ResetCourseDates @@ -67,4 +78,41 @@ 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 + + @GET("/api/mobile/v1/download_courses/{username}") + suspend fun getDownloadCoursesPreview( + @Path("username") username: String + ): List + + @GET("/api/course_home/progress/{course_id}") + suspend fun getCourseProgress( + @Path("course_id") courseId: String, + ): CourseProgressResponse } diff --git a/core/src/main/java/org/openedx/core/data/model/AnnouncementModel.kt b/core/src/main/java/org/openedx/core/data/model/AnnouncementModel.kt index 99dc6b92c..4bc3fd3da 100644 --- a/core/src/main/java/org/openedx/core/data/model/AnnouncementModel.kt +++ b/core/src/main/java/org/openedx/core/data/model/AnnouncementModel.kt @@ -9,6 +9,7 @@ data class AnnouncementModel( val content: String ) { fun mapToDomain() = org.openedx.core.domain.model.AnnouncementModel( - date, content + date, + content ) -} \ No newline at end of file +} 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..8c4d20e35 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/AssignmentProgress.kt @@ -0,0 +1,32 @@ +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 + +private const val DEFAULT_LABEL_LENGTH = 5 + +data class AssignmentProgress( + @SerializedName("assignment_type") + val assignmentType: String?, + @SerializedName("num_points_earned") + val numPointsEarned: Float?, + @SerializedName("num_points_possible") + val numPointsPossible: Float?, + @SerializedName("short_label") + val shortLabel: String? +) { + fun mapToDomain(displayName: String) = AssignmentProgress( + assignmentType = assignmentType, + numPointsEarned = numPointsEarned ?: 0f, + numPointsPossible = numPointsPossible ?: 0f, + shortLabel = shortLabel ?: displayName.take(DEFAULT_LABEL_LENGTH) + ) + + fun mapToRoomEntity() = AssignmentProgressDb( + assignmentType = assignmentType, + numPointsEarned = numPointsEarned, + numPointsPossible = numPointsPossible, + shortLabel = shortLabel + ) +} 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..c85a4c1b5 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,37 +38,48 @@ 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 { - val blockType = BlockType.getBlockType(type ?: "") - val descendantsType = if (blockType == BlockType.VERTICAL) { - val types = descendants?.map { descendant -> - BlockType.getBlockType(blockData[descendant]?.type ?: "") - } ?: emptyList() - val sortedBlockTypes = BlockType.sortByPriority(types) - sortedBlockTypes.firstOrNull() ?: blockType - } else { - blockType - } + fun mapToDomain(blockData: Map): DomainBlock { + val blockType = BlockType.getBlockType(type.orEmpty()) + val descendantsType = determineDescendantsType(blockType, blockData) - return org.openedx.core.domain.model.Block( - id = id ?: "", - blockId = blockId ?: "", - lmsWebUrl = lmsWebUrl ?: "", - legacyWebUrl = legacyWebUrl ?: "", - studentViewUrl = studentViewUrl ?: "", + return DomainBlock( + id = id.orEmpty(), + blockId = blockId.orEmpty(), + lmsWebUrl = lmsWebUrl.orEmpty(), + legacyWebUrl = legacyWebUrl.orEmpty(), + studentViewUrl = studentViewUrl.orEmpty(), type = blockType, - displayName = displayName ?: "", - descendants = descendants ?: emptyList(), + displayName = displayName.orEmpty(), + descendants = descendants.orEmpty(), descendantsType = descendantsType, graded = graded ?: false, studentViewData = studentViewData?.mapToDomain(), studentViewMultiDevice = studentViewMultiDevice ?: false, blockCounts = blockCounts?.mapToDomain()!!, completion = completion ?: 0.0, - containsGatedContent = containsGatedContent ?: false + containsGatedContent = containsGatedContent ?: false, + assignmentProgress = assignmentProgress?.mapToDomain(displayName.orEmpty()), + due = TimeUtils.iso8601ToDate(due.orEmpty()), + offlineDownload = offlineDownload?.mapToDomain() ) } + + private fun determineDescendantsType(blockType: BlockType, blockData: Map): BlockType { + if (blockType != BlockType.VERTICAL) return blockType + + val types = descendants?.map { descendant -> + BlockType.getBlockType(blockData[descendant]?.type.orEmpty()) + }.orEmpty() + + return BlockType.sortByPriority(types).firstOrNull() ?: blockType + } } data class StudentViewData( @@ -80,15 +96,13 @@ data class StudentViewData( @SerializedName("topic_id") val topicId: String? ) { - fun mapToDomain(): org.openedx.core.domain.model.StudentViewData { - return org.openedx.core.domain.model.StudentViewData( - onlyOnWeb = onlyOnWeb ?: false, - duration = duration ?: "", - transcripts = transcripts, - encodedVideos = encodedVideos?.mapToDomain(), - topicId = topicId ?: "" - ) - } + fun mapToDomain() = DomainStudentViewData( + onlyOnWeb = onlyOnWeb ?: false, + duration = duration ?: "", + transcripts = transcripts, + encodedVideos = encodedVideos?.mapToDomain(), + topicId = topicId.orEmpty() + ) } data class EncodedVideos( @@ -105,40 +119,35 @@ data class EncodedVideos( @SerializedName("mobile_low") var mobileLow: VideoInfo? ) { - - fun mapToDomain(): org.openedx.core.domain.model.EncodedVideos { - return org.openedx.core.domain.model.EncodedVideos( - youtube = videoInfo?.mapToDomain(), - hls = hls?.mapToDomain(), - fallback = fallback?.mapToDomain(), - desktopMp4 = desktopMp4?.mapToDomain(), - mobileHigh = mobileHigh?.mapToDomain(), - mobileLow = mobileLow?.mapToDomain() - ) - } + fun mapToDomain() = DomainEncodedVideos( + youtube = videoInfo?.mapToDomain(), + hls = hls?.mapToDomain(), + fallback = fallback?.mapToDomain(), + desktopMp4 = desktopMp4?.mapToDomain(), + mobileHigh = mobileHigh?.mapToDomain(), + mobileLow = mobileLow?.mapToDomain() + ) } 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( - url = url ?: "", - fileSize = fileSize ?: 0 - ) - } + fun mapToDomain() = DomainVideoInfo( + url = url + .orEmpty() + .trim(), + fileSize = fileSize ?: 0 + ) } data class BlockCounts( @SerializedName("video") var video: Int? ) { - fun mapToDomain(): org.openedx.core.domain.model.BlockCounts { - return org.openedx.core.domain.model.BlockCounts( - video = video ?: 0 - ) - } -} \ No newline at end of file + fun mapToDomain() = DomainBlockCounts( + video = video ?: 0 + ) +} diff --git a/core/src/main/java/org/openedx/core/data/model/BlocksCompletionBody.kt b/core/src/main/java/org/openedx/core/data/model/BlocksCompletionBody.kt index fa373a2ea..860a1c1e8 100644 --- a/core/src/main/java/org/openedx/core/data/model/BlocksCompletionBody.kt +++ b/core/src/main/java/org/openedx/core/data/model/BlocksCompletionBody.kt @@ -9,4 +9,4 @@ data class BlocksCompletionBody( val courseId: String, @SerializedName("blocks") val blocks: Map -) \ No newline at end of file +) diff --git a/core/src/main/java/org/openedx/core/data/model/Certificate.kt b/core/src/main/java/org/openedx/core/data/model/Certificate.kt index 8324e4cc7..f82cdf921 100644 --- a/core/src/main/java/org/openedx/core/data/model/Certificate.kt +++ b/core/src/main/java/org/openedx/core/data/model/Certificate.kt @@ -15,4 +15,4 @@ data class Certificate( } fun mapToRoomEntity() = CertificateDb(certificateURL) -} \ 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/CourseComponentStatus.kt b/core/src/main/java/org/openedx/core/data/model/CourseComponentStatus.kt index c907f932a..a423ab1f1 100644 --- a/core/src/main/java/org/openedx/core/data/model/CourseComponentStatus.kt +++ b/core/src/main/java/org/openedx/core/data/model/CourseComponentStatus.kt @@ -13,4 +13,4 @@ data class CourseComponentStatus( lastVisitedBlockId = lastVisitedBlockId ?: "" ) } -} \ No newline at end of file +} 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..1ad692a1c 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,13 +1,18 @@ 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, @SerializedName("date") - val date: String = "", // ISO 8601 compliant format + val date: String = "", // ISO 8601 compliant format @SerializedName("assignment_type") val assignmentType: String? = "", @SerializedName("date_type") @@ -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/CourseDates.kt b/core/src/main/java/org/openedx/core/data/model/CourseDates.kt index 97fc3180f..c0472c894 100644 --- a/core/src/main/java/org/openedx/core/data/model/CourseDates.kt +++ b/core/src/main/java/org/openedx/core/data/model/CourseDates.kt @@ -51,22 +51,22 @@ data class CourseDates( courseDatesResponse[DatesSection.TODAY] = datesList.filter { it.date.isToday() }.also { datesList.removeAll(it) } - //Update the date for upcoming comparison without time + // Update the date for upcoming comparison without time currentDate.clearTime() // for current week except today courseDatesResponse[DatesSection.THIS_WEEK] = datesList.filter { - it.date.after(currentDate) && it.date.before(currentDate.addDays(8)) + it.date.after(currentDate) && it.date.before(currentDate.addDays(days = 8)) }.also { datesList.removeAll(it) } // for coming week courseDatesResponse[DatesSection.NEXT_WEEK] = datesList.filter { - it.date.after(currentDate.addDays(7)) && it.date.before(currentDate.addDays(15)) + it.date.after(currentDate.addDays(days = 7)) && it.date.before(currentDate.addDays(days = 15)) }.also { datesList.removeAll(it) } // for upcoming courseDatesResponse[DatesSection.UPCOMING] = datesList.filter { - it.date.after(currentDate.addDays(14)) + it.date.after(currentDate.addDays(days = 14)) }.also { datesList.removeAll(it) } return courseDatesResponse 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..76d4d900f 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,39 @@ 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, primaryCourse) + } - return CourseEnrollments(enrollments, appConfig) + private fun deserializePrimaryCourse(json: JsonElement?): EnrolledCourse? { + return try { + Gson().fromJson( + (json as JsonObject).get("primary"), + EnrolledCourse::class.java + ) + } catch (e: Exception) { + e.printStackTrace() + null + } } private fun deserializeEnrollments(json: JsonElement?): DashboardCourseList { @@ -33,7 +56,8 @@ data class CourseEnrollments( (json as JsonObject).get("enrollments"), DashboardCourseList::class.java ) - } catch (ex: Exception) { + } catch (e: Exception) { + e.printStackTrace() DashboardCourseList( next = null, previous = null, @@ -61,7 +85,7 @@ data class CourseEnrollments( config.asString, AppConfig::class.java ) - } catch (ex: Exception) { + } catch (_: Exception) { AppConfig() } } 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/CourseProgressResponse.kt b/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt new file mode 100644 index 000000000..00d55a9b5 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt @@ -0,0 +1,285 @@ +package org.openedx.core.data.model + +import androidx.compose.ui.graphics.Color +import androidx.core.graphics.toColorInt +import com.google.gson.annotations.SerializedName +import org.openedx.core.data.model.room.CertificateDataDb +import org.openedx.core.data.model.room.CompletionSummaryDb +import org.openedx.core.data.model.room.CourseGradeDb +import org.openedx.core.data.model.room.CourseProgressEntity +import org.openedx.core.data.model.room.GradingPolicyDb +import org.openedx.core.data.model.room.SectionScoreDb +import org.openedx.core.data.model.room.VerificationDataDb +import org.openedx.core.domain.model.CourseProgress + +data class CourseProgressResponse( + @SerializedName("verified_mode") val verifiedMode: String?, + @SerializedName("access_expiration") val accessExpiration: String?, + @SerializedName("certificate_data") val certificateData: CertificateData?, + @SerializedName("completion_summary") val completionSummary: CompletionSummary?, + @SerializedName("course_grade") val courseGrade: CourseGrade?, + @SerializedName("credit_course_requirements") val creditCourseRequirements: String?, + @SerializedName("end") val end: String?, + @SerializedName("enrollment_mode") val enrollmentMode: String?, + @SerializedName("grading_policy") val gradingPolicy: GradingPolicy?, + @SerializedName("has_scheduled_content") val hasScheduledContent: Boolean?, + @SerializedName("section_scores") val sectionScores: List?, + @SerializedName("studio_url") val studioUrl: String?, + @SerializedName("username") val username: String?, + @SerializedName("user_has_passing_grade") val userHasPassingGrade: Boolean?, + @SerializedName("verification_data") val verificationData: VerificationData?, + @SerializedName("disable_progress_graph") val disableProgressGraph: Boolean?, +) { + data class CertificateData( + @SerializedName("cert_status") val certStatus: String?, + @SerializedName("cert_web_view_url") val certWebViewUrl: String?, + @SerializedName("download_url") val downloadUrl: String?, + @SerializedName("certificate_available_date") val certificateAvailableDate: String? + ) { + fun mapToRoomEntity() = CertificateDataDb( + certStatus = certStatus.orEmpty(), + certWebViewUrl = certWebViewUrl.orEmpty(), + downloadUrl = downloadUrl.orEmpty(), + certificateAvailableDate = certificateAvailableDate.orEmpty() + ) + + fun mapToDomain() = CourseProgress.CertificateData( + certStatus = certStatus ?: "", + certWebViewUrl = certWebViewUrl ?: "", + downloadUrl = downloadUrl ?: "", + certificateAvailableDate = certificateAvailableDate ?: "" + ) + } + + data class CompletionSummary( + @SerializedName("complete_count") val completeCount: Int?, + @SerializedName("incomplete_count") val incompleteCount: Int?, + @SerializedName("locked_count") val lockedCount: Int? + ) { + fun mapToRoomEntity() = CompletionSummaryDb( + completeCount = completeCount ?: 0, + incompleteCount = incompleteCount ?: 0, + lockedCount = lockedCount ?: 0 + ) + + fun mapToDomain() = CourseProgress.CompletionSummary( + completeCount = completeCount ?: 0, + incompleteCount = incompleteCount ?: 0, + lockedCount = lockedCount ?: 0 + ) + } + + data class CourseGrade( + @SerializedName("letter_grade") val letterGrade: String?, + @SerializedName("percent") val percent: Double?, + @SerializedName("is_passing") val isPassing: Boolean? + ) { + fun mapToRoomEntity() = CourseGradeDb( + letterGrade = letterGrade.orEmpty(), + percent = percent ?: 0.0, + isPassing = isPassing ?: false + ) + + fun mapToDomain() = CourseProgress.CourseGrade( + letterGrade = letterGrade ?: "", + percent = percent ?: 0.0, + isPassing = isPassing ?: false + ) + } + + data class GradingPolicy( + @SerializedName("assignment_policies") val assignmentPolicies: List?, + @SerializedName("grade_range") val gradeRange: Map?, + @SerializedName("assignment_colors") val assignmentColors: List? + ) { + // TODO Temporary solution. Backend will returns color list later + val defaultColors = listOf( + "#D24242", + "#7B9645", + "#5A5AD8", + "#B0842C", + "#2E90C2", + "#D13F88", + "#36A17D", + "#AE5AD8", + "#3BA03B" + ) + + fun mapToRoomEntity() = GradingPolicyDb( + assignmentPolicies = assignmentPolicies?.map { it.mapToRoomEntity() } ?: emptyList(), + gradeRange = gradeRange ?: emptyMap(), + assignmentColors = assignmentColors ?: defaultColors + ) + + fun mapToDomain() = CourseProgress.GradingPolicy( + assignmentPolicies = assignmentPolicies?.map { it.mapToDomain() } ?: emptyList(), + gradeRange = gradeRange ?: emptyMap(), + assignmentColors = assignmentColors?.map { colorString -> + Color(colorString.toColorInt()) + } ?: defaultColors.map { Color(it.toColorInt()) } + ) + + data class AssignmentPolicy( + @SerializedName("num_droppable") val numDroppable: Int?, + @SerializedName("num_total") val numTotal: Int?, + @SerializedName("short_label") val shortLabel: String?, + @SerializedName("type") val type: String?, + @SerializedName("weight") val weight: Double? + ) { + fun mapToRoomEntity() = GradingPolicyDb.AssignmentPolicyDb( + numDroppable = numDroppable ?: 0, + numTotal = numTotal ?: 0, + shortLabel = shortLabel.orEmpty(), + type = type.orEmpty(), + weight = weight ?: 0.0 + ) + + fun mapToDomain() = CourseProgress.GradingPolicy.AssignmentPolicy( + numDroppable = numDroppable ?: 0, + numTotal = numTotal ?: 0, + shortLabel = shortLabel ?: "", + type = type ?: "", + weight = weight ?: 0.0 + ) + } + } + + data class SectionScore( + @SerializedName("display_name") val displayName: String?, + @SerializedName("subsections") val subsections: List? + ) { + fun mapToRoomEntity() = SectionScoreDb( + displayName = displayName.orEmpty(), + subsections = subsections?.map { it.mapToRoomEntity() } ?: emptyList() + ) + + fun mapToDomain() = CourseProgress.SectionScore( + displayName = displayName ?: "", + subsections = subsections?.map { it.mapToDomain() } ?: emptyList() + ) + + data class Subsection( + @SerializedName("assignment_type") val assignmentType: String?, + @SerializedName("block_key") val blockKey: String?, + @SerializedName("display_name") val displayName: String?, + @SerializedName("has_graded_assignment") val hasGradedAssignment: Boolean?, + @SerializedName("override") val override: String?, + @SerializedName("learner_has_access") val learnerHasAccess: Boolean?, + @SerializedName("num_points_earned") val numPointsEarned: Float?, + @SerializedName("num_points_possible") val numPointsPossible: Float?, + @SerializedName("percent_graded") val percentGraded: Double?, + @SerializedName("problem_scores") val problemScores: List?, + @SerializedName("show_correctness") val showCorrectness: String?, + @SerializedName("show_grades") val showGrades: Boolean?, + @SerializedName("url") val url: String? + ) { + fun mapToRoomEntity() = SectionScoreDb.SubsectionDb( + assignmentType = assignmentType.orEmpty(), + blockKey = blockKey.orEmpty(), + displayName = displayName.orEmpty(), + hasGradedAssignment = hasGradedAssignment ?: false, + override = override.orEmpty(), + learnerHasAccess = learnerHasAccess ?: false, + numPointsEarned = numPointsEarned ?: 0f, + numPointsPossible = numPointsPossible ?: 0f, + percentGraded = percentGraded ?: 0.0, + problemScores = problemScores?.map { it.mapToRoomEntity() } ?: emptyList(), + showCorrectness = showCorrectness.orEmpty(), + showGrades = showGrades ?: false, + url = url.orEmpty() + ) + + fun mapToDomain() = CourseProgress.SectionScore.Subsection( + assignmentType = assignmentType ?: "", + blockKey = blockKey ?: "", + displayName = displayName ?: "", + hasGradedAssignment = hasGradedAssignment ?: false, + override = override ?: "", + learnerHasAccess = learnerHasAccess ?: false, + numPointsEarned = numPointsEarned ?: 0f, + numPointsPossible = numPointsPossible ?: 0f, + percentGraded = percentGraded ?: 0.0, + problemScores = problemScores?.map { it.mapToDomain() } ?: emptyList(), + showCorrectness = showCorrectness ?: "", + showGrades = showGrades ?: false, + url = url ?: "" + ) + + data class ProblemScore( + @SerializedName("earned") val earned: Double?, + @SerializedName("possible") val possible: Double? + ) { + fun mapToRoomEntity() = SectionScoreDb.SubsectionDb.ProblemScoreDb( + earned = earned ?: 0.0, + possible = possible ?: 0.0 + ) + + fun mapToDomain() = CourseProgress.SectionScore.Subsection.ProblemScore( + earned = earned ?: 0.0, + possible = possible ?: 0.0 + ) + } + } + } + + data class VerificationData( + @SerializedName("link") val link: String?, + @SerializedName("status") val status: String?, + @SerializedName("status_date") val statusDate: String? + ) { + fun mapToRoomEntity() = VerificationDataDb( + link = link.orEmpty(), + status = status.orEmpty(), + statusDate = statusDate.orEmpty() + ) + + fun mapToDomain() = CourseProgress.VerificationData( + link = link ?: "", + status = status ?: "", + statusDate = statusDate ?: "" + ) + } + + fun mapToDomain(): CourseProgress { + return CourseProgress( + verifiedMode = verifiedMode ?: "", + accessExpiration = accessExpiration ?: "", + certificateData = certificateData?.mapToDomain(), + completionSummary = completionSummary?.mapToDomain(), + courseGrade = courseGrade?.mapToDomain(), + creditCourseRequirements = creditCourseRequirements ?: "", + end = end ?: "", + enrollmentMode = enrollmentMode ?: "", + gradingPolicy = gradingPolicy?.mapToDomain(), + hasScheduledContent = hasScheduledContent ?: false, + sectionScores = sectionScores?.map { it.mapToDomain() } ?: emptyList(), + studioUrl = studioUrl ?: "", + username = username ?: "", + userHasPassingGrade = userHasPassingGrade ?: false, + verificationData = verificationData?.mapToDomain(), + disableProgressGraph = disableProgressGraph ?: false, + ) + } + + fun mapToRoomEntity(courseId: String): CourseProgressEntity { + return CourseProgressEntity( + courseId = courseId, + verifiedMode = verifiedMode.orEmpty(), + accessExpiration = accessExpiration.orEmpty(), + certificateData = certificateData?.mapToRoomEntity(), + completionSummary = completionSummary?.mapToRoomEntity(), + courseGrade = courseGrade?.mapToRoomEntity(), + creditCourseRequirements = creditCourseRequirements.orEmpty(), + end = end.orEmpty(), + enrollmentMode = enrollmentMode.orEmpty(), + gradingPolicy = gradingPolicy?.mapToRoomEntity(), + hasScheduledContent = hasScheduledContent ?: false, + sectionScores = sectionScores?.map { it.mapToRoomEntity() } ?: emptyList(), + studioUrl = studioUrl.orEmpty(), + username = username.orEmpty(), + userHasPassingGrade = userHasPassingGrade ?: false, + verificationData = verificationData?.mapToRoomEntity(), + disableProgressGraph = disableProgressGraph ?: false, + ) + } +} diff --git a/core/src/main/java/org/openedx/core/data/model/CourseSharingUtmParameters.kt b/core/src/main/java/org/openedx/core/data/model/CourseSharingUtmParameters.kt index 8b2651ddd..42bb0969a 100644 --- a/core/src/main/java/org/openedx/core/data/model/CourseSharingUtmParameters.kt +++ b/core/src/main/java/org/openedx/core/data/model/CourseSharingUtmParameters.kt @@ -21,4 +21,4 @@ data class CourseSharingUtmParameters( facebook = facebook ?: "", twitter = twitter ?: "" ) -} \ No newline at end of file +} 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/CoursewareAccess.kt b/core/src/main/java/org/openedx/core/data/model/CoursewareAccess.kt index d92ffc336..87d2fede7 100644 --- a/core/src/main/java/org/openedx/core/data/model/CoursewareAccess.kt +++ b/core/src/main/java/org/openedx/core/data/model/CoursewareAccess.kt @@ -40,5 +40,4 @@ data class CoursewareAccess( userFragment = userFragment ?: "" ) } - -} \ No newline at end of file +} diff --git a/core/src/main/java/org/openedx/core/data/model/DashboardCourseList.kt b/core/src/main/java/org/openedx/core/data/model/DashboardCourseList.kt index c75eeef33..996775d3a 100644 --- a/core/src/main/java/org/openedx/core/data/model/DashboardCourseList.kt +++ b/core/src/main/java/org/openedx/core/data/model/DashboardCourseList.kt @@ -29,5 +29,4 @@ data class DashboardCourseList( results.map { it.mapToDomain() } ) } - -} \ No newline at end of file +} diff --git a/core/src/main/java/org/openedx/core/data/model/DownloadCoursePreview.kt b/core/src/main/java/org/openedx/core/data/model/DownloadCoursePreview.kt new file mode 100644 index 000000000..2731b8b5d --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/DownloadCoursePreview.kt @@ -0,0 +1,34 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.data.model.room.DownloadCoursePreview as EntityDownloadCoursePreview +import org.openedx.core.domain.model.DownloadCoursePreview as DomainDownloadCoursePreview + +data class DownloadCoursePreview( + @SerializedName("course_id") + val id: String, + @SerializedName("course_name") + val name: String?, + @SerializedName("course_image") + val image: String?, + @SerializedName("total_size") + val totalSize: Long?, +) { + fun mapToDomain(): DomainDownloadCoursePreview { + return DomainDownloadCoursePreview( + id = id, + name = name ?: "", + image = image ?: "", + totalSize = totalSize ?: 0, + ) + } + + fun mapToRoomEntity(): EntityDownloadCoursePreview { + return EntityDownloadCoursePreview( + id = id, + name = name, + image = image, + totalSize = totalSize, + ) + } +} 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/EnrolledCourseData.kt b/core/src/main/java/org/openedx/core/data/model/EnrolledCourseData.kt index 4afc9ef71..38acd4401 100644 --- a/core/src/main/java/org/openedx/core/data/model/EnrolledCourseData.kt +++ b/core/src/main/java/org/openedx/core/data/model/EnrolledCourseData.kt @@ -51,51 +51,53 @@ data class EnrolledCourseData( fun mapToDomain(): EnrolledCourseData { return EnrolledCourseData( - id = id ?: "", - name = name ?: "", - number = number ?: "", - org = org ?: "", - start = TimeUtils.iso8601ToDate(start ?: ""), - startDisplay = startDisplay ?: "", - startType = startType ?: "", - end = TimeUtils.iso8601ToDate(end ?: ""), - dynamicUpgradeDeadline = dynamicUpgradeDeadline ?: "", - subscriptionId = subscriptionId ?: "", + id = id.orEmpty(), + name = name.orEmpty(), + number = number.orEmpty(), + org = org.orEmpty(), + start = parseDate(start), + startDisplay = startDisplay.orEmpty(), + startType = startType.orEmpty(), + end = parseDate(end), + dynamicUpgradeDeadline = dynamicUpgradeDeadline.orEmpty(), + subscriptionId = subscriptionId.orEmpty(), coursewareAccess = coursewareAccess?.mapToDomain(), media = media?.mapToDomain(), - courseImage = courseImage ?: "", - courseAbout = courseAbout ?: "", + courseImage = courseImage.orEmpty(), + courseAbout = courseAbout.orEmpty(), courseSharingUtmParameters = courseSharingUtmParameters?.mapToDomain()!!, - courseUpdates = courseUpdates ?: "", - courseHandouts = courseHandouts ?: "", - discussionUrl = discussionUrl ?: "", - videoOutline = videoOutline ?: "", + courseUpdates = courseUpdates.orEmpty(), + courseHandouts = courseHandouts.orEmpty(), + discussionUrl = discussionUrl.orEmpty(), + videoOutline = videoOutline.orEmpty(), isSelfPaced = isSelfPaced ?: false ) } fun mapToRoomEntity(): EnrolledCourseDataDb { return EnrolledCourseDataDb( - id = id ?: "", - name = name ?: "", - number = number ?: "", - org = org ?: "", - start = start ?: "", - startDisplay = startDisplay ?: "", - startType = startType ?: "", - end = end ?: "", - dynamicUpgradeDeadline = dynamicUpgradeDeadline ?: "", - subscriptionId = subscriptionId ?: "", + id = id.orEmpty(), + name = name.orEmpty(), + number = number.orEmpty(), + org = org.orEmpty(), + start = start.orEmpty(), + startDisplay = startDisplay.orEmpty(), + startType = startType.orEmpty(), + end = end.orEmpty(), + dynamicUpgradeDeadline = dynamicUpgradeDeadline.orEmpty(), + subscriptionId = subscriptionId.orEmpty(), coursewareAccess = coursewareAccess?.mapToRoomEntity(), media = MediaDb.createFrom(media), - courseImage = courseImage ?: "", - courseAbout = courseAbout ?: "", + courseImage = courseImage.orEmpty(), + courseAbout = courseAbout.orEmpty(), courseSharingUtmParameters = courseSharingUtmParameters?.mapToRoomEntity()!!, - courseUpdates = courseUpdates ?: "", - courseHandouts = courseHandouts ?: "", - discussionUrl = discussionUrl ?: "", - videoOutline = videoOutline ?: "", + courseUpdates = courseUpdates.orEmpty(), + courseHandouts = courseHandouts.orEmpty(), + discussionUrl = discussionUrl.orEmpty(), + videoOutline = videoOutline.orEmpty(), isSelfPaced = isSelfPaced ?: false ) } -} \ No newline at end of file + + private fun parseDate(date: String?) = TimeUtils.iso8601ToDate(date.orEmpty()) +} 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..d1998ed29 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/EnrollmentDetails.kt @@ -0,0 +1,33 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +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/ErrorResponse.kt b/core/src/main/java/org/openedx/core/data/model/ErrorResponse.kt index f76ea2edb..1365a3099 100644 --- a/core/src/main/java/org/openedx/core/data/model/ErrorResponse.kt +++ b/core/src/main/java/org/openedx/core/data/model/ErrorResponse.kt @@ -7,4 +7,4 @@ data class ErrorResponse( val error: String?, @SerializedName("error_description", alternate = ["value", "developer_message"]) val errorDescription: String? -) \ No newline at end of file +) diff --git a/core/src/main/java/org/openedx/core/data/model/Media.kt b/core/src/main/java/org/openedx/core/data/model/Media.kt index 2a7a05000..96ffafb4c 100644 --- a/core/src/main/java/org/openedx/core/data/model/Media.kt +++ b/core/src/main/java/org/openedx/core/data/model/Media.kt @@ -1,7 +1,6 @@ package org.openedx.core.data.model import com.google.gson.annotations.SerializedName -import org.openedx.core.data.model.room.discovery.* import org.openedx.core.domain.model.Media data class Media( @@ -15,7 +14,7 @@ data class Media( val image: Image?, ) { - fun mapToDomain(): org.openedx.core.domain.model.Media { + fun mapToDomain(): Media { return Media( bannerImage = bannerImage?.mapToDomain(), courseImage = courseImage?.mapToDomain(), @@ -23,7 +22,6 @@ data class Media( image = image?.mapToDomain() ) } - } data class Image( @@ -80,4 +78,4 @@ data class BannerImage( uriAbsolute = uriAbsolute ?: "" ) } -} \ No newline at end of file +} diff --git a/core/src/main/java/org/openedx/core/data/model/OfflineDownload.kt b/core/src/main/java/org/openedx/core/data/model/OfflineDownload.kt new file mode 100644 index 000000000..40868fc7a --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/OfflineDownload.kt @@ -0,0 +1,26 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.data.model.room.OfflineDownloadDb +import org.openedx.core.domain.model.OfflineDownload + +data class OfflineDownload( + @SerializedName("file_url") + var fileUrl: String?, + @SerializedName("last_modified") + var lastModified: String?, + @SerializedName("file_size") + var fileSize: Long?, +) { + fun mapToDomain() = OfflineDownload( + fileUrl = fileUrl ?: "", + lastModified = lastModified, + fileSize = fileSize ?: 0 + ) + + fun mapToRoomEntity() = OfflineDownloadDb( + fileUrl = fileUrl ?: "", + lastModified = lastModified, + fileSize = fileSize ?: 0 + ) +} diff --git a/core/src/main/java/org/openedx/core/data/model/Pagination.kt b/core/src/main/java/org/openedx/core/data/model/Pagination.kt index e375ac6fe..0d72b9fd1 100644 --- a/core/src/main/java/org/openedx/core/data/model/Pagination.kt +++ b/core/src/main/java/org/openedx/core/data/model/Pagination.kt @@ -19,4 +19,4 @@ data class Pagination( numPages = numPages ?: 0, previous = previous ?: "" ) -} \ No newline at end of file +} 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..469be14b9 --- /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( + completed = assignmentsCompleted ?: 0, + total = totalAssignmentsCount ?: 0 + ) + + fun mapToRoomEntity() = ProgressDb( + assignmentsCompleted = assignmentsCompleted ?: 0, + totalAssignmentsCount = totalAssignmentsCount ?: 0 + ) +} diff --git a/core/src/main/java/org/openedx/core/data/model/User.kt b/core/src/main/java/org/openedx/core/data/model/User.kt index 99194624b..6ba76efaa 100644 --- a/core/src/main/java/org/openedx/core/data/model/User.kt +++ b/core/src/main/java/org/openedx/core/data/model/User.kt @@ -15,7 +15,10 @@ data class User( ) { fun mapToDomain(): User { return User( - id, username, email, name?:"" + id, + username, + email, + name ?: "" ) } } 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..4ec631f30 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(), @@ -126,7 +149,6 @@ data class StudentViewDataDb( topicId = studentViewData?.topicId ?: "" ) } - } } @@ -144,9 +166,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 +178,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), @@ -167,22 +189,23 @@ data class EncodedVideosDb( ) } } - } 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 ?: "", + videoInfo.url + .orEmpty() + .trim(), videoInfo.fileSize ?: 0, ) } @@ -193,11 +216,45 @@ 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?, + val shortLabel: String? +) { + fun mapToDomain() = DomainAssignmentProgress( + assignmentType = assignmentType, + numPointsEarned = numPointsEarned ?: 0f, + numPointsPossible = numPointsPossible ?: 0f, + shortLabel = shortLabel ?: "" + ) +} + +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/CourseEnrollmentDetailsEntity.kt b/core/src/main/java/org/openedx/core/data/model/room/CourseEnrollmentDetailsEntity.kt new file mode 100644 index 000000000..cc80a0438 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/room/CourseEnrollmentDetailsEntity.kt @@ -0,0 +1,84 @@ +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.openedx.core.data.model.room.discovery.CertificateDb +import org.openedx.core.data.model.room.discovery.CourseAccessDetailsDb +import org.openedx.core.data.model.room.discovery.CourseSharingUtmParametersDb +import org.openedx.core.data.model.room.discovery.EnrollmentDetailsDB +import org.openedx.core.domain.model.CourseEnrollmentDetails +import org.openedx.core.domain.model.CourseInfoOverview +import java.util.Date + +@Entity(tableName = "course_enrollment_details_table") +data class CourseEnrollmentDetailsEntity( + @PrimaryKey + @ColumnInfo("id") + val id: String, + @ColumnInfo("courseUpdates") + val courseUpdates: String, + @ColumnInfo("courseHandouts") + val courseHandouts: String, + @ColumnInfo("discussionUrl") + val discussionUrl: String, + @Embedded + val courseAccessDetails: CourseAccessDetailsDb, + @Embedded + val certificate: CertificateDb?, + @Embedded + val enrollmentDetails: EnrollmentDetailsDB, + @Embedded + val courseInfoOverview: CourseInfoOverviewDb +) { + fun mapToDomain() = CourseEnrollmentDetails( + id = id, + courseUpdates = courseUpdates, + courseHandouts = courseHandouts, + discussionUrl = discussionUrl, + courseAccessDetails = courseAccessDetails.mapToDomain(), + certificate = certificate?.mapToDomain(), + enrollmentDetails = enrollmentDetails.mapToDomain(), + courseInfoOverview = courseInfoOverview.mapToDomain() + ) +} + +data class CourseInfoOverviewDb( + @ColumnInfo("name") + val name: String, + @ColumnInfo("number") + val number: String, + @ColumnInfo("org") + val org: String, + @ColumnInfo("start") + val start: Date?, + @ColumnInfo("startDisplay") + val startDisplay: String, + @ColumnInfo("startType") + val startType: String, + @ColumnInfo("end") + val end: Date?, + @ColumnInfo("isSelfPaced") + val isSelfPaced: Boolean, + @Embedded + var media: MediaDb?, + @Embedded + val courseSharingUtmParameters: CourseSharingUtmParametersDb, + @ColumnInfo("courseAbout") + val courseAbout: String, +) { + fun mapToDomain() = CourseInfoOverview( + name = name, + number = number, + org = org, + start = start, + startDisplay = startDisplay, + startType = startType, + end = end, + isSelfPaced = isSelfPaced, + media = media?.mapToDomain(), + courseSharingUtmParameters = courseSharingUtmParameters.mapToDomain(), + courseAbout = courseAbout + ) +} diff --git a/core/src/main/java/org/openedx/core/data/model/room/CourseProgressEntity.kt b/core/src/main/java/org/openedx/core/data/model/room/CourseProgressEntity.kt new file mode 100644 index 000000000..19ad78590 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/room/CourseProgressEntity.kt @@ -0,0 +1,239 @@ +package org.openedx.core.data.model.room + +import androidx.compose.ui.graphics.Color +import androidx.core.graphics.toColorInt +import androidx.room.ColumnInfo +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.PrimaryKey +import org.openedx.core.domain.model.CourseProgress + +@Entity(tableName = "course_progress_table") +data class CourseProgressEntity( + @PrimaryKey + @ColumnInfo("courseId") + val courseId: String, + @ColumnInfo("verifiedMode") + val verifiedMode: String, + @ColumnInfo("accessExpiration") + val accessExpiration: String, + @Embedded(prefix = "certificate_") + val certificateData: CertificateDataDb?, + @Embedded(prefix = "completion_") + val completionSummary: CompletionSummaryDb?, + @Embedded(prefix = "grade_") + val courseGrade: CourseGradeDb?, + @ColumnInfo("creditCourseRequirements") + val creditCourseRequirements: String, + @ColumnInfo("end") + val end: String, + @ColumnInfo("enrollmentMode") + val enrollmentMode: String, + @Embedded(prefix = "grading_") + val gradingPolicy: GradingPolicyDb?, + @ColumnInfo("hasScheduledContent") + val hasScheduledContent: Boolean, + @ColumnInfo("sectionScores") + val sectionScores: List, + @ColumnInfo("studioUrl") + val studioUrl: String, + @ColumnInfo("username") + val username: String, + @ColumnInfo("userHasPassingGrade") + val userHasPassingGrade: Boolean, + @Embedded(prefix = "verification_") + val verificationData: VerificationDataDb?, + @ColumnInfo("disableProgressGraph") + val disableProgressGraph: Boolean, +) { + fun mapToDomain(): CourseProgress { + return CourseProgress( + verifiedMode = verifiedMode, + accessExpiration = accessExpiration, + certificateData = certificateData?.mapToDomain(), + completionSummary = completionSummary?.mapToDomain(), + courseGrade = courseGrade?.mapToDomain(), + creditCourseRequirements = creditCourseRequirements, + end = end, + enrollmentMode = enrollmentMode, + gradingPolicy = gradingPolicy?.mapToDomain(), + hasScheduledContent = hasScheduledContent, + sectionScores = sectionScores.map { it.mapToDomain() }, + studioUrl = studioUrl, + username = username, + userHasPassingGrade = userHasPassingGrade, + verificationData = verificationData?.mapToDomain(), + disableProgressGraph = disableProgressGraph, + ) + } +} + +data class CertificateDataDb( + @ColumnInfo("certStatus") + val certStatus: String, + @ColumnInfo("certWebViewUrl") + val certWebViewUrl: String, + @ColumnInfo("downloadUrl") + val downloadUrl: String, + @ColumnInfo("certificateAvailableDate") + val certificateAvailableDate: String +) { + fun mapToDomain() = CourseProgress.CertificateData( + certStatus = certStatus, + certWebViewUrl = certWebViewUrl, + downloadUrl = downloadUrl, + certificateAvailableDate = certificateAvailableDate + ) +} + +data class CompletionSummaryDb( + @ColumnInfo("completeCount") + val completeCount: Int, + @ColumnInfo("incompleteCount") + val incompleteCount: Int, + @ColumnInfo("lockedCount") + val lockedCount: Int +) { + fun mapToDomain() = CourseProgress.CompletionSummary( + completeCount = completeCount, + incompleteCount = incompleteCount, + lockedCount = lockedCount + ) +} + +data class CourseGradeDb( + @ColumnInfo("letterGrade") + val letterGrade: String, + @ColumnInfo("percent") + val percent: Double, + @ColumnInfo("isPassing") + val isPassing: Boolean +) { + fun mapToDomain() = CourseProgress.CourseGrade( + letterGrade = letterGrade, + percent = percent, + isPassing = isPassing + ) +} + +data class GradingPolicyDb( + @ColumnInfo("assignmentPolicies") + val assignmentPolicies: List, + @ColumnInfo("gradeRange") + val gradeRange: Map, + @ColumnInfo("assignmentColors") + val assignmentColors: List +) { + fun mapToDomain() = CourseProgress.GradingPolicy( + assignmentPolicies = assignmentPolicies.map { it.mapToDomain() }, + gradeRange = gradeRange, + assignmentColors = assignmentColors.map { colorString -> + Color(colorString.toColorInt()) + } + ) + + data class AssignmentPolicyDb( + @ColumnInfo("numDroppable") + val numDroppable: Int, + @ColumnInfo("numTotal") + val numTotal: Int, + @ColumnInfo("shortLabel") + val shortLabel: String, + @ColumnInfo("type") + val type: String, + @ColumnInfo("weight") + val weight: Double + ) { + fun mapToDomain() = CourseProgress.GradingPolicy.AssignmentPolicy( + numDroppable = numDroppable, + numTotal = numTotal, + shortLabel = shortLabel, + type = type, + weight = weight + ) + } +} + +data class SectionScoreDb( + @ColumnInfo("displayName") + val displayName: String, + @ColumnInfo("subsections") + val subsections: List +) { + fun mapToDomain() = CourseProgress.SectionScore( + displayName = displayName, + subsections = subsections.map { it.mapToDomain() } + ) + + data class SubsectionDb( + @ColumnInfo("assignmentType") + val assignmentType: String, + @ColumnInfo("blockKey") + val blockKey: String, + @ColumnInfo("displayName") + val displayName: String, + @ColumnInfo("hasGradedAssignment") + val hasGradedAssignment: Boolean, + @ColumnInfo("override") + val override: String, + @ColumnInfo("learnerHasAccess") + val learnerHasAccess: Boolean, + @ColumnInfo("numPointsEarned") + val numPointsEarned: Float, + @ColumnInfo("numPointsPossible") + val numPointsPossible: Float, + @ColumnInfo("percentGraded") + val percentGraded: Double, + @ColumnInfo("problemScores") + val problemScores: List, + @ColumnInfo("showCorrectness") + val showCorrectness: String, + @ColumnInfo("showGrades") + val showGrades: Boolean, + @ColumnInfo("url") + val url: String + ) { + fun mapToDomain() = CourseProgress.SectionScore.Subsection( + assignmentType = assignmentType, + blockKey = blockKey, + displayName = displayName, + hasGradedAssignment = hasGradedAssignment, + override = override, + learnerHasAccess = learnerHasAccess, + numPointsEarned = numPointsEarned, + numPointsPossible = numPointsPossible, + percentGraded = percentGraded, + problemScores = problemScores.map { it.mapToDomain() }, + showCorrectness = showCorrectness, + showGrades = showGrades, + url = url + ) + + data class ProblemScoreDb( + @ColumnInfo("earned") + val earned: Double, + @ColumnInfo("possible") + val possible: Double + ) { + fun mapToDomain() = CourseProgress.SectionScore.Subsection.ProblemScore( + earned = earned, + possible = possible + ) + } + } +} + +data class VerificationDataDb( + @ColumnInfo("link") + val link: String, + @ColumnInfo("status") + val status: String, + @ColumnInfo("statusDate") + val statusDate: String +) { + fun mapToDomain() = CourseProgress.VerificationData( + link = link, + status = status, + statusDate = statusDate + ) +} 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..9ad7e4cc2 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,8 +60,8 @@ data class CourseStructureEntity( coursewareAccess?.mapToDomain(), media?.mapToDomain(), certificate?.mapToDomain(), - isSelfPaced + isSelfPaced, + progress.mapToDomain() ) } - -} \ No newline at end of file +} diff --git a/core/src/main/java/org/openedx/core/data/model/room/DownloadCoursePreview.kt b/core/src/main/java/org/openedx/core/data/model/room/DownloadCoursePreview.kt new file mode 100644 index 000000000..b4806f0f3 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/room/DownloadCoursePreview.kt @@ -0,0 +1,28 @@ +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.DownloadCoursePreview as DomainDownloadCoursePreview + +@Entity(tableName = "download_course_preview_table") +data class DownloadCoursePreview( + @PrimaryKey + @ColumnInfo("course_id") + val id: String, + @ColumnInfo("course_name") + val name: String?, + @ColumnInfo("course_image") + val image: String?, + @ColumnInfo("total_size") + val totalSize: Long?, +) { + fun mapToDomain(): DomainDownloadCoursePreview { + return DomainDownloadCoursePreview( + id = id, + name = name ?: "", + image = image ?: "", + totalSize = totalSize ?: 0, + ) + } +} diff --git a/core/src/main/java/org/openedx/core/data/model/room/MediaDb.kt b/core/src/main/java/org/openedx/core/data/model/room/MediaDb.kt index be5bf45b4..6d61f9820 100644 --- a/core/src/main/java/org/openedx/core/data/model/room/MediaDb.kt +++ b/core/src/main/java/org/openedx/core/data/model/room/MediaDb.kt @@ -1,7 +1,11 @@ package org.openedx.core.data.model.room import androidx.room.ColumnInfo -import org.openedx.core.domain.model.* +import org.openedx.core.domain.model.BannerImage +import org.openedx.core.domain.model.CourseImage +import org.openedx.core.domain.model.CourseVideo +import org.openedx.core.domain.model.Image +import org.openedx.core.domain.model.Media data class MediaDb( @ColumnInfo("bannerImage") @@ -44,7 +48,9 @@ data class ImageDb( val small: String ) { fun mapToDomain() = Image( - large, raw, small + large, + raw, + small ) companion object { @@ -57,7 +63,6 @@ data class ImageDb( } } - data class CourseVideoDb( @ColumnInfo("uri") val uri: String @@ -103,5 +108,4 @@ data class BannerImageDb( uriAbsolute = bannerImage?.uriAbsolute ?: "" ) } - -} \ No newline at end of file +} 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/VideoProgressEntity.kt b/core/src/main/java/org/openedx/core/data/model/room/VideoProgressEntity.kt new file mode 100644 index 000000000..2ee58d802 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/room/VideoProgressEntity.kt @@ -0,0 +1,18 @@ +package org.openedx.core.data.model.room + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "video_progress_table") +data class VideoProgressEntity( + @PrimaryKey + @ColumnInfo("block_id") + val blockId: String, + @ColumnInfo("video_url") + val videoUrl: String, + @ColumnInfo("video_time") + val videoTime: Long?, + @ColumnInfo("duration") + val duration: Long?, +) 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..2bcf3c664 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 { @@ -132,12 +153,11 @@ data class CoursewareAccessDb( userFragment ) } - } data class CertificateDb( @ColumnInfo("certificateURL") - val certificateURL: String? + val certificateURL: String?, ) { fun mapToDomain() = Certificate(certificateURL) } @@ -146,9 +166,127 @@ data class CourseSharingUtmParametersDb( @ColumnInfo("facebook") val facebook: String, @ColumnInfo("twitter") - val twitter: String + val twitter: String, ) { fun mapToDomain() = CourseSharingUtmParameters( - facebook, twitter + facebook, + twitter + ) +} + +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 ?: "") ) -} \ No newline at end of file +} + +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/data/storage/CourseDao.kt b/core/src/main/java/org/openedx/core/data/storage/CourseDao.kt new file mode 100644 index 000000000..4ca7db3a6 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/storage/CourseDao.kt @@ -0,0 +1,59 @@ +package org.openedx.core.data.storage + +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.CourseEnrollmentDetailsEntity +import org.openedx.core.data.model.room.CourseProgressEntity +import org.openedx.core.data.model.room.CourseStructureEntity +import org.openedx.core.data.model.room.VideoProgressEntity + +@Dao +interface CourseDao { + + @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) + + @Transaction + suspend fun clearCachedData() { + clearCourseStructure() + clearVideoProgress() + clearEnrollmentCachedData() + clearCourseProgressData() + } + + @Query("DELETE FROM course_structure_table") + suspend fun clearCourseStructure() + + @Query("DELETE FROM video_progress_table") + suspend fun clearVideoProgress() + + @Query("DELETE FROM course_enrollment_details_table") + suspend fun clearEnrollmentCachedData() + + @Query("DELETE FROM course_progress_table") + suspend fun clearCourseProgressData() + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertCourseEnrollmentDetailsEntity(vararg courseEnrollmentDetailsEntity: CourseEnrollmentDetailsEntity) + + @Query("SELECT * FROM course_enrollment_details_table WHERE id=:id") + suspend fun getCourseEnrollmentDetailsById(id: String): CourseEnrollmentDetailsEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertVideoProgressEntity(vararg videoProgressEntity: VideoProgressEntity) + + @Query("SELECT * FROM video_progress_table WHERE block_id=:blockId") + suspend fun getVideoProgressByBlockId(blockId: String): VideoProgressEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertCourseProgressEntity(vararg courseProgressEntity: CourseProgressEntity) + + @Query("SELECT * FROM course_progress_table WHERE courseId=:id") + suspend fun getCourseProgressById(id: String): CourseProgressEntity? +} diff --git a/core/src/main/java/org/openedx/core/data/storage/InAppReviewPreferences.kt b/core/src/main/java/org/openedx/core/data/storage/InAppReviewPreferences.kt index c9bf0638c..590ff021d 100644 --- a/core/src/main/java/org/openedx/core/data/storage/InAppReviewPreferences.kt +++ b/core/src/main/java/org/openedx/core/data/storage/InAppReviewPreferences.kt @@ -25,4 +25,4 @@ interface InAppReviewPreferences { val default = VersionName(Int.MIN_VALUE, Int.MIN_VALUE) } } -} \ No newline at end of file +} diff --git a/core/src/main/java/org/openedx/core/domain/helper/VideoPreviewHelper.kt b/core/src/main/java/org/openedx/core/domain/helper/VideoPreviewHelper.kt new file mode 100644 index 000000000..6914cb78c --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/helper/VideoPreviewHelper.kt @@ -0,0 +1,62 @@ +package org.openedx.core.domain.helper + +import android.content.Context +import org.openedx.core.domain.model.Block +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.utils.VideoPreview + +/** + * Helper class for handling video preview generation. + * This class encapsulates the logic for getting video previews from blocks, + * avoiding the need to inject Context directly into ViewModels. + */ +class VideoPreviewHelper( + private val context: Context, + private val networkConnection: NetworkConnection +) { + + /** + * Gets video preview for a single block + * @param block The block to get video preview for + * @param offlineUrl Optional offline URL for the video + * @return VideoPreview object or null if no preview available + */ + fun getVideoPreview(block: Block, offlineUrl: String? = null): VideoPreview? { + return block.getVideoPreview( + context = context, + isOnline = networkConnection.isOnline(), + offlineUrl = offlineUrl + ) + } + + /** + * Gets video previews for multiple blocks + * @param blocks List of blocks to get video previews for + * @param offlineUrls Optional map of block IDs to offline URLs + * @return Map of block IDs to VideoPreview objects + */ + fun getVideoPreviews( + blocks: List, + offlineUrls: Map? = null + ): Map { + return blocks.associate { block -> + val offlineUrl = offlineUrls?.get(block.id) + block.id to getVideoPreview(block, offlineUrl) + } + } + + /** + * Gets video preview for a single block with a specific offline URL + * @param blockId The ID of the block + * @param block The block to get video preview for + * @param offlineUrl Optional offline URL for the video + * @return Pair of block ID and VideoPreview object or null + */ + fun getVideoPreviewWithId( + blockId: String, + block: Block, + offlineUrl: String? = null + ): Pair { + return blockId to getVideoPreview(block, offlineUrl) + } +} 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/interactor/CourseInteractor.kt b/core/src/main/java/org/openedx/core/domain/interactor/CourseInteractor.kt new file mode 100644 index 000000000..ef5a8b7c5 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/interactor/CourseInteractor.kt @@ -0,0 +1,15 @@ +package org.openedx.core.domain.interactor + +import org.openedx.core.domain.model.CourseStructure +import org.openedx.core.module.db.DownloadModel + +interface CourseInteractor { + suspend fun getCourseStructure( + courseId: String, + isNeedRefresh: Boolean = false + ): CourseStructure + + suspend fun getCourseStructureFromCache(courseId: String): CourseStructure + + suspend fun getAllDownloadModels(): List +} 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..a64e09655 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 @@ -1,14 +1,18 @@ package org.openedx.core.domain.model -import java.io.Serializable +import com.google.gson.annotations.SerializedName data class AppConfig( - val courseDatesCalendarSync: CourseDatesCalendarSync, -) : Serializable + val courseDatesCalendarSync: CourseDatesCalendarSync = CourseDatesCalendarSync(), +) data class CourseDatesCalendarSync( - val isEnabled: Boolean, - val isSelfPacedEnabled: Boolean, - val isInstructorPacedEnabled: Boolean, - val isDeepLinkEnabled: Boolean, -) : Serializable + @SerializedName("is_enabled") + val isEnabled: Boolean = false, + @SerializedName("is_self_paced_enabled") + val isSelfPacedEnabled: Boolean = false, + @SerializedName("is_instructor_paced_enabled") + val isInstructorPacedEnabled: Boolean = false, + @SerializedName("is_deep_link_enabled") + val isDeepLinkEnabled: Boolean = false, +) 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..6c51810fb --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/AssignmentProgress.kt @@ -0,0 +1,27 @@ +package org.openedx.core.domain.model + +import android.os.Parcelable +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import org.openedx.core.extension.safeDivBy + +@Parcelize +data class AssignmentProgress( + val assignmentType: String?, + val numPointsEarned: Float, + val numPointsPossible: Float, + val shortLabel: String +) : Parcelable { + + @IgnoredOnParcel + val value: Float = numPointsEarned.safeDivBy(numPointsPossible) + + fun toPointString(separator: String = ""): String { + return "${numPointsEarned.toInt()}$separator/$separator${numPointsPossible.toInt()}" + } + + @IgnoredOnParcel + val label = shortLabel + .replace(" ", "") + .replaceFirst(Regex("^(\\D+)(0*)(\\d+)$"), "$1$3") +} 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..4b27c87fd 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,20 @@ package org.openedx.core.domain.model +import android.content.Context +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.PreviewHelper +import org.openedx.core.utils.VideoPreview import org.openedx.core.utils.VideoUtil +import java.util.Date - +@Parcelize data class Block( val id: String, val blockId: String, @@ -25,43 +31,38 @@ 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 { - return downloadModel?.downloadedState == DownloadedState.DOWNLOADING || - downloadModel?.downloadedState == DownloadedState.WAITING - } - - fun isDownloaded() = downloadModel?.downloadedState == DownloadedState.DOWNLOADED - fun isGated() = containsGatedContent fun isCompleted() = completion == 1.0 fun getFirstDescendantBlock(blocks: List): Block? { - if (blocks.isEmpty()) return null - descendants.forEach { descendant -> - blocks.find { it.id == descendant }?.let { descendantBlock -> - return descendantBlock - } + return descendants.firstOrNull { descendant -> + blocks.find { it.id == descendant } != null + }?.let { descendant -> + blocks.find { it.id == descendant } } - return null } fun getDownloadsCount(blocks: List): Int { @@ -75,6 +76,44 @@ data class Block( return count } + fun getFileSize(): Long { + return when { + type == BlockType.VIDEO -> downloadModel?.size ?: 0L + isxBlock -> offlineDownload?.fileSize ?: 0L + else -> 0L + } + } + + fun getVideoPreview(context: Context, isOnline: Boolean, offlineUrl: String?): VideoPreview? { + return if (studentViewData?.encodedVideos?.hasYoutubeUrl == true) { + val youtubeUrl = studentViewData.encodedVideos.youtube?.url ?: "" + VideoPreview.createYoutubePreview( + PreviewHelper.getYouTubeThumbnailUrl(youtubeUrl) + ) + } else if (studentViewData?.encodedVideos?.hasVideoUrl == true) { + val videoUrl = if (studentViewData.encodedVideos.videoUrl.isNotEmpty() && isOnline) { + studentViewData.encodedVideos.videoUrl + } else { + offlineUrl ?: "" + } + val bitmap = PreviewHelper.getVideoFrameBitmap( + context = context, + isOnline = isOnline, + videoUrl = videoUrl + ) + bitmap?.let { VideoPreview.createEncodedVideoPreview(it) } + } else { + null + } + } + + val videoUrl: String? + get() = if (studentViewData?.encodedVideos?.hasVideoUrl == true) { + studentViewData.encodedVideos.videoUrl + } else { + studentViewData?.encodedVideos?.youtube?.url + } + val isVideoBlock get() = type == BlockType.VIDEO val isDiscussionBlock get() = type == BlockType.DISCUSSION val isHTMLBlock get() = type == BlockType.HTML @@ -86,14 +125,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 +142,7 @@ data class EncodedVideos( var desktopMp4: VideoInfo?, var mobileHigh: VideoInfo?, var mobileLow: VideoInfo?, -) { +) : Parcelable { val hasDownloadableVideo: Boolean get() = isPreferredVideoInfo(hls) || isPreferredVideoInfo(fallback) || @@ -110,11 +151,11 @@ data class EncodedVideos( isPreferredVideoInfo(mobileLow) val hasNonYoutubeVideo: Boolean - get() = mobileHigh?.url != null - || mobileLow?.url != null - || desktopMp4?.url != null - || hls?.url != null - || fallback?.url != null + get() = mobileHigh?.url != null || + mobileLow?.url != null || + desktopMp4?.url != null || + hls?.url != null || + fallback?.url != null val videoUrl: String get() = fallback?.url @@ -148,29 +189,19 @@ data class EncodedVideos( } private fun getDefaultVideoInfoForDownloading(): VideoInfo? { - if (isPreferredVideoInfo(mobileLow)) { - return mobileLow - } - if (isPreferredVideoInfo(mobileHigh)) { - return mobileHigh - } - if (isPreferredVideoInfo(desktopMp4)) { - return desktopMp4 - } - fallback?.let { - if (isPreferredVideoInfo(it) && - !VideoUtil.videoHasFormat(it.url, AppDataConstants.VIDEO_FORMAT_M3U8) - ) { - return fallback - } - } - hls?.let { - if (isPreferredVideoInfo(it) - ) { - return hls - } + return when { + isPreferredVideoInfo(mobileLow) -> mobileLow + isPreferredVideoInfo(mobileHigh) -> mobileHigh + isPreferredVideoInfo(desktopMp4) -> desktopMp4 + fallback != null && isPreferredVideoInfo(fallback) && + !VideoUtil.videoHasFormat( + fallback!!.url, + AppDataConstants.VIDEO_FORMAT_M3U8 + ) -> fallback + + hls != null && isPreferredVideoInfo(hls) -> hls + else -> null } - return null } private fun isPreferredVideoInfo(videoInfo: VideoInfo?): Boolean { @@ -178,14 +209,22 @@ data class EncodedVideos( URLUtil.isNetworkUrl(videoInfo.url) && VideoUtil.isValidVideoUrl(videoInfo.url) } - } +@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/Certificate.kt b/core/src/main/java/org/openedx/core/domain/model/Certificate.kt index 83430d697..62fb51b50 100644 --- a/core/src/main/java/org/openedx/core/domain/model/Certificate.kt +++ b/core/src/main/java/org/openedx/core/domain/model/Certificate.kt @@ -2,6 +2,7 @@ package org.openedx.core.domain.model import android.os.Parcelable import kotlinx.parcelize.Parcelize +import org.openedx.core.data.model.room.discovery.CertificateDb @Parcelize data class Certificate( @@ -9,4 +10,5 @@ data class Certificate( ) : Parcelable { fun isCertificateEarned() = certificateURL?.isNotEmpty() == true + fun mapToRoomEntity() = CertificateDb(certificateURL) } 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..2c95865e9 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseAccessDetails.kt @@ -0,0 +1,26 @@ +package org.openedx.core.domain.model + +import android.os.Parcelable +import com.google.gson.internal.bind.util.ISO8601Utils +import kotlinx.parcelize.Parcelize +import org.openedx.core.data.model.room.discovery.CourseAccessDetailsDb +import java.util.Date + +@Parcelize +data class CourseAccessDetails( + val hasUnmetPrerequisites: Boolean, + val isTooEarly: Boolean, + val isStaff: Boolean, + val auditAccessExpires: Date?, + val coursewareAccess: CoursewareAccess?, +) : Parcelable { + + fun mapToRoomEntity(): CourseAccessDetailsDb = + CourseAccessDetailsDb( + hasUnmetPrerequisites = hasUnmetPrerequisites, + isTooEarly = isTooEarly, + isStaff = isStaff, + auditAccessExpires = auditAccessExpires?.let { ISO8601Utils.format(it) }, + coursewareAccess = coursewareAccess?.mapToEntity() + ) +} 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..9c60747ca --- /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..6c2165dca 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,18 +16,35 @@ data class CourseDateBlock( val date: Date, val dateType: DateType = DateType.NONE, val assignmentType: String? = "", -) { +) : Parcelable { fun isCompleted(): Boolean { - return complete || (dateType in setOf( + val dateTypeInSet = dateType in setOf( DateType.COURSE_START_DATE, DateType.COURSE_END_DATE, DateType.CERTIFICATE_AVAILABLE_DATE, DateType.VERIFIED_UPGRADE_DEADLINE, - DateType.VERIFICATION_DEADLINE_DATE, - ) && date.before(Date())) + DateType.VERIFICATION_DEADLINE_DATE + ) + return complete || (dateTypeInSet && 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/CourseDatesBannerInfo.kt b/core/src/main/java/org/openedx/core/domain/model/CourseDatesBannerInfo.kt index 3281ca045..f7d840681 100644 --- a/core/src/main/java/org/openedx/core/domain/model/CourseDatesBannerInfo.kt +++ b/core/src/main/java/org/openedx/core/domain/model/CourseDatesBannerInfo.kt @@ -61,5 +61,5 @@ enum class CourseBannerType( headerResId = R.string.core_dates_reset_dates_banner_header, bodyResId = R.string.core_dates_reset_dates_banner_body, buttonResId = R.string.core_dates_reset_dates_banner_button - ); + ) } 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..ec961dfcd --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseEnrollmentDetails.kt @@ -0,0 +1,42 @@ +package org.openedx.core.domain.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import org.openedx.core.data.model.room.CourseEnrollmentDetailsEntity +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) + + fun mapToEntity() = CourseEnrollmentDetailsEntity( + id = id, + courseUpdates = courseUpdates, + courseHandouts = courseHandouts, + discussionUrl = discussionUrl, + courseAccessDetails = courseAccessDetails.mapToRoomEntity(), + certificate = certificate?.mapToRoomEntity(), + enrollmentDetails = enrollmentDetails.mapToEntity(), + courseInfoOverview = courseInfoOverview.mapToEntity() + ) +} + +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..6895522f5 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseInfoOverview.kt @@ -0,0 +1,38 @@ +package org.openedx.core.domain.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import org.openedx.core.data.model.room.CourseInfoOverviewDb +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 + + fun mapToEntity() = CourseInfoOverviewDb( + name = name, + number = number, + org = org, + start = start, + startDisplay = startDisplay ?: "", + startType = startType, + end = end, + isSelfPaced = isSelfPaced, + media = media?.mapToEntity(), + courseSharingUtmParameters = courseSharingUtmParameters.mapToEntity(), + courseAbout = courseAbout + ) +} diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseProgress.kt b/core/src/main/java/org/openedx/core/domain/model/CourseProgress.kt new file mode 100644 index 000000000..77ae5f65a --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseProgress.kt @@ -0,0 +1,138 @@ +package org.openedx.core.domain.model + +import androidx.compose.ui.graphics.Color + +data class CourseProgress( + val verifiedMode: String, + val accessExpiration: String, + val certificateData: CertificateData?, + val completionSummary: CompletionSummary?, + val courseGrade: CourseGrade?, + val creditCourseRequirements: String, + val end: String, + val enrollmentMode: String, + val gradingPolicy: GradingPolicy?, + val hasScheduledContent: Boolean, + val sectionScores: List, + val studioUrl: String, + val username: String, + val userHasPassingGrade: Boolean, + val verificationData: VerificationData?, + val disableProgressGraph: Boolean, +) { + val completion = with(completionSummary) { + val total = (this?.completeCount ?: 0) + (this?.incompleteCount ?: 0) + if (total > 0f) (this?.completeCount ?: 0).toFloat() / total else 0f + } + val completionPercent = (completion * 100f).toInt() + val requiredGrade = gradingPolicy?.gradeRange?.values?.firstOrNull() ?: 0f + val requiredGradePercent = (requiredGrade * 100f).toInt() + + fun getAssignmentGradedPercent(type: String): Float { + val assignmentSections = getAssignmentSections(type) + if (assignmentSections.isEmpty()) return 0f + return assignmentSections.sumOf { it.percentGraded }.toFloat() / assignmentSections.size + } + + fun getAssignmentSections(type: String) = sectionScores + .flatMap { it.subsections } + .filter { it.assignmentType == type } + + fun getAssignmentWeightedGradedPercent(assignmentPolicy: GradingPolicy.AssignmentPolicy): Float { + return (assignmentPolicy.weight * getAssignmentGradedPercent(assignmentPolicy.type) * 100f).toFloat() + } + + fun getTotalWeightPercent() = + gradingPolicy?.assignmentPolicies?.sumOf { getAssignmentWeightedGradedPercent(it).toDouble() } + ?.toFloat() ?: 0f + + fun getNotCompletedWeightedGradePercent(): Float { + val totalWeightedPercent = getTotalWeightPercent() + val notCompletedPercent = 100.0 - totalWeightedPercent + return if (notCompletedPercent < 0.0) 0f else notCompletedPercent.toFloat() + } + + fun getNotEmptyGradingPolicies() = gradingPolicy?.assignmentPolicies?.mapNotNull { + if (getAssignmentSections(it.type).isNotEmpty()) { + it + } else { + null + } + } + + fun getCompletedAssignmentCount( + policy: GradingPolicy.AssignmentPolicy, + courseStructure: CourseStructure? = null + ): Int { + val assignments = getAssignmentSections(policy.type) + return courseStructure?.blockData + ?.filter { it.id in assignments.map { assignment -> assignment.blockKey } } + ?.filter { it.isCompleted() } + ?.size ?: 0 + } + + data class CertificateData( + val certStatus: String, + val certWebViewUrl: String, + val downloadUrl: String, + val certificateAvailableDate: String + ) + + data class CompletionSummary( + val completeCount: Int, + val incompleteCount: Int, + val lockedCount: Int + ) + + data class CourseGrade( + val letterGrade: String, + val percent: Double, + val isPassing: Boolean + ) + + data class GradingPolicy( + val assignmentPolicies: List, + val gradeRange: Map, + val assignmentColors: List, + ) { + data class AssignmentPolicy( + val numDroppable: Int, + val numTotal: Int, + val shortLabel: String, + val type: String, + val weight: Double + ) + } + + data class SectionScore( + val displayName: String, + val subsections: List + ) { + data class Subsection( + val assignmentType: String, + val blockKey: String, + val displayName: String, + val hasGradedAssignment: Boolean, + val override: String, + val learnerHasAccess: Boolean, + val numPointsEarned: Float, + val numPointsPossible: Float, + val percentGraded: Double, + val problemScores: List, + val showCorrectness: String, + val showGrades: Boolean, + val url: String + ) { + data class ProblemScore( + val earned: Double, + val possible: Double + ) + } + } + + data class VerificationData( + val link: String, + val status: String, + val statusDate: String + ) +} diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseSharingUtmParameters.kt b/core/src/main/java/org/openedx/core/domain/model/CourseSharingUtmParameters.kt index f09f057db..1d27361a3 100644 --- a/core/src/main/java/org/openedx/core/domain/model/CourseSharingUtmParameters.kt +++ b/core/src/main/java/org/openedx/core/domain/model/CourseSharingUtmParameters.kt @@ -2,9 +2,16 @@ package org.openedx.core.domain.model import android.os.Parcelable import kotlinx.parcelize.Parcelize +import org.openedx.core.data.model.room.discovery.CourseSharingUtmParametersDb @Parcelize data class CourseSharingUtmParameters( val facebook: String, val twitter: String -) : Parcelable \ No newline at end of file +) : Parcelable { + + fun mapToEntity() = CourseSharingUtmParametersDb( + facebook = facebook, + twitter = twitter + ) +} 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/CoursewareAccess.kt b/core/src/main/java/org/openedx/core/domain/model/CoursewareAccess.kt index 187c995b6..9f0fd60e6 100644 --- a/core/src/main/java/org/openedx/core/domain/model/CoursewareAccess.kt +++ b/core/src/main/java/org/openedx/core/domain/model/CoursewareAccess.kt @@ -2,6 +2,7 @@ package org.openedx.core.domain.model import android.os.Parcelable import kotlinx.parcelize.Parcelize +import org.openedx.core.data.model.room.discovery.CoursewareAccessDb @Parcelize data class CoursewareAccess( @@ -11,4 +12,14 @@ data class CoursewareAccess( val userMessage: String, val additionalContextUserMessage: String, val userFragment: String -) : Parcelable \ No newline at end of file +) : Parcelable { + + fun mapToEntity() = CoursewareAccessDb( + hasAccess = hasAccess, + errorCode = errorCode, + developerMessage = developerMessage, + userMessage = userMessage, + additionalContextUserMessage = additionalContextUserMessage, + userFragment = userFragment + ) +} diff --git a/core/src/main/java/org/openedx/core/domain/model/DatesSection.kt b/core/src/main/java/org/openedx/core/domain/model/DatesSection.kt index 111d6e65e..d641c79d8 100644 --- a/core/src/main/java/org/openedx/core/domain/model/DatesSection.kt +++ b/core/src/main/java/org/openedx/core/domain/model/DatesSection.kt @@ -9,5 +9,5 @@ enum class DatesSection(val stringResId: Int) { THIS_WEEK(R.string.core_date_type_this_week), NEXT_WEEK(R.string.core_date_type_next_week), UPCOMING(R.string.core_date_type_upcoming), - NONE(R.string.core_date_type_none); + NONE(R.string.core_date_type_none) } diff --git a/core/src/main/java/org/openedx/core/domain/model/DownloadCoursePreview.kt b/core/src/main/java/org/openedx/core/domain/model/DownloadCoursePreview.kt new file mode 100644 index 000000000..d4fccf4e0 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/DownloadCoursePreview.kt @@ -0,0 +1,8 @@ +package org.openedx.core.domain.model + +data class DownloadCoursePreview( + val id: String, + val name: String, + val image: String, + val totalSize: Long, +) diff --git a/core/src/main/java/org/openedx/core/domain/model/DownloadDialogResource.kt b/core/src/main/java/org/openedx/core/domain/model/DownloadDialogResource.kt new file mode 100644 index 000000000..a0666f2b1 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/DownloadDialogResource.kt @@ -0,0 +1,9 @@ +package org.openedx.core.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/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/EnrolledCourseData.kt b/core/src/main/java/org/openedx/core/domain/model/EnrolledCourseData.kt index 2a66cccde..58fdaebf2 100644 --- a/core/src/main/java/org/openedx/core/domain/model/EnrolledCourseData.kt +++ b/core/src/main/java/org/openedx/core/domain/model/EnrolledCourseData.kt @@ -2,7 +2,7 @@ package org.openedx.core.domain.model import android.os.Parcelable import kotlinx.parcelize.Parcelize -import java.util.* +import java.util.Date @Parcelize data class EnrolledCourseData( @@ -26,4 +26,4 @@ data class EnrolledCourseData( val discussionUrl: String, val videoOutline: String, val isSelfPaced: Boolean -) : Parcelable \ No newline at end of file +) : 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..b880f3948 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/EnrollmentDetails.kt @@ -0,0 +1,23 @@ +package org.openedx.core.domain.model + +import android.os.Parcelable +import com.google.gson.internal.bind.util.ISO8601Utils +import kotlinx.parcelize.Parcelize +import org.openedx.core.data.model.room.discovery.EnrollmentDetailsDB +import java.util.Date + +@Parcelize +data class EnrollmentDetails( + val created: Date?, + val mode: String?, + val isActive: Boolean, + val upgradeDeadline: Date?, +) : Parcelable { + + fun mapToEntity() = EnrollmentDetailsDB( + created = created?.let { ISO8601Utils.format(it) }, + mode = mode, + isActive = isActive, + upgradeDeadline = upgradeDeadline?.let { ISO8601Utils.format(it) } + ) +} 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/HandoutsModel.kt b/core/src/main/java/org/openedx/core/domain/model/HandoutsModel.kt index 80a84d4b7..bdac364ba 100644 --- a/core/src/main/java/org/openedx/core/domain/model/HandoutsModel.kt +++ b/core/src/main/java/org/openedx/core/domain/model/HandoutsModel.kt @@ -2,4 +2,4 @@ package org.openedx.core.domain.model data class HandoutsModel( val handoutsHtml: String -) \ No newline at end of file +) diff --git a/core/src/main/java/org/openedx/core/domain/model/Media.kt b/core/src/main/java/org/openedx/core/domain/model/Media.kt index 16d6f66c3..572fcbdae 100644 --- a/core/src/main/java/org/openedx/core/domain/model/Media.kt +++ b/core/src/main/java/org/openedx/core/domain/model/Media.kt @@ -2,6 +2,11 @@ package org.openedx.core.domain.model import android.os.Parcelable import kotlinx.parcelize.Parcelize +import org.openedx.core.data.model.room.BannerImageDb +import org.openedx.core.data.model.room.CourseImageDb +import org.openedx.core.data.model.room.CourseVideoDb +import org.openedx.core.data.model.room.ImageDb +import org.openedx.core.data.model.room.MediaDb @Parcelize data class Media( @@ -9,28 +14,48 @@ data class Media( val courseImage: CourseImage? = null, val courseVideo: CourseVideo? = null, val image: Image? = null -) : Parcelable +) : Parcelable { + + fun mapToEntity() = MediaDb( + bannerImage = bannerImage?.mapToEntity(), + courseImage = courseImage?.mapToEntity(), + courseVideo = courseVideo?.mapToEntity(), + image = image?.mapToEntity() + ) +} @Parcelize data class Image( val large: String, val raw: String, val small: String -) : Parcelable +) : Parcelable { + + fun mapToEntity() = ImageDb(large, raw, small) +} @Parcelize data class CourseVideo( val uri: String -) : Parcelable +) : Parcelable { + + fun mapToEntity() = CourseVideoDb(uri) +} @Parcelize data class CourseImage( val uri: String, - val name : String -) : Parcelable + val name: String +) : Parcelable { + + fun mapToEntity() = CourseImageDb(uri, name) +} @Parcelize data class BannerImage( val uri: String, val uriAbsolute: String -) : Parcelable \ No newline at end of file +) : Parcelable { + + fun mapToEntity() = BannerImageDb(uri, uriAbsolute) +} diff --git a/core/src/main/java/org/openedx/core/domain/model/Pagination.kt b/core/src/main/java/org/openedx/core/domain/model/Pagination.kt index 267eb6392..28bc025c8 100644 --- a/core/src/main/java/org/openedx/core/domain/model/Pagination.kt +++ b/core/src/main/java/org/openedx/core/domain/model/Pagination.kt @@ -5,4 +5,4 @@ data class Pagination( val next: String, val numPages: Int, val previous: String -) \ No newline at end of file +) diff --git a/core/src/main/java/org/openedx/core/domain/model/ProfileImage.kt b/core/src/main/java/org/openedx/core/domain/model/ProfileImage.kt index ef07f5b33..48fc89620 100644 --- a/core/src/main/java/org/openedx/core/domain/model/ProfileImage.kt +++ b/core/src/main/java/org/openedx/core/domain/model/ProfileImage.kt @@ -10,4 +10,4 @@ data class ProfileImage( val imageUrlMedium: String, val imageUrlSmall: String, val hasImage: Boolean -) : Parcelable \ No newline at end of file +) : Parcelable diff --git a/core/src/main/java/org/openedx/core/domain/model/Progress.kt b/core/src/main/java/org/openedx/core/domain/model/Progress.kt new file mode 100644 index 000000000..fbe82d5cc --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/Progress.kt @@ -0,0 +1,20 @@ +package org.openedx.core.domain.model + +import android.os.Parcelable +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import org.openedx.core.extension.safeDivBy + +@Parcelize +data class Progress( + val completed: Int, + val total: Int, +) : Parcelable { + + @IgnoredOnParcel + val value: Float = completed.toFloat().safeDivBy(total.toFloat()) + + companion object { + val DEFAULT_PROGRESS = Progress(0, 0) + } +} diff --git a/core/src/main/java/org/openedx/core/domain/model/StartType.kt b/core/src/main/java/org/openedx/core/domain/model/StartType.kt index 38ace6fff..8f167bd6a 100644 --- a/core/src/main/java/org/openedx/core/domain/model/StartType.kt +++ b/core/src/main/java/org/openedx/core/domain/model/StartType.kt @@ -20,4 +20,4 @@ enum class StartType(val type: String) { */ @SerializedName("empty") EMPTY("empty") -} \ No newline at end of file +} diff --git a/core/src/main/java/org/openedx/core/domain/model/User.kt b/core/src/main/java/org/openedx/core/domain/model/User.kt index a14d19951..8fae9464e 100644 --- a/core/src/main/java/org/openedx/core/domain/model/User.kt +++ b/core/src/main/java/org/openedx/core/domain/model/User.kt @@ -1,9 +1,8 @@ package org.openedx.core.domain.model - data class User( val id: Long, val username: String?, val email: String?, val name: String -) \ No newline at end of file +) diff --git a/core/src/main/java/org/openedx/core/domain/model/VideoSettings.kt b/core/src/main/java/org/openedx/core/domain/model/VideoSettings.kt index 4f411551e..28248d403 100644 --- a/core/src/main/java/org/openedx/core/domain/model/VideoSettings.kt +++ b/core/src/main/java/org/openedx/core/domain/model/VideoSettings.kt @@ -46,5 +46,5 @@ enum class VideoQuality( width = 1280, height = 720, tagId = "high", - ); + ) } diff --git a/core/src/main/java/org/openedx/core/exception/NoCachedDataException.kt b/core/src/main/java/org/openedx/core/exception/NoCachedDataException.kt index d917c5c49..234e53a3f 100644 --- a/core/src/main/java/org/openedx/core/exception/NoCachedDataException.kt +++ b/core/src/main/java/org/openedx/core/exception/NoCachedDataException.kt @@ -1,3 +1,3 @@ package org.openedx.core.exception -class NoCachedDataException : Exception() \ No newline at end of file +class NoCachedDataException : Exception() 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/CoroutineExt.kt b/core/src/main/java/org/openedx/core/extension/CoroutineExt.kt new file mode 100644 index 000000000..5a29ef9f5 --- /dev/null +++ b/core/src/main/java/org/openedx/core/extension/CoroutineExt.kt @@ -0,0 +1,14 @@ +package org.openedx.core.extension + +import kotlinx.coroutines.channels.ProducerScope +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.channelFlow +import kotlin.experimental.ExperimentalTypeInference + +@OptIn(ExperimentalTypeInference::class) +inline fun channelFlowWithAwait( + @BuilderInference crossinline block: suspend ProducerScope.() -> Unit +) = channelFlow { + block(this) + awaitClose() +} diff --git a/core/src/main/java/org/openedx/core/extension/FloatExt.kt b/core/src/main/java/org/openedx/core/extension/FloatExt.kt new file mode 100644 index 000000000..77a022736 --- /dev/null +++ b/core/src/main/java/org/openedx/core/extension/FloatExt.kt @@ -0,0 +1,19 @@ +package org.openedx.core.extension + +/** + * Safely divides this Float by [divisor], returning 0f if: + * - [divisor] is zero, + * - the result is NaN. + * + * Workaround for accessibility issue: + * https://github.com/openedx/openedx-app-android/issues/442 + */ +fun Float.safeDivBy(divisor: Float): Float = try { + var result = this / divisor + if (result.isNaN()) { + result = 0f + } + result +} catch (_: ArithmeticException) { + 0f +} 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..f5cc21279 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 } } @@ -35,8 +11,24 @@ fun List.getSequentialBlocks(): List { return this.filter { it.type == BlockType.SEQUENTIAL } } -fun List?.isNotEmptyThenLet(block: (List) -> Unit) { - if (!isNullOrEmpty()) { - block(this) +fun List.getChapterBlocks(): List { + return this.filter { it.type == BlockType.CHAPTER } +} + +fun List.getUnitChapter(blockId: String): Block? { + val verticalBlock = this.firstOrNull { + it.type == BlockType.VERTICAL && it.descendants.contains(blockId) + } + + val sequentialBlock = verticalBlock?.let { vertical -> + this.firstOrNull { + it.type == BlockType.SEQUENTIAL && it.descendants.contains(vertical.id) + } + } + + return sequentialBlock?.let { sequential -> + this.firstOrNull { + it.type == BlockType.CHAPTER && it.descendants.contains(sequential.id) + } } } 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..1ed4f3fb6 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 (_: 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/TextConverter.kt b/core/src/main/java/org/openedx/core/extension/TextConverter.kt index e6a60d989..f01d33aa3 100644 --- a/core/src/main/java/org/openedx/core/extension/TextConverter.kt +++ b/core/src/main/java/org/openedx/core/extension/TextConverter.kt @@ -1,8 +1,6 @@ package org.openedx.core.extension -import android.os.Parcelable import android.util.Patterns -import kotlinx.parcelize.Parcelize import org.jsoup.Jsoup import org.jsoup.nodes.Document import org.jsoup.select.Elements @@ -36,83 +34,10 @@ object TextConverter : KoinComponent { return LinkedText(text, linksMap.toMap()) } - fun textToLinkedImageText(html: String): LinkedImageText { - val doc: Document = - Jsoup.parse(html) - val links: Elements = doc.select("a[href]") - var text = doc.text() - val headers = getHeaders(doc) - val linksMap = mutableMapOf() - for (link in links) { - if (isLinkValid(link.attr("href"))) { - val linkText = if (link.hasText()) link.text() else link.attr("href") - linksMap[linkText] = link.attr("href") - } else { - val resultLink = - if (link.attr("href").isNotEmpty() && link.attr("href")[0] == '/') { - link.attr("href").substring(1) - } else { - link.attr("href") - } - if (resultLink.isNotEmpty() && isLinkValid(config.getApiHostURL() + resultLink)) { - linksMap[link.text()] = config.getApiHostURL() + resultLink - } - } - } - text = setSpacesForHeaders(text, headers) - return LinkedImageText( - text, - linksMap.toMap(), - getImageLinks(doc), - headers - ) - } - fun isLinkValid(link: String) = Patterns.WEB_URL.matcher(link.lowercase()).matches() - - private fun getHeaders(document: Document): List { - val headersList = mutableListOf() - for (index in 1..6) { - if (document.select("h$index").hasText()) { - headersList.add(document.select("h$index").text()) - } - } - return headersList.toList() - } - - private fun setSpacesForHeaders(text: String, headers: List): String { - var result = text - headers.forEach { - val startIndex = text.indexOf(it) - val endIndex = startIndex + it.length + 1 - result = text.replaceRange(startIndex, endIndex, it + "\n") - } - return result - } - - private fun getImageLinks(document: Document): Map { - val imageLinks = mutableMapOf() - val elements = document.getElementsByTag("img") - for (element in elements) { - if (element.hasAttr("alt")) { - imageLinks[element.attr("alt")] = element.attr("src") - } else { - imageLinks[element.attr("src")] = element.attr("src") - } - } - return imageLinks.toMap() - } } data class LinkedText( val text: String, val links: Map ) - -@Parcelize -data class LinkedImageText( - val text: String, - val links: Map, - val imageLinks: Map, - val headers: List -) : Parcelable 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..f91a19c6a 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,37 @@ 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.presentation.DownloadsAnalytics +import org.openedx.core.presentation.DownloadsAnalyticsEvent +import org.openedx.core.presentation.DownloadsAnalyticsKey +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 - + 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 val analytics: DownloadsAnalytics by inject(DownloadsAnalytics::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,14 +65,15 @@ class DownloadWorker( return Result.success() } - 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 + val serviceType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + } else { + 0 + } return ForegroundInfo( NOTIFICATION_ID, @@ -89,7 +94,7 @@ class DownloadWorker( val progress = 100 * value / size // Update no more than 5 times per sec if (!fileDownloader.isCanceled && - (System.currentTimeMillis() - lastUpdateTime > 200) + (System.currentTimeMillis() - lastUpdateTime > PROGRESS_UPDATE_INTERVAL) ) { lastUpdateTime = System.currentTimeMillis() @@ -119,7 +124,7 @@ class DownloadWorker( folder.mkdir() } - downloadEnqueue = downloadDao.readAllData().first() + downloadEnqueue = downloadDao.getAllDataFlow().first() .map { it.mapToDomain() } .filter { it.downloadedState == DownloadedState.WAITING } @@ -134,21 +139,38 @@ 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() + logEvent(DownloadsAnalyticsEvent.DOWNLOAD_STARTED) + val downloadResult = fileDownloader.download(downloadTask.url, downloadTask.path) + when (downloadResult) { + DownloadResult.SUCCESS -> { + logEvent(DownloadsAnalyticsEvent.DOWNLOAD_COMPLETED) + 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 -> { + logEvent(DownloadsAnalyticsEvent.DOWNLOAD_CANCELLED) + downloadDao.removeDownloadModel(downloadTask.id) + } + + DownloadResult.ERROR -> { + logEvent(DownloadsAnalyticsEvent.DOWNLOAD_ERROR) + downloadDao.removeDownloadModel(downloadTask.id) + downloadError.add(downloadTask) + } } newDownload() } else { + if (downloadError.isNotEmpty()) { + notifier.send(DownloadFailed(downloadError)) + } return } } @@ -160,12 +182,21 @@ class DownloadWorker( notificationManager.createNotificationChannel(notificationChannel) } + fun logEvent(event: DownloadsAnalyticsEvent) { + analytics.logEvent( + event = event.eventName, + params = buildMap { + put(DownloadsAnalyticsKey.NAME.key, event.biValue) + } + ) + } + companion object { const val WORKER_TAG = "downloadWorker" private const val CHANNEL_ID = "download_channel_ID" private const val CHANNEL_NAME = "download_channel_name" private const val NOTIFICATION_ID = 10 + private const val PROGRESS_UPDATE_INTERVAL = 200L } - } 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..e225bbae6 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,13 @@ 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.Logger +import org.openedx.core.utils.Sha1Util +import org.openedx.foundation.utils.FileUtil import subtitleFile.FormatSRT import subtitleFile.TimedTextObject import java.io.File @@ -14,21 +18,26 @@ import java.nio.charset.Charset import java.util.concurrent.TimeUnit class TranscriptManager( - val context: Context + val context: Context, + val fileUtil: FileUtil ) { + private val logger = Logger(TAG) + private val transcriptDownloader = object : AbstractDownloader() { override val client: OkHttpClient get() = OkHttpClient.Builder().build() } - var transcriptObject: TimedTextObject? = null + private var transcriptObject: TimedTextObject? = null - fun has(url: String): Boolean { + private fun has(url: String): Boolean { 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( + FILE_VALIDITY_DURATION_HOURS + ) } fun get(url: String): String? { @@ -50,21 +59,24 @@ class TranscriptManager( return if (!file.exists()) { // not in cache null - } else FileInputStream(file) + } else { + FileInputStream(file) + } } private suspend fun startTranscriptDownload(downloadLink: String) { - if (!has(downloadLink)) { - val file = File(getTranscriptDir(), Sha1Util.SHA1(downloadLink)) - val result = transcriptDownloader.download( - downloadLink, - file.path - ) - if (result) { - getInputStream(downloadLink)?.let { - val transcriptTimedTextObject = - convertIntoTimedTextObject(it) - transcriptObject = transcriptTimedTextObject + if (has(downloadLink)) return + val file = File(getTranscriptDir(), Sha1Util.SHA1(downloadLink)) + val result = transcriptDownloader.download( + downloadLink, + file.path + ) + if (result == AbstractDownloader.DownloadResult.SUCCESS) { + getInputStream(downloadLink)?.let { + try { + transcriptObject = convertIntoTimedTextObject(it) + } catch (e: NullPointerException) { + logger.e(throwable = e, submitCrashReport = true) } } } @@ -78,7 +90,7 @@ class TranscriptManager( try { transcriptObject = convertIntoTimedTextObject(transcriptInputStream) } catch (e: Exception) { - e.printStackTrace() + logger.e(throwable = e, submitCrashReport = true) } } else { startTranscriptDownload(transcriptUrl) @@ -96,24 +108,19 @@ class TranscriptManager( return timedTextObject } - fun fetchTranscriptResponse(url: String?): InputStream? { - if (url == null) { - return null - } - val response: InputStream? - try { - if (has(url)) { - response = getInputStream(url) - return response - } + private fun fetchTranscriptResponse(url: String?): InputStream? { + if (url == null) return null + + return try { + if (has(url)) getInputStream(url) else null } catch (e: IOException) { e.printStackTrace() + null } - return null } 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) @@ -123,4 +130,8 @@ class TranscriptManager( return null } -} \ No newline at end of file + companion object { + private const val TAG = "TranscriptManager" + private const val FILE_VALIDITY_DURATION_HOURS = 5L + } +} 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..377a8a2d9 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,13 @@ 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.DownloadCoursePreview +import org.openedx.core.data.model.room.OfflineXBlockProgress @Dao interface DownloadDao { @@ -16,11 +22,38 @@ interface DownloadDao { suspend fun updateDownloadModel(downloadModelEntity: DownloadModelEntity) @Query("SELECT * FROM download_model") - fun readAllData() : Flow> + fun getAllDataFlow(): Flow> + + @Query("SELECT * FROM download_model") + suspend fun readAllData(): List @Query("SELECT * FROM download_model WHERE id in (:ids)") - fun readAllDataByIds(ids: List) : Flow> + fun readAllDataByIds(ids: List): Flow> @Query("DELETE FROM download_model WHERE id in (:ids)") suspend fun removeAllDownloadModels(ids: List) + + @Query("SELECT * FROM download_model WHERE courseId = :courseId") + suspend fun getDownloadModelsByCourseIds(courseId: String): 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() + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertDownloadCoursePreview(downloadCoursePreview: List) + + @Query("SELECT * FROM download_course_preview_table") + fun getDownloadCoursesPreview(): List } 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..9f5abd3f4 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,22 +1,27 @@ 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; + WAITING, DOWNLOADING, DOWNLOADED, NOT_DOWNLOADED, LOADING_COURSE_STRUCTURE; val isWaitingOrDownloading: Boolean get() { - return this == WAITING || this == DOWNLOADING + return this == WAITING || this == DOWNLOADING || this == LOADING_COURSE_STRUCTURE } val isDownloaded: Boolean @@ -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..6414b67c7 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,16 +46,15 @@ data class DownloadModelEntity( return DownloadModelEntity( id, title, + courseId, size, path, url, type.name, downloadedState.name, - progress + lastModified ) } } - } - -} \ No newline at end of file +} 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..86fac4271 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,40 +35,46 @@ abstract class AbstractDownloader : KoinComponent { open suspend fun download( url: String, path: String - ): Boolean { + ): DownloadResult { isCanceled = false return try { - val response = downloadApi.downloadFile(url).body() - if (response != null) { - val file = File(path) - if (file.exists()) { - file.delete() + val responseBody = downloadApi.downloadFile(url).body() ?: return DownloadResult.ERROR + initializeFile(path) + responseBody.byteStream().use { inputStream -> + FileOutputStream(File(path)).use { outputStream -> + writeToFile(inputStream, outputStream) } - file.createNewFile() - input = response.byteStream() - currentDownloadingFilePath = path - fos = FileOutputStream(file) - fos.use { output -> - val buffer = ByteArray(4 * 1024) - var read: Int - while (input!!.read(buffer).also { read = it } != -1) { - output?.write(buffer, 0, read) - } - output?.flush() - } - true - } else { - false } + DownloadResult.SUCCESS } catch (e: Exception) { e.printStackTrace() - false + if (isCanceled) DownloadResult.CANCELED else DownloadResult.ERROR } finally { - fos?.close() - input?.close() + closeResources() + } + } + + private fun initializeFile(path: String) { + val file = File(path) + if (file.exists()) file.delete() + file.createNewFile() + currentDownloadingFilePath = path + } + + private fun writeToFile(inputStream: InputStream, outputStream: FileOutputStream) { + val buffer = ByteArray(BUFFER_SIZE) + var bytesRead: Int + while (inputStream.read(buffer).also { bytesRead = it } != -1) { + outputStream.write(buffer, 0, bytesRead) } + outputStream.flush() } + private fun closeResources() { + fos?.close() + input?.close() + currentDownloadingFilePath = null + } suspend fun cancelDownloading() { isCanceled = true @@ -88,4 +94,11 @@ abstract class AbstractDownloader : KoinComponent { } } -} \ No newline at end of file + enum class DownloadResult { + SUCCESS, CANCELED, ERROR + } + + companion object { + private const val BUFFER_SIZE = 4 * 1024 + } +} 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..ba87e6ab0 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,18 +16,17 @@ 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, private val downloadDao: DownloadDao, 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() @@ -36,13 +34,12 @@ abstract class BaseDownloadViewModel( private val _downloadModelsStatusFlow = MutableSharedFlow>() protected val downloadModelsStatusFlow = _downloadModelsStatusFlow.asSharedFlow() - private var downloadingModelsList = listOf() private val _downloadingModelsFlow = MutableSharedFlow>() protected val downloadingModelsFlow = _downloadingModelsFlow.asSharedFlow() 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) @@ -55,39 +52,59 @@ abstract class BaseDownloadViewModel( _downloadModelsStatusFlow.emit(downloadModelsStatus) } - private suspend fun getDownloadModelList(): List { - return downloadDao.readAllData().first().map { it.mapToDomain() } + suspend fun getDownloadModelList(): List { + return downloadDao.getAllDataFlow().first().map { it.mapToDomain() } } private suspend fun updateDownloadModelsStatus(models: List) { val downloadModelMap = models.associateBy { it.id } - for (item in downloadableChildrenMap) { - var downloadingCount = 0 - var downloadedCount = 0 - item.value.forEach { blockId -> - val downloadModel = downloadModelMap[blockId] - if (downloadModel != null) { - if (downloadModel.downloadedState.isWaitingOrDownloading) { - downloadModelsStatus[blockId] = DownloadedState.DOWNLOADING - downloadingCount++ - } else if (downloadModel.downloadedState.isDownloaded) { - downloadModelsStatus[blockId] = DownloadedState.DOWNLOADED - downloadedCount++ - } - } else { - downloadModelsStatus[blockId] = DownloadedState.NOT_DOWNLOADED + + downloadableChildrenMap.forEach { (parentId, children) -> + val (downloadingCount, downloadedCount) = updateChildrenStatus(children, downloadModelMap) + updateParentStatus(parentId, children.size, downloadingCount, downloadedCount) + } + + _downloadingModelsFlow.emit(models) + } + + private fun updateChildrenStatus( + children: List, + downloadModelMap: Map + ): Pair { + var downloadingCount = 0 + var downloadedCount = 0 + + children.forEach { blockId -> + val downloadModel = downloadModelMap[blockId] + downloadModelsStatus[blockId] = when { + downloadModel?.downloadedState?.isWaitingOrDownloading == true -> { + downloadingCount++ + DownloadedState.DOWNLOADING + } + + downloadModel?.downloadedState?.isDownloaded == true -> { + downloadedCount++ + DownloadedState.DOWNLOADED } - } - downloadModelsStatus[item.key] = when { - downloadingCount > 0 -> DownloadedState.DOWNLOADING - downloadedCount == item.value.size -> DownloadedState.DOWNLOADED else -> DownloadedState.NOT_DOWNLOADED } } - downloadingModelsList = models.filter { it.downloadedState.isWaitingOrDownloading } - _downloadingModelsFlow.emit(downloadingModelsList) + return downloadingCount to downloadedCount + } + + private fun updateParentStatus( + parentId: String, + childrenSize: Int, + downloadingCount: Int, + downloadedCount: Int + ) { + downloadModelsStatus[parentId] = when { + downloadingCount > 0 -> DownloadedState.DOWNLOADING + downloadedCount == childrenSize -> DownloadedState.DOWNLOADED + else -> DownloadedState.NOT_DOWNLOADED + } } protected fun setBlocks(list: List) { @@ -96,6 +113,10 @@ abstract class BaseDownloadViewModel( allBlocks.putAll(list.map { it.id to it }) } + protected fun addBlocks(list: List) { + allBlocks.putAll(list.map { it.id to it }) + } + fun isBlockDownloading(id: String): Boolean { val blockDownloadingState = downloadModelsStatus[id] return blockDownloadingState?.isWaitingOrDownloading == true @@ -106,48 +127,31 @@ abstract class BaseDownloadViewModel( return blockDownloadingState == DownloadedState.DOWNLOADED } - open fun saveDownloadModels(folder: String, id: String) { + open fun saveDownloadModels(folder: String, courseId: String, id: String) { viewModelScope.launch { val saveBlocksIds = downloadableChildrenMap[id] ?: listOf() - logSubsectionDownloadEvent(id, saveBlocksIds.size) - saveDownloadModels(folder, saveBlocksIds) + logSubsectionDownloadEvent(id, saveBlocksIds.size, courseId) + saveDownloadModels(folder, courseId, saveBlocksIds) } } - open fun saveAllDownloadModels(folder: String) { + open fun saveAllDownloadModels(folder: String, courseId: String) { viewModelScope.launch { val saveBlocksIds = downloadableChildrenMap.values.flatten() - saveDownloadModels(folder, saveBlocksIds) + saveDownloadModels(folder, courseId, saveBlocksIds) } } - private suspend fun saveDownloadModels(folder: String, saveBlocksIds: List) { + suspend fun saveDownloadModels(folder: String, courseId: 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) } } } @@ -193,81 +197,78 @@ abstract class BaseDownloadViewModel( ) } - fun hasDownloadModelsInQueue() = downloadingModelsList.isNotEmpty() - fun getDownloadableChildren(id: String) = downloadableChildrenMap[id] - open fun removeDownloadModels(blockId: String) { + open fun removeDownloadModels(blockId: String, courseId: String) { viewModelScope.launch { val downloadableChildren = downloadableChildrenMap[blockId] ?: listOf() - logSubsectionDeleteEvent(blockId, downloadableChildren.size) + logSubsectionDeleteEvent(blockId, downloadableChildren.size, courseId) workerController.removeModels(downloadableChildren) } } - fun removeAllDownloadModels() { + fun removeBlockDownloadModel(blockId: String) { viewModelScope.launch { - val downloadableChildren = downloadableChildrenMap.values.flatten() - workerController.removeModels(downloadableChildren) + workerController.removeModel(blockId) } } + @Suppress("NestedBlockDepth") protected fun addDownloadableChildrenForSequentialBlock(sequentialBlock: Block) { - for (item in sequentialBlock.descendants) { - allBlocks[item]?.let { blockDescendant -> - if (blockDescendant.type == BlockType.VERTICAL) { - for (unitBlockId in blockDescendant.descendants) { - val block = allBlocks[unitBlockId] - if (block?.isDownloadable == true) { - val id = sequentialBlock.id - val children = downloadableChildrenMap[id] ?: listOf() - downloadableChildrenMap[id] = children + block.id - } + sequentialBlock.descendants.forEach { descendantId -> + val blockDescendant = allBlocks[descendantId] ?: return@forEach + + if (blockDescendant.type == BlockType.VERTICAL) { + blockDescendant.descendants.forEach { unitBlockId -> + val block = allBlocks[unitBlockId] + if (block?.isDownloadable == true) { + addDownloadableChild(sequentialBlock.id, block.id) } } } } } - 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 - } - } + private fun addDownloadableChild(parentId: String, childId: String) { + val children = downloadableChildrenMap[parentId] ?: listOf() + downloadableChildrenMap[parentId] = children + childId } - fun logBulkDownloadToggleEvent(toggle: Boolean) { - logEvent( - CoreAnalyticsEvent.VIDEO_BULK_DOWNLOAD_TOGGLE, - buildMap { - put(CoreAnalyticsKey.ACTION.key, toggle) - } - ) - } - - private fun logSubsectionDownloadEvent(subsectionId: String, numberOfVideos: Int) { + private fun logSubsectionDownloadEvent( + subsectionId: String, + numberOfVideos: Int, + courseId: String + ) { logEvent( CoreAnalyticsEvent.VIDEO_DOWNLOAD_SUBSECTION, buildMap { put(CoreAnalyticsKey.BLOCK_ID.key, subsectionId) put(CoreAnalyticsKey.NUMBER_OF_VIDEOS.key, numberOfVideos) - }) + }, + courseId + ) } - private fun logSubsectionDeleteEvent(subsectionId: String, numberOfVideos: Int) { + private fun logSubsectionDeleteEvent( + subsectionId: String, + numberOfVideos: Int, + courseId: String + ) { logEvent( CoreAnalyticsEvent.VIDEO_DELETE_SUBSECTION, buildMap { put(CoreAnalyticsKey.BLOCK_ID.key, subsectionId) put(CoreAnalyticsKey.NUMBER_OF_VIDEOS.key, numberOfVideos) - }) + }, + courseId + ) } - private fun logEvent(event: CoreAnalyticsEvent, param: Map = emptyMap()) { + private fun logEvent( + event: CoreAnalyticsEvent, + param: Map = emptyMap(), + courseId: String + ) { analytics.logEvent( event.eventName, buildMap { 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..327d4814e --- /dev/null +++ b/core/src/main/java/org/openedx/core/module/download/DownloadHelper.kt @@ -0,0 +1,106 @@ +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 { + if (!directory.exists()) return 0 + + return directory.listFiles()?.sumOf { file -> + if (file.isDirectory) { + calculateDirectorySize(file) + } else { + file.length() + } + } ?: 0 + } +} diff --git a/core/src/main/java/org/openedx/core/module/download/DownloadModelsSize.kt b/core/src/main/java/org/openedx/core/module/download/DownloadModelsSize.kt index b40876c99..8db4d05b6 100644 --- a/core/src/main/java/org/openedx/core/module/download/DownloadModelsSize.kt +++ b/core/src/main/java/org/openedx/core/module/download/DownloadModelsSize.kt @@ -7,4 +7,3 @@ data class DownloadModelsSize( val allCount: Int, val allSize: Long ) - diff --git a/core/src/main/java/org/openedx/core/module/download/DownloadType.kt b/core/src/main/java/org/openedx/core/module/download/DownloadType.kt index 1810ee23d..5310e10cd 100644 --- a/core/src/main/java/org/openedx/core/module/download/DownloadType.kt +++ b/core/src/main/java/org/openedx/core/module/download/DownloadType.kt @@ -2,4 +2,4 @@ package org.openedx.core.module.download enum class DownloadType { VIDEO, SCORM, HTML -} \ No newline at end of file +} diff --git a/core/src/main/java/org/openedx/core/module/download/FileDownloader.kt b/core/src/main/java/org/openedx/core/module/download/FileDownloader.kt index fe68e696f..350cad365 100644 --- a/core/src/main/java/org/openedx/core/module/download/FileDownloader.kt +++ b/core/src/main/java/org/openedx/core/module/download/FileDownloader.kt @@ -14,12 +14,14 @@ class FileDownloader : AbstractDownloader(), ProgressListener { private var firstUpdate = true override val client: OkHttpClient = OkHttpClient.Builder() - .addNetworkInterceptor(Interceptor { chain: Interceptor.Chain -> - val originalResponse: Response = chain.proceed(chain.request()) - originalResponse.newBuilder() - .body(ProgressResponseBody(originalResponse.body!!, this)) - .build() - }) + .addNetworkInterceptor( + Interceptor { chain: Interceptor.Chain -> + val originalResponse: Response = chain.proceed(chain.request()) + originalResponse.newBuilder() + .body(ProgressResponseBody(originalResponse.body!!, this)) + .build() + } + ) .build() var progressListener: CurrentProgress? = null @@ -42,7 +44,6 @@ class FileDownloader : AbstractDownloader(), ProgressListener { } } } - } interface CurrentProgress { @@ -54,5 +55,4 @@ interface DownloadApi { @Streaming @GET suspend fun downloadFile(@Url fileUrl: String): retrofit2.Response - } diff --git a/core/src/main/java/org/openedx/core/module/download/ProgressListener.kt b/core/src/main/java/org/openedx/core/module/download/ProgressListener.kt index 0acf4f320..53f2f2de3 100644 --- a/core/src/main/java/org/openedx/core/module/download/ProgressListener.kt +++ b/core/src/main/java/org/openedx/core/module/download/ProgressListener.kt @@ -2,7 +2,11 @@ package org.openedx.core.module.download import okhttp3.MediaType import okhttp3.ResponseBody -import okio.* +import okio.Buffer +import okio.BufferedSource +import okio.ForwardingSource +import okio.Source +import okio.buffer class ProgressResponseBody( private val responseBody: ResponseBody, @@ -38,12 +42,10 @@ class ProgressResponseBody( ) return bytesRead } - } } } - interface ProgressListener { fun update(bytesRead: Long, contentLength: Long, done: Boolean) -} \ No newline at end of file +} diff --git a/core/src/main/java/org/openedx/core/presentation/DownloadsAnalytics.kt b/core/src/main/java/org/openedx/core/presentation/DownloadsAnalytics.kt new file mode 100644 index 000000000..625140d4f --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/DownloadsAnalytics.kt @@ -0,0 +1,49 @@ +package org.openedx.core.presentation + +interface DownloadsAnalytics { + fun logEvent(event: String, params: Map) + fun logScreenEvent(screenName: String, params: Map) +} + +enum class DownloadsAnalyticsEvent(val eventName: String, val biValue: String) { + DOWNLOAD_COURSE_CLICKED( + "Downloads:Download Course Clicked", + "edx.bi.app.downloads.downloadCourseClicked" + ), + CANCEL_DOWNLOAD_CLICKED( + "Downloads:Cancel Download Clicked", + "edx.bi.app.downloads.cancelDownloadClicked" + ), + REMOVE_DOWNLOAD_CLICKED( + "Downloads:Remove Download Clicked", + "edx.bi.app.downloads.removeDownloadClicked" + ), + DOWNLOAD_CONFIRMED( + "Downloads:Download Confirmed", + "edx.bi.app.downloads.downloadConfirmed" + ), + DOWNLOAD_CANCELLED( + "Downloads:Download Cancelled", + "edx.bi.app.downloads.downloadCancelled" + ), + DOWNLOAD_REMOVED( + "Downloads:Download Removed", + "edx.bi.app.downloads.downloadRemoved" + ), + DOWNLOAD_ERROR( + "Downloads:Download Error", + "edx.bi.app.downloads.downloadError" + ), + DOWNLOAD_COMPLETED( + "Downloads:Download Completed", + "edx.bi.app.downloads.downloadCompleted" + ), + DOWNLOAD_STARTED( + "Downloads:Download Started", + "edx.bi.app.downloads.downloadStarted" + ), +} + +enum class DownloadsAnalyticsKey(val key: String) { + NAME("name"), +} 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/course/CourseViewMode.kt b/core/src/main/java/org/openedx/core/presentation/course/CourseViewMode.kt deleted file mode 100644 index c2aaf97cb..000000000 --- a/core/src/main/java/org/openedx/core/presentation/course/CourseViewMode.kt +++ /dev/null @@ -1,6 +0,0 @@ -package org.openedx.core.presentation.course - -enum class CourseViewMode { - FULL, - VIDEOS -} \ No newline at end of file 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..28f357896 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 @@ -34,13 +34,13 @@ import org.openedx.core.config.Config import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.presentation.CoreAnalyticsEvent import org.openedx.core.presentation.CoreAnalyticsKey -import org.openedx.core.presentation.global.app_upgrade.DefaultTextButton -import org.openedx.core.presentation.global.app_upgrade.TransparentTextButton +import org.openedx.core.presentation.global.appupgrade.DefaultTextButton +import org.openedx.core.presentation.global.appupgrade.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.core.utils.UrlUtils +import org.openedx.foundation.utils.UrlUtils class ActionDialogFragment : DialogFragment() { diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/alert/InfoDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/alert/InfoDialogFragment.kt index bc41d936d..77c413924 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/alert/InfoDialogFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/alert/InfoDialogFragment.kt @@ -27,7 +27,7 @@ import androidx.compose.ui.unit.dp import androidx.core.os.bundleOf import androidx.fragment.app.DialogFragment import org.openedx.core.R -import org.openedx.core.presentation.global.app_upgrade.DefaultTextButton +import org.openedx.core.presentation.global.appupgrade.DefaultTextButton import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/appreview/AppReviewManager.kt b/core/src/main/java/org/openedx/core/presentation/dialog/appreview/AppReviewManager.kt index c825a8e9b..06a2a278a 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/appreview/AppReviewManager.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/appreview/AppReviewManager.kt @@ -17,11 +17,11 @@ class AppReviewManager( isDialogShowed = true val currentVersionName = reviewPreferences.formatVersionName(appData.versionName) // Check is app wasn't positive rated AND 2 minor OR 1 major app versions passed since the last review - if ( - !reviewPreferences.wasPositiveRated - && (currentVersionName.minorVersion - 2 >= reviewPreferences.lastReviewVersion.minorVersion - || currentVersionName.majorVersion - 1 >= reviewPreferences.lastReviewVersion.majorVersion) - ) { + val minorVersionPassed = + currentVersionName.minorVersion - 2 >= reviewPreferences.lastReviewVersion.minorVersion + val majorVersionPassed = + currentVersionName.majorVersion - 1 >= reviewPreferences.lastReviewVersion.majorVersion + if (!reviewPreferences.wasPositiveRated && (minorVersionPassed || majorVersionPassed)) { val dialog = RateDialogFragment.newInstance() dialog.show( supportFragmentManager, @@ -30,4 +30,4 @@ class AppReviewManager( } } } -} \ No newline at end of file +} 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..632669c11 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 @@ -376,7 +333,7 @@ fun RatingBar( } .pointerInput(Unit) { detectTapGestures { offset -> - rating.intValue = round(offset.x / maxXValue * stars + 0.8f).toInt() + rating.intValue = round(x = offset.x / maxXValue * stars + 0.8f).toInt() } }, horizontalArrangement = Arrangement.Center @@ -461,4 +418,4 @@ private fun ThankYouDialogWithoutButtonsPreview() { onRateUsClick = {} ) } -} \ No newline at end of file +} 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/appreview/FeedbackDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/appreview/FeedbackDialogFragment.kt index 03d449c5f..1bb9d8156 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/appreview/FeedbackDialogFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/appreview/FeedbackDialogFragment.kt @@ -76,7 +76,6 @@ class FeedbackDialogFragment : BaseAppReviewDialogFragment() { ) } - override fun dismiss() { onDismiss() } @@ -86,4 +85,4 @@ class FeedbackDialogFragment : BaseAppReviewDialogFragment() { return FeedbackDialogFragment() } } -} \ No newline at end of file +} diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/appreview/RateDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/appreview/RateDialogFragment.kt index c8f49153c..945b81819 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/appreview/RateDialogFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/appreview/RateDialogFragment.kt @@ -36,7 +36,7 @@ class RateDialogFragment : BaseAppReviewDialogFragment() { private fun onSubmitClick(rating: Int) { onSubmitRatingClick(rating) - if (rating > 3) { + if (rating > MIN_RATE) { openThankYouDialog() } else { openFeedbackDialog() @@ -66,8 +66,10 @@ class RateDialogFragment : BaseAppReviewDialogFragment() { } companion object { + private const val MIN_RATE = 3 + fun newInstance(): RateDialogFragment { return RateDialogFragment() } } -} \ No newline at end of file +} diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/appreview/ThankYouDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/appreview/ThankYouDialogFragment.kt index 137672f45..9efdd694a 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/appreview/ThankYouDialogFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/appreview/ThankYouDialogFragment.kt @@ -61,7 +61,7 @@ class ThankYouDialogFragment : BaseAppReviewDialogFragment() { private fun closeDialogDelay(isFeedbackPositive: Boolean) { if (!isFeedbackPositive) { lifecycleScope.launch { - delay(3000) + delay(timeMillis = 3000) dismiss() } } diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/appupgrade/AppUpgradeDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/appupgrade/AppUpgradeDialogFragment.kt index 4c5c4ce56..a558e8b40 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/appupgrade/AppUpgradeDialogFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/appupgrade/AppUpgradeDialogFragment.kt @@ -7,9 +7,9 @@ import android.view.ViewGroup import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.fragment.app.DialogFragment -import org.openedx.core.presentation.global.app_upgrade.AppUpgradeRecommendDialog -import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.AppUpdateState +import org.openedx.core.presentation.global.appupgrade.AppUpgradeRecommendDialog +import org.openedx.core.ui.theme.OpenEdXTheme class AppUpgradeDialogFragment : DialogFragment() { @@ -33,12 +33,12 @@ class AppUpgradeDialogFragment : DialogFragment() { } private fun onNotNowClick() { - AppUpdateState.wasUpdateDialogClosed.value = true + AppUpdateState.wasUpgradeDialogClosed.value = true dismiss() } private fun onUpdateClick() { - AppUpdateState.wasUpdateDialogClosed.value = true + AppUpdateState.wasUpgradeDialogClosed.value = true dismiss() AppUpdateState.openPlayMarket(requireContext()) } @@ -48,5 +48,4 @@ class AppUpgradeDialogFragment : DialogFragment() { return AppUpgradeDialogFragment() } } - } diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadConfirmDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadConfirmDialogFragment.kt new file mode 100644 index 000000000..5ab8db529 --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadConfirmDialogFragment.kt @@ -0,0 +1,272 @@ +package org.openedx.core.presentation.dialog.downloaddialog + +import android.graphics.Color +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.heightIn +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.graphics.drawable.toDrawable +import androidx.core.os.bundleOf +import androidx.fragment.app.DialogFragment +import org.openedx.core.R +import org.openedx.core.domain.model.DownloadDialogResource +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.foundation.extension.parcelable +import org.openedx.foundation.extension.toFileSize +import org.openedx.foundation.system.PreviewFragmentManager +import androidx.compose.ui.graphics.Color as ComposeColor + +class DownloadConfirmDialogFragment : DialogFragment(), DownloadDialog { + + override var listener: DownloadDialogListener? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + dialog?.window?.setBackgroundDrawable(Color.TRANSPARENT.toDrawable()) + 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 = R.string.course_confirm_download), + description = stringResource( + id = R.string.core_download_confirm_dialog_description, + sizeSumString + ), + ) + + DownloadConfirmDialogType.DOWNLOAD_ON_CELLULAR -> DownloadDialogResource( + title = stringResource(id = R.string.core_download_on_cellural), + description = stringResource( + id = R.string.core_download_on_cellural_dialog_description, + sizeSumString + ), + icon = painterResource(id = R.drawable.core_ic_warning), + ) + + DownloadConfirmDialogType.REMOVE -> DownloadDialogResource( + title = stringResource(id = R.string.core_download_remove_offline_content), + description = stringResource( + id = R.string.core_download_remove_dialog_description, + sizeSumString + ) + ) + } + + DownloadConfirmDialogView( + downloadDialogResource = dialogData, + uiState = uiState, + dialogType = dialogType, + onConfirmClick = { + uiState.saveDownloadModels() + dismiss() + listener?.onConfirmClick() + }, + onRemoveClick = { + uiState.removeDownloadModels() + dismiss() + }, + onCancelClick = { + dismiss() + listener?.onCancelClick() + } + ) + } + } + } + + companion object { + 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() + .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( + modifier = Modifier + .heightIn(max = DownloadDialogManager.listMaxSize) + .verticalScroll(scrollState) + ) { + 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.core_remove) + buttonIcon = Icons.Rounded.Delete + buttonColor = MaterialTheme.appColors.error + onClick = onRemoveClick + } + + else -> { + buttonText = stringResource(id = R.string.core_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 = R.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/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadConfirmDialogType.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadConfirmDialogType.kt new file mode 100644 index 000000000..a14a1033c --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadConfirmDialogType.kt @@ -0,0 +1,9 @@ +package org.openedx.core.presentation.dialog.downloaddialog + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +enum class DownloadConfirmDialogType : Parcelable { + DOWNLOAD_ON_CELLULAR, CONFIRM, REMOVE +} diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogItem.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogItem.kt new file mode 100644 index 000000000..2e29ccec4 --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogItem.kt @@ -0,0 +1,13 @@ +package org.openedx.core.presentation.dialog.downloaddialog + +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/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogManager.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogManager.kt new file mode 100644 index 000000000..cc9959c79 --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogManager.kt @@ -0,0 +1,364 @@ +package org.openedx.core.presentation.dialog.downloaddialog + +import android.content.res.Configuration +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.School +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +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.interactor.CourseInteractor +import org.openedx.core.domain.model.Block +import org.openedx.core.domain.model.DownloadCoursePreview +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.foundation.presentation.rememberWindowSize + +interface DownloadDialogListener { + fun onCancelClick() + fun onConfirmClick() +} + +interface DownloadDialog { + var listener: DownloadDialogListener? +} + +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 + + val listMaxSize: Dp + @Composable + get() { + val configuration = LocalConfiguration.current + val windowSize = rememberWindowSize() + return when { + configuration.orientation == Configuration.ORIENTATION_PORTRAIT || windowSize.isTablet -> { + 200.dp + } + + else -> { + 88.dp + } + } + } + } + + 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 + } + + val dialogListener = object : DownloadDialogListener { + override fun onCancelClick() { + state.onDismissClick() + } + + override fun onConfirmClick() { + state.onConfirmClick() + } + } + if (dialog != null) { + dialog.listener = dialogListener + dialog.show(state.fragmentManager, dialog::class.java.simpleName) + } else { + state.onConfirmClick() + state.saveDownloadModels() + } + } + } + } + + fun showPopup( + subSectionsBlocks: List, + courseId: String, + isBlocksDownloaded: Boolean, + onlyVideoBlocks: Boolean = false, + fragmentManager: FragmentManager, + removeDownloadModels: (blockId: String, courseId: String) -> Unit, + saveDownloadModels: (blockId: String) -> Unit, + onDismissClick: () -> Unit = {}, + onConfirmClick: () -> Unit = {}, + ) { + createDownloadItems( + subSectionsBlocks = subSectionsBlocks, + courseId = courseId, + fragmentManager = fragmentManager, + isBlocksDownloaded = isBlocksDownloaded, + onlyVideoBlocks = onlyVideoBlocks, + removeDownloadModels = removeDownloadModels, + saveDownloadModels = saveDownloadModels, + onDismissClick = onDismissClick, + onConfirmClick = onConfirmClick + ) + } + + fun showPopup( + coursePreview: DownloadCoursePreview, + isBlocksDownloaded: Boolean, + fragmentManager: FragmentManager, + removeDownloadModels: (blockId: String, courseId: String) -> Unit, + saveDownloadModels: () -> Unit, + onDismissClick: () -> Unit = {}, + onConfirmClick: () -> Unit = {}, + ) { + createCourseDownloadItems( + coursePreview = coursePreview, + fragmentManager = fragmentManager, + isBlocksDownloaded = isBlocksDownloaded, + removeDownloadModels = removeDownloadModels, + saveDownloadModels = saveDownloadModels, + onDismissClick = onDismissClick, + onConfirmClick = onConfirmClick + ) + } + + 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 { it.getFileSize() } + + 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, courseId: String) -> Unit, + saveDownloadModels: (blockId: String) -> Unit, + onDismissClick: () -> Unit = {}, + onConfirmClick: () -> 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 { it.getFileSize() } + 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, + courseId + ) + } + }, + saveDownloadModels = { subSectionsBlocks.forEach { saveDownloadModels(it.id) } }, + onDismissClick = onDismissClick, + onConfirmClick = onConfirmClick, + ) + ) + } + } + + private fun createCourseDownloadItems( + coursePreview: DownloadCoursePreview, + fragmentManager: FragmentManager, + isBlocksDownloaded: Boolean, + removeDownloadModels: (blockId: String, courseId: String) -> Unit, + saveDownloadModels: () -> Unit, + onDismissClick: () -> Unit = {}, + onConfirmClick: () -> Unit = {}, + ) { + coroutineScope.launch { + val downloadDialogItems = listOf( + DownloadDialogItem( + title = coursePreview.name, + size = coursePreview.totalSize, + icon = Icons.Default.School + ) + ) + + uiState.emit( + DownloadDialogUIState( + downloadDialogItems = downloadDialogItems, + isAllBlocksDownloaded = isBlocksDownloaded, + isDownloadFailed = false, + sizeSum = downloadDialogItems.sumOf { it.size }, + fragmentManager = fragmentManager, + removeDownloadModels = { + coroutineScope.launch { + val downloadModels = interactor.getAllDownloadModels().filter { + it.courseId == coursePreview.id + } + downloadModels.forEach { + removeDownloadModels( + it.id, + coursePreview.id + ) + } + } + }, + saveDownloadModels = saveDownloadModels, + onDismissClick = onDismissClick, + onConfirmClick = onConfirmClick, + ) + ) + } + } +} diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogUIState.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogUIState.kt new file mode 100644 index 000000000..72288449b --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogUIState.kt @@ -0,0 +1,19 @@ +package org.openedx.core.presentation.dialog.downloaddialog + +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, + val onDismissClick: () -> Unit = {}, + val onConfirmClick: () -> Unit = {}, +) : Parcelable diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadErrorDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadErrorDialogFragment.kt new file mode 100644 index 000000000..f7bbe6ea5 --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadErrorDialogFragment.kt @@ -0,0 +1,227 @@ +package org.openedx.core.presentation.dialog.downloaddialog + +import android.graphics.Color +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.heightIn +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.graphics.drawable.toDrawable +import androidx.core.os.bundleOf +import androidx.fragment.app.DialogFragment +import org.openedx.core.R +import org.openedx.core.domain.model.DownloadDialogResource +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.foundation.extension.parcelable +import org.openedx.foundation.system.PreviewFragmentManager + +class DownloadErrorDialogFragment : DialogFragment(), DownloadDialog { + + override var listener: DownloadDialogListener? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + dialog?.window?.setBackgroundDrawable(Color.TRANSPARENT.toDrawable()) + 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 = R.string.core_no_internet_connection), + description = stringResource(id = R.string.core_download_no_internet_dialog_description), + icon = painterResource(id = R.drawable.core_ic_error), + ) + + DownloadErrorDialogType.WIFI_REQUIRED -> DownloadDialogResource( + title = stringResource(id = R.string.core_wifi_required), + description = stringResource(id = R.string.core_download_wifi_required_dialog_description), + icon = painterResource(id = R.drawable.core_ic_error), + ) + + DownloadErrorDialogType.DOWNLOAD_FAILED -> DownloadDialogResource( + title = stringResource(id = R.string.core_download_failed), + description = stringResource(id = R.string.core_download_failed_dialog_description), + icon = painterResource(id = R.drawable.core_ic_error), + ) + } + + DownloadErrorDialogView( + downloadDialogResource = downloadDialogResource, + uiState = uiState, + dialogType = dialogType, + onTryAgainClick = { + uiState.saveDownloadModels() + dismiss() + }, + onCancelClick = { + dismiss() + listener?.onCancelClick() + } + ) + } + } + } + + companion object { + 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 = R.string.core_cancel) + else -> stringResource(id = R.string.core_close) + } + DefaultDialogBox( + modifier = modifier, + onDismissClick = onCancelClick + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .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( + modifier = Modifier + .heightIn(max = DownloadDialogManager.listMaxSize) + .verticalScroll(scrollState) + ) { + 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 = R.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.core_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/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadErrorDialogType.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadErrorDialogType.kt new file mode 100644 index 000000000..5bb035f07 --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadErrorDialogType.kt @@ -0,0 +1,9 @@ +package org.openedx.core.presentation.dialog.downloaddialog + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +enum class DownloadErrorDialogType : Parcelable { + NO_CONNECTION, WIFI_REQUIRED, DOWNLOAD_FAILED +} diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadStorageErrorDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadStorageErrorDialogFragment.kt new file mode 100644 index 000000000..8c026bdf2 --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadStorageErrorDialogFragment.kt @@ -0,0 +1,300 @@ +package org.openedx.core.presentation.dialog.downloaddialog + +import android.graphics.Color +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.heightIn +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.graphics.drawable.toDrawable +import androidx.core.os.bundleOf +import androidx.fragment.app.DialogFragment +import org.openedx.core.R +import org.openedx.core.domain.model.DownloadDialogResource +import org.openedx.core.presentation.dialog.DefaultDialogBox +import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager.Companion.DOWNLOAD_SIZE_FACTOR +import org.openedx.core.presentation.dialog.downloaddialog.DownloadStorageErrorDialogFragment.Companion.STORAGE_BAR_MIN_SIZE +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.foundation.extension.parcelable +import org.openedx.foundation.extension.toFileSize +import org.openedx.foundation.system.PreviewFragmentManager + +class DownloadStorageErrorDialogFragment : DialogFragment(), DownloadDialog { + + override var listener: DownloadDialogListener? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + dialog?.window?.setBackgroundDrawable(Color.TRANSPARENT.toDrawable()) + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val uiState = requireArguments().parcelable(ARG_UI_STATE) + ?: return@OpenEdXTheme + val downloadDialogResource = DownloadDialogResource( + title = stringResource(id = R.string.core_device_storage_full), + description = stringResource(id = R.string.core_download_device_storage_full_dialog_description), + icon = painterResource(id = R.drawable.core_ic_error), + ) + + DownloadStorageErrorDialogView( + uiState = uiState, + downloadDialogResource = downloadDialogResource, + onCancelClick = { + dismiss() + listener?.onCancelClick() + } + ) + } + } + } + + companion object { + const val ARG_UI_STATE = "uiState" + const val STORAGE_BAR_MIN_SIZE = 0.1f + + 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() + .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( + modifier = Modifier + .heightIn(max = DownloadDialogManager.listMaxSize) + .verticalScroll(scrollState) + ) { + 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 = R.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 freePercentage = freeSpace / requiredSpace.toFloat() + STORAGE_BAR_MIN_SIZE + val reqPercentage = (requiredSpace - freeSpace) / requiredSpace.toFloat() + STORAGE_BAR_MIN_SIZE + + 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.core_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.core_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/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadView.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadView.kt new file mode 100644 index 000000000..58a5f9d22 --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadView.kt @@ -0,0 +1,59 @@ +package org.openedx.core.presentation.dialog.downloaddialog + +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.core_ic_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/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..3890aa360 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 @@ -25,20 +25,19 @@ import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp -import androidx.fragment.app.DialogFragment import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog 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() { @@ -47,7 +46,7 @@ class SelectBottomDialogFragment : BottomSheetDialogFragment() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) viewModel.values = requireArguments().parcelableArrayList(ARG_LIST_VALUES)!! - setStyle(DialogFragment.STYLE_NORMAL, R.style.BottomSheetDialog) + setStyle(STYLE_NORMAL, R.style.BottomSheetDialog) } override fun onCreateView( @@ -95,7 +94,7 @@ class SelectBottomDialogFragment : BottomSheetDialogFragment() { ) .clip(MaterialTheme.appShapes.screenBackgroundShape) .padding(bottom = if (isImeVisible) 120.dp else 0.dp) - .noRippleClickable { } + .noRippleClickable { } ) { SheetContent( searchValue = searchValue, @@ -129,5 +128,4 @@ class SelectBottomDialogFragment : BottomSheetDialogFragment() { return dialog } } - -} \ No newline at end of file +} 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..f215974ce 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 @@ -18,5 +18,4 @@ class SelectDialogViewModel( notifier.send(CourseSubtitleLanguageChanged(value)) } } - } diff --git a/core/src/main/java/org/openedx/core/presentation/global/AppData.kt b/core/src/main/java/org/openedx/core/presentation/global/AppData.kt index 324d3325a..fab1a72e7 100644 --- a/core/src/main/java/org/openedx/core/presentation/global/AppData.kt +++ b/core/src/main/java/org/openedx/core/presentation/global/AppData.kt @@ -1,5 +1,9 @@ package org.openedx.core.presentation.global data class AppData( + val appName: String, + val applicationId: String, val versionName: String, -) +) { + val appUserAgent get() = "$appName/$applicationId/$versionName" +} 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/FragmentViewBindingDelegate.kt b/core/src/main/java/org/openedx/core/presentation/global/FragmentViewBindingDelegate.kt index 35163150c..56708b051 100644 --- a/core/src/main/java/org/openedx/core/presentation/global/FragmentViewBindingDelegate.kt +++ b/core/src/main/java/org/openedx/core/presentation/global/FragmentViewBindingDelegate.kt @@ -40,7 +40,7 @@ class FragmentViewBindingDelegate( val lifecycle = fragment.viewLifecycleOwner.lifecycle if (!lifecycle.currentState.isAtLeast(Lifecycle.State.INITIALIZED)) { - throw IllegalStateException("Should not attempt to get bindings when Fragment views are destroyed.") + error("Should not attempt to get bindings when Fragment views are destroyed.") } return viewBindingFactory(thisRef.requireView()).also { this.binding = it } @@ -54,4 +54,4 @@ inline fun AppCompatActivity.viewBinding( crossinline bindingInflater: (LayoutInflater) -> T, ) = lazy(LazyThreadSafetyMode.NONE) { bindingInflater.invoke(layoutInflater) -} \ No newline at end of file +} diff --git a/core/src/main/java/org/openedx/core/presentation/global/InsetHolder.kt b/core/src/main/java/org/openedx/core/presentation/global/InsetHolder.kt index 26996f162..9224f09d1 100644 --- a/core/src/main/java/org/openedx/core/presentation/global/InsetHolder.kt +++ b/core/src/main/java/org/openedx/core/presentation/global/InsetHolder.kt @@ -1,8 +1,7 @@ package org.openedx.core.presentation.global - interface InsetHolder { val topInset: Int val bottomInset: Int val cutoutInset: Int -} \ No newline at end of file +} diff --git a/core/src/main/java/org/openedx/core/presentation/global/WhatsNewGlobalManager.kt b/core/src/main/java/org/openedx/core/presentation/global/WhatsNewGlobalManager.kt index e2cf46a4e..9c91d4ea3 100644 --- a/core/src/main/java/org/openedx/core/presentation/global/WhatsNewGlobalManager.kt +++ b/core/src/main/java/org/openedx/core/presentation/global/WhatsNewGlobalManager.kt @@ -2,4 +2,4 @@ package org.openedx.core.presentation.global interface WhatsNewGlobalManager { fun shouldShowWhatsNew(): Boolean -} \ No newline at end of file +} 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/appupgrade/AppUpdateUI.kt similarity index 98% rename from core/src/main/java/org/openedx/core/presentation/global/app_upgrade/AppUpdateUI.kt rename to core/src/main/java/org/openedx/core/presentation/global/appupgrade/AppUpdateUI.kt index f0502b49d..e0cbae480 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/appupgrade/AppUpdateUI.kt @@ -1,4 +1,4 @@ -package org.openedx.core.presentation.global.app_upgrade +package org.openedx.core.presentation.global.appupgrade import android.content.res.Configuration.ORIENTATION_LANDSCAPE import android.content.res.Configuration.UI_MODE_NIGHT_NO @@ -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/app_upgrade/AppUpgradeRouter.kt b/core/src/main/java/org/openedx/core/presentation/global/appupgrade/AppUpgradeRouter.kt similarity index 68% rename from core/src/main/java/org/openedx/core/presentation/global/app_upgrade/AppUpgradeRouter.kt rename to core/src/main/java/org/openedx/core/presentation/global/appupgrade/AppUpgradeRouter.kt index 482c91093..fa4a13f80 100644 --- a/core/src/main/java/org/openedx/core/presentation/global/app_upgrade/AppUpgradeRouter.kt +++ b/core/src/main/java/org/openedx/core/presentation/global/appupgrade/AppUpgradeRouter.kt @@ -1,7 +1,7 @@ -package org.openedx.core.presentation.global.app_upgrade +package org.openedx.core.presentation.global.appupgrade import androidx.fragment.app.FragmentManager interface AppUpgradeRouter { fun navigateToUserProfile(fm: FragmentManager) -} \ No newline at end of file +} diff --git a/core/src/main/java/org/openedx/core/presentation/global/app_upgrade/UpgradeRequiredFragment.kt b/core/src/main/java/org/openedx/core/presentation/global/appupgrade/UpgradeRequiredFragment.kt similarity index 96% rename from core/src/main/java/org/openedx/core/presentation/global/app_upgrade/UpgradeRequiredFragment.kt rename to core/src/main/java/org/openedx/core/presentation/global/appupgrade/UpgradeRequiredFragment.kt index da8685435..4176146c9 100644 --- a/core/src/main/java/org/openedx/core/presentation/global/app_upgrade/UpgradeRequiredFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/global/appupgrade/UpgradeRequiredFragment.kt @@ -1,4 +1,4 @@ -package org.openedx.core.presentation.global.app_upgrade +package org.openedx.core.presentation.global.appupgrade import android.os.Bundle import android.view.LayoutInflater @@ -8,8 +8,8 @@ import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import androidx.fragment.app.setFragmentResult -import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.AppUpdateState +import org.openedx.core.ui.theme.OpenEdXTheme class UpgradeRequiredFragment : Fragment() { @@ -39,4 +39,4 @@ class UpgradeRequiredFragment : Fragment() { const val REQUEST_KEY = "UpgradeRequiredFragmentRequestKey" const val OPEN_ACCOUNT_SETTINGS_KEY = "openAccountSettings" } -} \ 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..17c9e20b2 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() { @@ -34,7 +34,8 @@ class WebContentFragment : Fragment() { contentUrl = requireArguments().getString(ARG_URL, ""), onBackClick = { requireActivity().supportFragmentManager.popBackStack() - }) + } + ) } } } 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 95% 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..15f94d338 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.presentation.global.app_upgrade.TransparentTextButton +import org.openedx.core.R +import org.openedx.core.presentation.global.appupgrade.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..4df3017e3 --- /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 93% 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..b370cd56d 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() { @@ -81,10 +81,11 @@ class VideoQualityFragment : Fragment() { val windowSize = rememberWindowSize() val title = stringResource( - id = if (viewModel.getQualityType() == VideoQualityType.Streaming) + id = if (viewModel.getQualityType() == VideoQualityType.Streaming) { R.string.core_video_streaming_quality - else + } else { R.string.core_video_download_quality + } ) val videoQuality by viewModel.videoQuality.observeAsState(viewModel.getCurrentVideoQuality()) @@ -97,7 +98,8 @@ class VideoQualityFragment : Fragment() { }, onBackClick = { requireActivity().supportFragmentManager.popBackStack() - }) + } + ) } } } @@ -183,7 +185,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() @@ -260,7 +262,7 @@ private fun VideoQualityScreenPreview() { title = "", selectedVideoQuality = VideoQuality.OPTION_720P, onQualityChanged = {}, - onBackClick = {}) + onBackClick = {} + ) } } - 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 91% 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..95ecca130 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, @@ -29,9 +29,11 @@ class VideoQualityViewModel( } fun getCurrentVideoQuality(): VideoQuality { - return if (getQualityType() == VideoQualityType.Streaming) - preferencesManager.videoSettings.videoStreamingQuality else + return if (getQualityType() == VideoQualityType.Streaming) { + preferencesManager.videoSettings.videoStreamingQuality + } else { preferencesManager.videoSettings.videoDownloadQuality + } } fun setVideoQuality(quality: VideoQuality) { @@ -51,11 +53,11 @@ class VideoQualityViewModel( fun getQualityType() = VideoQualityType.valueOf(qualityType) private fun logVideoQualityChangedEvent(oldQuality: VideoQuality, newQuality: VideoQuality) { - val event = - if (getQualityType() == VideoQualityType.Streaming) + val event = if (getQualityType() == VideoQualityType.Streaming) { CoreAnalyticsEvent.VIDEO_STREAMING_QUALITY_CHANGED - else + } else { CoreAnalyticsEvent.VIDEO_DOWNLOAD_QUALITY_CHANGED + } analytics.logEvent( event.eventName, 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/EdxError.kt b/core/src/main/java/org/openedx/core/system/EdxError.kt index f9ea93d56..bdc8692bd 100644 --- a/core/src/main/java/org/openedx/core/system/EdxError.kt +++ b/core/src/main/java/org/openedx/core/system/EdxError.kt @@ -7,4 +7,4 @@ sealed class EdxError : IOException() { class UserNotActiveException : EdxError() class ValidationException(val error: String) : EdxError() data class UnknownException(val error: String) : EdxError() -} \ No newline at end of file +} 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/connection/NetworkConnection.kt b/core/src/main/java/org/openedx/core/system/connection/NetworkConnection.kt index 570ce2c71..12ec7a815 100644 --- a/core/src/main/java/org/openedx/core/system/connection/NetworkConnection.kt +++ b/core/src/main/java/org/openedx/core/system/connection/NetworkConnection.kt @@ -9,20 +9,14 @@ class NetworkConnection( ) { fun isOnline(): Boolean { - val connectivityManager = - context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - val capabilities = - connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork) - if (capabilities != null) { - if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) { - return true - } else if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) { - return true - } else if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) { - return true - } - } - return false + val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val capabilities = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork) + + return capabilities != null && ( + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) || + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) + ) } fun isWifiConnected(): Boolean { @@ -37,5 +31,4 @@ class NetworkConnection( } return false } - -} \ No newline at end of file +} diff --git a/core/src/main/java/org/openedx/core/system/notifier/AppUpgradeEvent.kt b/core/src/main/java/org/openedx/core/system/notifier/AppUpgradeEvent.kt deleted file mode 100644 index f99086a11..000000000 --- a/core/src/main/java/org/openedx/core/system/notifier/AppUpgradeEvent.kt +++ /dev/null @@ -1,6 +0,0 @@ -package org.openedx.core.system.notifier - -sealed class AppUpgradeEvent { - object UpgradeRequiredEvent : AppUpgradeEvent() - class UpgradeRecommendedEvent(val newVersionName: String) : AppUpgradeEvent() -} diff --git a/core/src/main/java/org/openedx/core/system/notifier/AppUpgradeNotifier.kt b/core/src/main/java/org/openedx/core/system/notifier/AppUpgradeNotifier.kt deleted file mode 100644 index 0f5a274d5..000000000 --- a/core/src/main/java/org/openedx/core/system/notifier/AppUpgradeNotifier.kt +++ /dev/null @@ -1,15 +0,0 @@ -package org.openedx.core.system.notifier - -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.asSharedFlow - -class AppUpgradeNotifier { - - private val channel = MutableSharedFlow(replay = 0, extraBufferCapacity = 0) - - val notifier: Flow = channel.asSharedFlow() - - suspend fun send(event: AppUpgradeEvent) = channel.emit(event) - -} \ No newline at end of file diff --git a/core/src/main/java/org/openedx/core/system/notifier/CourseCompletionSet.kt b/core/src/main/java/org/openedx/core/system/notifier/CourseCompletionSet.kt index ae2450a9c..038da1bd7 100644 --- a/core/src/main/java/org/openedx/core/system/notifier/CourseCompletionSet.kt +++ b/core/src/main/java/org/openedx/core/system/notifier/CourseCompletionSet.kt @@ -1,3 +1,3 @@ package org.openedx.core.system.notifier -class CourseCompletionSet : CourseEvent \ No newline at end of file +class CourseCompletionSet : CourseEvent 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/CourseEvent.kt b/core/src/main/java/org/openedx/core/system/notifier/CourseEvent.kt index a79fe7e70..a45dd971c 100644 --- a/core/src/main/java/org/openedx/core/system/notifier/CourseEvent.kt +++ b/core/src/main/java/org/openedx/core/system/notifier/CourseEvent.kt @@ -1,3 +1,3 @@ package org.openedx.core.system.notifier -interface CourseEvent \ No newline at end of file +interface 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..6272ded50 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 @@ -12,12 +12,16 @@ class CourseNotifier { suspend fun send(event: CourseVideoPositionChanged) = channel.emit(event) suspend fun send(event: CourseStructureUpdated) = channel.emit(event) + suspend fun send(event: CourseStructureGot) = channel.emit(event) suspend fun send(event: CourseSubtitleLanguageChanged) = channel.emit(event) suspend fun send(event: CourseSectionChanged) = channel.emit(event) suspend fun send(event: CourseCompletionSet) = channel.emit(event) suspend fun send(event: CalendarSyncEvent) = channel.emit(event) suspend fun send(event: CourseDatesShifted) = channel.emit(event) suspend fun send(event: CourseLoading) = channel.emit(event) - suspend fun send(event: CourseDataReady) = channel.emit(event) - suspend fun send(event: CourseRefresh) = channel.emit(event) + suspend fun send(event: CourseOpenBlock) = channel.emit(event) + suspend fun send(event: RefreshDates) = channel.emit(event) + suspend fun send(event: RefreshDiscussions) = channel.emit(event) + suspend fun send(event: RefreshProgress) = channel.emit(event) + suspend fun send(event: CourseProgressLoaded) = 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/CourseProgressLoaded.kt b/core/src/main/java/org/openedx/core/system/notifier/CourseProgressLoaded.kt new file mode 100644 index 000000000..482d9271e --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/CourseProgressLoaded.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier + +object CourseProgressLoaded : 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/CourseStructureGot.kt b/core/src/main/java/org/openedx/core/system/notifier/CourseStructureGot.kt new file mode 100644 index 000000000..d685519e3 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/CourseStructureGot.kt @@ -0,0 +1,5 @@ +package org.openedx.core.system.notifier + +class CourseStructureGot( + val courseId: String +) : CourseEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/CourseStructureUpdated.kt b/core/src/main/java/org/openedx/core/system/notifier/CourseStructureUpdated.kt index 0587f5eb4..c63cbdf94 100644 --- a/core/src/main/java/org/openedx/core/system/notifier/CourseStructureUpdated.kt +++ b/core/src/main/java/org/openedx/core/system/notifier/CourseStructureUpdated.kt @@ -2,4 +2,4 @@ package org.openedx.core.system.notifier class CourseStructureUpdated( val courseId: String -) : CourseEvent \ No newline at end of file +) : CourseEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/CourseVideoPositionChanged.kt b/core/src/main/java/org/openedx/core/system/notifier/CourseVideoPositionChanged.kt index af7a0583e..a289abe91 100644 --- a/core/src/main/java/org/openedx/core/system/notifier/CourseVideoPositionChanged.kt +++ b/core/src/main/java/org/openedx/core/system/notifier/CourseVideoPositionChanged.kt @@ -3,5 +3,6 @@ package org.openedx.core.system.notifier data class CourseVideoPositionChanged( val videoUrl: String, val videoTime: Long, + val duration: Long, val isPlaying: Boolean -) : CourseEvent \ No newline at end of file +) : 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..4ee889b0c 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,5 @@ 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/DownloadProgressChanged.kt b/core/src/main/java/org/openedx/core/system/notifier/DownloadProgressChanged.kt index 474b25f2f..1e4d4a331 100644 --- a/core/src/main/java/org/openedx/core/system/notifier/DownloadProgressChanged.kt +++ b/core/src/main/java/org/openedx/core/system/notifier/DownloadProgressChanged.kt @@ -1,5 +1,7 @@ package org.openedx.core.system.notifier data class DownloadProgressChanged( - val id: String, val value: Long, val size: Long + val id: String, + val value: Long, + val size: Long ) : DownloadEvent 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/RefreshProgress.kt b/core/src/main/java/org/openedx/core/system/notifier/RefreshProgress.kt new file mode 100644 index 000000000..c0835f787 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/RefreshProgress.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier + +object RefreshProgress : 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..d453abfb3 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,9 @@ 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/app/AppUpgradeEvent.kt b/core/src/main/java/org/openedx/core/system/notifier/app/AppUpgradeEvent.kt new file mode 100644 index 000000000..89451c744 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/app/AppUpgradeEvent.kt @@ -0,0 +1,6 @@ +package org.openedx.core.system.notifier.app + +sealed class AppUpgradeEvent : AppEvent { + data 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..eed214567 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt @@ -1,11 +1,11 @@ package org.openedx.core.ui -import android.os.Build.VERSION.SDK_INT import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -17,9 +17,9 @@ 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.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn @@ -28,14 +28,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 @@ -46,8 +50,11 @@ import androidx.compose.material.ScaffoldState import androidx.compose.material.Text import androidx.compose.material.TextFieldDefaults import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack 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,7 +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.layout.ContentScale +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController @@ -96,21 +102,19 @@ 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 coil.ImageLoader -import coil.compose.AsyncImage -import coil.decode.GifDecoder -import coil.decode.ImageDecoderDecoder +import androidx.compose.ui.zIndex 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( @@ -121,19 +125,21 @@ fun StaticSearchBar( Row( modifier = modifier .testTag("tf_search") - .then(Modifier - .background( - MaterialTheme.appColors.textFieldBackground, - MaterialTheme.appShapes.textFieldShape - ) - .clip(MaterialTheme.appShapes.textFieldShape) - .border( - 1.dp, - MaterialTheme.appColors.textFieldBorder, - MaterialTheme.appShapes.textFieldShape - ) - .clickable { onClick() } - .padding(horizontal = 20.dp)), + .then( + Modifier + .background( + MaterialTheme.appColors.textFieldBackground, + MaterialTheme.appShapes.textFieldShape + ) + .clip(MaterialTheme.appShapes.textFieldShape) + .border( + 1.dp, + MaterialTheme.appColors.textFieldBorder, + MaterialTheme.appShapes.textFieldShape + ) + .clickable { onClick() } + .padding(horizontal = 20.dp) + ), verticalAlignment = Alignment.CenterVertically ) { Icon( @@ -199,8 +205,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 +214,40 @@ fun Toolbar( } } -@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun MainToolbar( + 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 = R.string.core_accessibility_settings) + ) + } + } +} + @Composable fun SearchBar( modifier: Modifier, @@ -255,7 +294,11 @@ fun SearchBar( }, colors = TextFieldDefaults.outlinedTextFieldColors( textColor = MaterialTheme.appColors.textPrimary, - backgroundColor = if (isFocused) MaterialTheme.appColors.background else MaterialTheme.appColors.textFieldBackground, + backgroundColor = if (isFocused) { + MaterialTheme.appColors.background + } else { + MaterialTheme.appColors.textFieldBackground + }, focusedBorderColor = MaterialTheme.appColors.primary, unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, cursorColor = MaterialTheme.appColors.primary, @@ -308,7 +351,6 @@ fun SearchBar( ) } -@OptIn(ExperimentalComposeUiApi::class) @Composable fun SearchBarStateless( modifier: Modifier, @@ -347,7 +389,11 @@ fun SearchBarStateless( }, colors = TextFieldDefaults.outlinedTextFieldColors( textColor = MaterialTheme.appColors.textPrimary, - backgroundColor = if (isFocused) MaterialTheme.appColors.background else MaterialTheme.appColors.textFieldBackground, + backgroundColor = if (isFocused) { + MaterialTheme.appColors.background + } else { + MaterialTheme.appColors.textFieldBackground + }, focusedBorderColor = MaterialTheme.appColors.primary, unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, cursorColor = MaterialTheme.appColors.primary, @@ -434,7 +480,7 @@ fun HyperlinkText( append(fullText) addStyle( style = SpanStyle( - color = MaterialTheme.appColors.textPrimary, + color = MaterialTheme.appColors.textPrimaryLight, fontSize = fontSize ), start = 0, @@ -442,7 +488,6 @@ fun HyperlinkText( ) for ((key, value) in hyperLinks) { - val startIndex = fullText.indexOf(key) val endIndex = startIndex + key.length addStyle( @@ -450,7 +495,7 @@ fun HyperlinkText( color = linkTextColor, fontSize = fontSize, fontWeight = linkTextFontWeight, - textDecoration = linkTextDecoration + textDecoration = linkTextDecoration, ), start = startIndex, end = endIndex @@ -473,143 +518,20 @@ 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) - } - } - ) -} - -@Composable -fun HyperlinkImageText( - modifier: Modifier = Modifier, - title: String = "", - imageText: LinkedImageText, - textStyle: TextStyle = TextStyle.Default, - linkTextColor: Color = MaterialTheme.appColors.primary, - linkTextFontWeight: FontWeight = FontWeight.Normal, - linkTextDecoration: TextDecoration = TextDecoration.None, - fontSize: TextUnit = TextUnit.Unspecified, -) { - val fullText = imageText.text - val hyperLinks = imageText.links - val annotatedString = buildAnnotatedString { - if (title.isNotEmpty()) { - append(title) - append("\n\n") - } - append(fullText) - addStyle( - style = SpanStyle( - color = MaterialTheme.appColors.textPrimary, - fontSize = fontSize - ), - start = 0, - end = this.length - ) - - for ((key, value) in hyperLinks) { - val startIndex = this.toString().indexOf(key) - if (startIndex == -1) continue - val endIndex = startIndex + key.length - addStyle( - style = SpanStyle( - color = linkTextColor, - fontSize = fontSize, - fontWeight = linkTextFontWeight, - textDecoration = linkTextDecoration - ), - start = startIndex, - end = endIndex - ) - addStringAnnotation( - tag = "URL", - annotation = value, - start = startIndex, - end = endIndex - ) - } - if (title.isNotEmpty()) { - addStyle( - style = SpanStyle( - color = MaterialTheme.appColors.textPrimary, - fontSize = MaterialTheme.appTypography.titleLarge.fontSize, - fontWeight = MaterialTheme.appTypography.titleLarge.fontWeight - ), - start = 0, - end = title.length - ) - } - for (item in imageText.headers) { - val startIndex = this.toString().indexOf(item) - if (startIndex == -1) continue - val endIndex = startIndex + item.length - addStyle( - style = SpanStyle( - color = MaterialTheme.appColors.textPrimary, - fontSize = MaterialTheme.appTypography.titleLarge.fontSize, - fontWeight = MaterialTheme.appTypography.titleLarge.fontWeight - ), - start = startIndex, - end = endIndex - ) - } - addStyle( - style = SpanStyle( - fontSize = fontSize - ), - start = 0, - end = this.length - ) - } - - val uriHandler = LocalUriHandler.current - val context = LocalContext.current - val imageLoader = ImageLoader.Builder(context) - .components { - if (SDK_INT >= 28) { - add(ImageDecoderDecoder.Factory()) - } else { - add(GifDecoder.Factory()) - } - } - .build() - - Column(Modifier.fillMaxWidth()) { - ClickableText( - modifier = modifier, - text = annotatedString, - style = textStyle, - onClick = { - annotatedString - .getStringAnnotations("URL", it, it) + modifier = modifier.pointerInput(Unit) { + detectTapGestures { offset -> + val position = offset.x.toInt() + annotatedString.getStringAnnotations("URL", position, position) .firstOrNull()?.let { stringAnnotation -> - uriHandler.openUri(stringAnnotation.item) + action?.invoke(stringAnnotation.item) + ?: uriHandler.openUri(stringAnnotation.item) } } - ) - imageText.imageLinks.values.forEach { - Spacer(Modifier.height(8.dp)) - AsyncImage( - modifier = Modifier - .fillMaxWidth() - .heightIn(0.dp, 360.dp), - contentScale = ContentScale.Fit, - model = it, - contentDescription = null, - imageLoader = imageLoader - ) - } - Spacer(Modifier.height(16.dp)) - } + }, + style = textStyle + ) } @Composable @@ -635,7 +557,8 @@ fun SheetContent( .padding(10.dp), textAlign = TextAlign.Center, style = MaterialTheme.appTypography.titleMedium, - text = title + text = title, + color = MaterialTheme.appColors.onBackground ) SearchBarStateless( modifier = Modifier @@ -649,15 +572,21 @@ fun SheetContent( }, onValueChanged = { textField -> searchValueChanged(textField) - }, onClearValue = { + }, + onClearValue = { searchValueChanged("") } ) Spacer(Modifier.height(10.dp)) - LazyColumn(Modifier.fillMaxSize(), listState) { - items(expandedList.filter { - it.name.startsWith(searchValue.text, true) - }) { item -> + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState + ) { + items( + expandedList.filter { + it.name.startsWith(searchValue.text, true) + } + ) { item -> Text( modifier = Modifier .testTag("txt_${item.value.tagId()}_title") @@ -667,6 +596,7 @@ fun SheetContent( onItemClick(item) } .padding(vertical = 12.dp), + color = MaterialTheme.appColors.onBackground, text = item.name, style = MaterialTheme.appTypography.bodyLarge, textAlign = TextAlign.Center @@ -711,15 +641,20 @@ fun SheetContent( }, onValueChanged = { textField -> searchValueChanged(textField) - }, onClearValue = { + }, + onClearValue = { searchValueChanged("") } ) Spacer(Modifier.height(10.dp)) - LazyColumn(Modifier.fillMaxWidth()) { - items(expandedList.filter { - it.first.startsWith(searchValue.text, true) - }) { item -> + LazyColumn( + Modifier.fillMaxWidth() + ) { + items( + expandedList.filter { + it.first.startsWith(searchValue.text, true) + } + ) { item -> Text( modifier = Modifier .fillMaxWidth() @@ -829,6 +764,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 +781,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 } @@ -879,7 +814,7 @@ fun IconText( Icon( modifier = Modifier .testTag("ic_${text.tagId()}") - .size((textStyle.fontSize.value + 4).dp), + .size(size = (textStyle.fontSize.value + 4).dp), imageVector = icon, contentDescription = null, tint = color @@ -917,7 +852,7 @@ fun IconText( Icon( modifier = Modifier .testTag("ic_${text.tagId()}") - .size((textStyle.fontSize.value + 4).dp), + .size(size = (textStyle.fontSize.value + 4).dp), painter = painter, contentDescription = null, tint = color @@ -933,25 +868,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(size = (textStyle.fontSize.value + 4).dp), imageVector = icon, contentDescription = null, tint = color @@ -961,11 +898,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) { @@ -981,7 +918,7 @@ fun TextIcon( Text(text = text, color = color, style = textStyle) Icon( modifier = iconModifier - .size((textStyle.fontSize.value + 4).dp), + .size(size = (textStyle.fontSize.value + 4).dp), painter = painter, contentDescription = null, tint = color @@ -1018,7 +955,8 @@ fun OfflineModeDialog( modifier = Modifier.size(20.dp), onClick = { onReloadClick() - }) { + } + ) { Icon( modifier = Modifier.size(20.dp), painter = painterResource(R.drawable.core_ic_reload), @@ -1030,7 +968,8 @@ fun OfflineModeDialog( modifier = Modifier.size(20.dp), onClick = { onDismissCLick() - }) { + } + ) { Icon( modifier = Modifier.size(20.dp), imageVector = Icons.Filled.Close, @@ -1045,18 +984,20 @@ fun OfflineModeDialog( @Composable fun OpenEdXButton( - modifier: Modifier = Modifier.fillMaxWidth(), + modifier: Modifier = Modifier + .fillMaxWidth() + .height(42.dp), 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 .testTag("btn_${text.tagId()}") - .then(modifier) - .height(42.dp), + .then(modifier), shape = MaterialTheme.appShapes.buttonShape, colors = ButtonDefaults.buttonColors( backgroundColor = backgroundColor @@ -1068,7 +1009,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 +1025,7 @@ fun OpenEdXOutlinedButton( borderColor: Color, textColor: Color, text: String = "", + enabled: Boolean = true, onClick: () -> Unit, content: (@Composable RowScope.() -> Unit)? = null, ) { @@ -1093,6 +1035,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) @@ -1116,10 +1059,14 @@ fun BackBtn( tint: Color = MaterialTheme.appColors.primary, onBackClick: () -> Unit, ) { - IconButton(modifier = modifier.testTag("ib_back"), - onClick = { onBackClick() }) { + IconButton( + modifier = modifier.testTag("ib_back"), + onClick = { + onBackClick() + } + ) { Icon( - painter = painterResource(id = R.drawable.core_ic_back), + imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(id = R.string.core_accessibility_btn_back), tint = tint ) @@ -1127,33 +1074,41 @@ 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), + modifier = Modifier.fillMaxWidth(fraction = 0.8f), + text = stringResource(id = errorType.titleResId), color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.titleLarge, textAlign = TextAlign.Center ) Spacer(Modifier.height(16.dp)) Text( - modifier = Modifier.fillMaxWidth(0.8f), - text = stringResource(id = R.string.core_no_internet_connection_description), + modifier = Modifier.fillMaxWidth(fraction = 0.8f), + text = stringResource(id = errorType.descriptionResId), color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.bodyLarge, textAlign = TextAlign.Center @@ -1162,8 +1117,49 @@ 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.secondaryButtonText, + 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 + .sizeIn( + maxWidth = 80.dp, + maxHeight = 80.dp + ), + painter = icon, + contentDescription = null, + tint = MaterialTheme.appColors.progressBarBackgroundColor, + ) + Spacer(Modifier.height(24.dp)) + Text( + modifier = Modifier.fillMaxWidth(fraction = 0.8f), + text = message, + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.bodyMedium, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center ) } } @@ -1172,27 +1168,39 @@ 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() } - ) - OpenEdXOutlinedButton( modifier = Modifier .testTag("btn_sign_in") - .width(100.dp) - .padding(start = 16.dp), + .then( + if (showRegisterButton) { + Modifier + .width(100.dp) + .padding(end = 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, ) + 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() } + ) + } } } @@ -1202,29 +1210,44 @@ 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 - val backgroundColor = - if (isSelected) MaterialTheme.appColors.primary else MaterialTheme.appColors.tabUnselectedBtnBackground - val contentColor = - if (isSelected) MaterialTheme.appColors.tabSelectedBtnContent else MaterialTheme.appColors.tabUnselectedBtnContent - val border = if (!isSystemInDarkTheme()) Modifier.border( - 1.dp, - MaterialTheme.appColors.primary, - CircleShape - ) else Modifier + val backgroundColor = if (isSelected) { + MaterialTheme.appColors.primary + } else { + MaterialTheme.appColors.tabUnselectedBtnBackground + } + val contentColor = if (isSelected) { + MaterialTheme.appColors.tabSelectedBtnContent + } else { + MaterialTheme.appColors.tabUnselectedBtnContent + } + val border = if (!isSystemInDarkTheme()) { + Modifier.border( + 1.dp, + MaterialTheme.appColors.primary, + CircleShape + ) + } else { + Modifier + } RoundTab( modifier = Modifier @@ -1234,11 +1257,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 +1270,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 +1294,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 @@ -1270,6 +1310,23 @@ private fun RoundTab( } } +@Composable +fun OpenEdXDropdownMenuItem( + modifier: Modifier = Modifier, + text: String, + onClick: () -> Unit +) { + Text( + modifier = modifier + .fillMaxWidth() + .clickable { onClick() } + .padding(16.dp), + text = text, + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textDark, + ) +} + @Preview @Composable private fun StaticSearchBarPreview() { @@ -1310,7 +1367,7 @@ private fun ToolbarPreview() { @Preview @Composable private fun AuthButtonsPanelPreview() { - AuthButtonsPanel(onRegisterClick = {}, onSignInClick = {}) + AuthButtonsPanel(onRegisterClick = {}, onSignInClick = {}, showRegisterButton = true) } @Preview @@ -1341,11 +1398,7 @@ private fun IconTextPreview() { @Composable private fun ConnectionErrorViewPreview() { OpenEdXTheme(darkTheme = true) { - ConnectionErrorView( - modifier = Modifier - .fillMaxSize(), - onReloadClick = {} - ) + ConnectionErrorView(onReloadClick = {}) } } @@ -1363,7 +1416,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..1351662eb 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeExtensions.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeExtensions.kt @@ -1,17 +1,16 @@ package org.openedx.core.ui import android.content.res.Configuration -import android.graphics.Rect -import android.view.ViewTreeObserver -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.MutatePriority import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.ime 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 +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.Stable @@ -30,19 +29,19 @@ 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 import androidx.compose.ui.platform.LocalInspectionMode -import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.Dp -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.awaitCancellation -import kotlinx.coroutines.launch +import androidx.compose.ui.unit.dp import org.openedx.core.R import org.openedx.core.presentation.global.InsetHolder +const val KEYBOARD_VISIBILITY_THRESHOLD = 0.15f + inline val isPreview: Boolean @ReadOnlyComposable @Composable @@ -75,6 +74,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 @@ -94,7 +103,8 @@ fun Modifier.displayCutoutForLandscape(): Modifier = composed { inline fun Modifier.noRippleClickable(crossinline onClick: () -> Unit): Modifier = composed { this then Modifier.clickable( indication = null, - interactionSource = remember { MutableInteractionSource() }) { + interactionSource = remember { MutableInteractionSource() } + ) { onClick() } } @@ -145,42 +155,18 @@ fun rememberSaveableMap(init: () -> MutableMap): MutableMa } @Composable -fun isImeVisibleState(): State { - val keyboardState = remember { mutableStateOf(false) } - val view = LocalView.current - DisposableEffect(view) { - val onGlobalListener = ViewTreeObserver.OnGlobalLayoutListener { - val rect = Rect() - view.getWindowVisibleDisplayFrame(rect) - val screenHeight = view.rootView.height - val keypadHeight = screenHeight - rect.bottom - keyboardState.value = keypadHeight > screenHeight * 0.15 - } - view.viewTreeObserver.addOnGlobalLayoutListener(onGlobalListener) - - onDispose { - view.viewTreeObserver.removeOnGlobalLayoutListener(onGlobalListener) - } - } - - return keyboardState -} +fun isImeVisibleState(threshold: Int = 0): State { + val imeInsets = WindowInsets.ime + val imeBottom = imeInsets.getBottom(LocalDensity.current) + val isOpen = remember(imeBottom) { mutableStateOf(false) } -fun LazyListState.disableScrolling(scope: CoroutineScope) { - scope.launch { - scroll(scrollPriority = MutatePriority.PreventUserInput) { - awaitCancellation() - } + LaunchedEffect(imeBottom) { + isOpen.value = imeBottom > threshold } -} -fun LazyListState.reEnableScrolling(scope: CoroutineScope) { - scope.launch { - scroll(scrollPriority = MutatePriority.PreventUserInput) {} - } + return isOpen } -@OptIn(ExperimentalFoundationApi::class) fun PagerState.calculateCurrentOffsetForPage(page: Int): Float { return (currentPage - page) + currentPageOffsetFraction } @@ -192,4 +178,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/HTMLRenderer.kt b/core/src/main/java/org/openedx/core/ui/HTMLRenderer.kt new file mode 100644 index 000000000..0105e2cff --- /dev/null +++ b/core/src/main/java/org/openedx/core/ui/HTMLRenderer.kt @@ -0,0 +1,295 @@ +package org.openedx.core.ui + +import android.content.ActivityNotFoundException +import android.content.Intent +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +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.Row +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.width +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.times +import androidx.core.net.toUri +import coil.compose.AsyncImage +import coil.request.ImageRequest +import org.jsoup.Jsoup +import org.jsoup.nodes.Element +import org.jsoup.nodes.Node +import org.jsoup.nodes.TextNode +import org.openedx.core.R +import org.openedx.core.ui.theme.appColors + +@Composable +fun RenderHtmlContent(html: String) { + val document = remember(html) { Jsoup.parse(html) } + val bodyElements = document.body().children() + Column { + bodyElements.forEach { element -> + RenderBlockElement(element) + } + } +} + +@Composable +private fun RenderClickableText(annotated: AnnotatedString) { + val context = LocalContext.current + val hasLink = annotated.getStringAnnotations("URL", 0, annotated.length).isNotEmpty() + var textLayoutResult by remember { mutableStateOf(null) } + val modifier = if (hasLink) { + Modifier.pointerInput(annotated) { + detectTapGestures { offset -> + textLayoutResult?.let { layoutResult -> + val position = layoutResult.getOffsetForPosition(offset) + annotated.getStringAnnotations("URL", position, position) + .firstOrNull()?.let { annotation -> + try { + val intent = Intent(Intent.ACTION_VIEW, annotation.item.toUri()) + context.startActivity(intent) + } catch (e: ActivityNotFoundException) { + e.printStackTrace() + } + } + } + } + } + } else { + Modifier + } + Text( + text = annotated, + modifier = modifier, + color = MaterialTheme.appColors.textPrimary, + onTextLayout = { textLayoutResult = it } + ) +} + +@Composable +private fun RenderParagraph(element: Element) { + val segments = extractSegmentsFromNodes(element.childNodes()) + Column(modifier = Modifier.padding(vertical = 4.dp)) { + segments.forEach { segment -> + when (segment) { + is List<*> -> { + val nodes = segment.filterIsInstance() + val annotated = buildAnnotatedStringFromNodes(nodes) + RenderClickableText(annotated) + } + + is Element -> { + RenderBlockElement(segment) + } + } + } + } +} + +private fun extractSegmentsFromNodes(nodes: List): List { + val segments = mutableListOf() + val currentSegment = mutableListOf() + + for (node in nodes) { + if (node is Element) { + val tagName = node.tagName() + if (tagName == "img" || tagName == "ul" || tagName == "ol" || tagName == "blockquote") { + flush(currentSegment, segments) + segments.add(node) + } else if (node.select("img").isNotEmpty()) { + flush(currentSegment, segments) + segments.addAll(extractSegmentsFromNodes(node.childNodes())) + } else { + currentSegment.add(node) + } + } else { + currentSegment.add(node) + } + } + flush(currentSegment, segments) + return segments +} + +@Composable +private fun RenderBlockElement(element: Element, indent: Int = 0) { + when (element.tagName()) { + "p" -> { + RenderParagraph(element) + } + + "ul" -> { + Column(modifier = Modifier.padding(start = (indent + 1) * 16.dp)) { + element.children().forEach { child -> + if (child.tagName() == "li") { + Row( + modifier = Modifier.padding(vertical = 2.dp), + ) { + Text( + modifier = Modifier.padding(top = 4.dp), + text = AnnotatedString("• "), + style = TextStyle(fontWeight = FontWeight.Bold), + color = MaterialTheme.appColors.textPrimary + ) + RenderBlockElement(child, indent + 1) + } + } + } + } + } + + "ol" -> { + Column(modifier = Modifier.padding(start = (indent + 1) * 16.dp)) { + element.children().forEachIndexed { index, child -> + if (child.tagName() == "li") { + Row( + modifier = Modifier.padding(vertical = 2.dp), + ) { + Text( + modifier = Modifier.padding(top = 4.dp), + text = AnnotatedString("${index + 1}. "), + color = MaterialTheme.appColors.textPrimary + ) + RenderBlockElement(child, indent + 1) + } + } + } + } + } + + "li" -> { + RenderParagraph(element) + } + + "blockquote" -> { + Row( + modifier = Modifier.height(IntrinsicSize.Min), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Box( + modifier = Modifier + .width(2.dp) + .fillMaxHeight() + .background(MaterialTheme.appColors.cardViewBorder) + ) + Column { + element.children().forEach { child -> + RenderBlockElement(child) + } + } + } + } + + "img" -> { + val src = element.attr("src") + AsyncImage( + modifier = Modifier.fillMaxWidth(), + model = ImageRequest.Builder(LocalContext.current) + .data(src) + .error(R.drawable.core_no_image_course) + .placeholder(R.drawable.core_no_image_course) + .build(), + contentDescription = null, + contentScale = ContentScale.FillWidth + ) + } + + else -> { + RenderParagraph(element) + } + } +} + +@Composable +private fun AnnotatedString.Builder.AppendNodes(nodes: List) { + nodes.forEach { node -> + when (node) { + is TextNode -> append(node.text()) + is Element -> AppendElement(node) + } + } +} + +@Composable +private fun AnnotatedString.Builder.AppendElement(element: Element) { + when (element.tagName()) { + "br" -> append("\n") + "strong" -> withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + AppendNodes(element.childNodes()) + } + + "em" -> withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { + AppendNodes(element.childNodes()) + } + + "code" -> withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) { + AppendNodes(element.childNodes()) + } + + "span" -> { + val styleAttr = element.attr("style") + if (styleAttr.contains("text-decoration: underline", ignoreCase = true)) { + withStyle(SpanStyle(textDecoration = TextDecoration.Underline)) { + AppendNodes(element.childNodes()) + } + } else { + AppendNodes(element.childNodes()) + } + } + + "a" -> { + val href = element.attr("href") + val start = this.length + AppendNodes(element.childNodes()) + val end = this.length + addStyle( + SpanStyle( + color = MaterialTheme.appColors.primary, + textDecoration = TextDecoration.Underline + ), + start, + end + ) + addStringAnnotation(tag = "URL", annotation = href, start = start, end = end) + } + + else -> AppendNodes(element.childNodes()) + } +} + +@Composable +private fun buildAnnotatedStringFromNodes(nodes: List): AnnotatedString { + return AnnotatedString.Builder().apply { + AppendNodes(nodes) + }.toAnnotatedString() +} + +private fun flush(currentSegment: MutableList, segments: MutableList) { + if (currentSegment.isNotEmpty()) { + segments.add(currentSegment.toList()) + currentSegment.clear() + } +} diff --git a/core/src/main/java/org/openedx/core/ui/PageIndicator.kt b/core/src/main/java/org/openedx/core/ui/PageIndicator.kt new file mode 100644 index 000000000..8e9f4f40b --- /dev/null +++ b/core/src/main/java/org/openedx/core/ui/PageIndicator.kt @@ -0,0 +1,123 @@ +package org.openedx.core.ui + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors + +@Composable +fun PageIndicator( + numberOfPages: Int, + modifier: Modifier = Modifier, + selectedPage: Int = 0, + selectedColor: Color = MaterialTheme.appColors.info, + previousUnselectedColor: Color = MaterialTheme.appColors.cardViewBorder, + nextUnselectedColor: Color = MaterialTheme.appColors.textFieldBorder, + defaultRadius: Dp = 20.dp, + selectedLength: Dp = 60.dp, + space: Dp = 30.dp, + animationDurationInMillis: Int = 300, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(space), + modifier = modifier, + ) { + for (i in 0 until numberOfPages) { + val isSelected = i == selectedPage + val unselectedColor = + if (i < selectedPage) previousUnselectedColor else nextUnselectedColor + PageIndicatorView( + isSelected = isSelected, + selectedColor = selectedColor, + defaultColor = unselectedColor, + defaultRadius = defaultRadius, + selectedLength = selectedLength, + animationDurationInMillis = animationDurationInMillis, + ) + } + } +} + +@Composable +fun PageIndicatorView( + isSelected: Boolean, + selectedColor: Color, + defaultColor: Color, + defaultRadius: Dp, + selectedLength: Dp, + animationDurationInMillis: Int, + modifier: Modifier = Modifier, +) { + val color: Color by animateColorAsState( + targetValue = if (isSelected) { + selectedColor + } else { + defaultColor + }, + animationSpec = tween( + durationMillis = animationDurationInMillis, + ), + label = "" + ) + val width: Dp by animateDpAsState( + targetValue = if (isSelected) { + selectedLength + } else { + defaultRadius + }, + animationSpec = tween( + durationMillis = animationDurationInMillis, + ), + label = "" + ) + + Canvas( + modifier = modifier + .size( + width = width, + height = defaultRadius, + ), + ) { + drawRoundRect( + color = color, + topLeft = Offset.Zero, + size = Size( + width = width.toPx(), + height = defaultRadius.toPx(), + ), + cornerRadius = CornerRadius( + x = defaultRadius.toPx(), + y = defaultRadius.toPx(), + ), + ) + } +} + +@Preview +@Composable +private fun PageIndicatorViewPreview() { + OpenEdXTheme { + PageIndicator( + numberOfPages = 4, + selectedPage = 2 + ) + } +} 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..70f320368 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) @@ -64,7 +65,6 @@ fun WebContentScreen( scaffoldState = scaffoldState, backgroundColor = MaterialTheme.appColors.background ) { - val screenWidth by remember(key1 = windowSize) { mutableStateOf( windowSize.windowSizeValue( @@ -100,15 +100,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( @@ -121,7 +113,8 @@ fun WebContentScreen( contentUrl = contentUrl, onWebPageLoaded = { webViewAlpha = 1f - }) + } + ) } } } @@ -154,10 +147,7 @@ private fun WebViewContent( request: WebResourceRequest? ): Boolean { val clickUrl = request?.url?.toString() ?: "" - return if (clickUrl.isNotEmpty() && - (clickUrl.startsWith("http://") || - clickUrl.startsWith("https://")) - ) { + return if (clickUrl.isNotEmpty() && clickUrl.startsWith("http")) { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(clickUrl))) true } else if (clickUrl.startsWith("mailto:")) { @@ -195,6 +185,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..bf20366d9 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 infoVariant: 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,13 @@ data class AppColors( val courseHomeHeaderShade: Color, val courseHomeBackBtnBackground: Color, - val settingsTitleContent: Color + val settingsTitleContent: Color, + + val progressBarColor: Color, + val progressBarBackgroundColor: Color, + val gradeProgressBarBorder: Color, + val gradeProgressBarBackground: Color, + val assignmentCardBorder: 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/Shape.kt b/core/src/main/java/org/openedx/core/ui/theme/AppShapes.kt similarity index 89% rename from core/src/main/java/org/openedx/core/ui/theme/Shape.kt rename to core/src/main/java/org/openedx/core/ui/theme/AppShapes.kt index eed4d481d..1a45681f9 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/Shape.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/AppShapes.kt @@ -13,9 +13,11 @@ data class AppShapes( val textFieldShape: CornerBasedShape, val screenBackgroundShape: CornerBasedShape, val cardShape: CornerBasedShape, + val sectionCardShape: CornerBasedShape, val screenBackgroundShapeFull: CornerBasedShape, val courseImageShape: CornerBasedShape, val dialogShape: CornerBasedShape, + val videoPreviewShape: CornerBasedShape, ) val MaterialTheme.appShapes: AppShapes diff --git a/core/src/main/java/org/openedx/core/ui/theme/Type.kt b/core/src/main/java/org/openedx/core/ui/theme/AppTypography.kt similarity index 95% rename from core/src/main/java/org/openedx/core/ui/theme/Type.kt rename to core/src/main/java/org/openedx/core/ui/theme/AppTypography.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/AppTypography.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/ui/theme/Theme.kt b/core/src/main/java/org/openedx/core/ui/theme/Theme.kt index 1ffa3c73d..9b42c90ac 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 @@ -1,7 +1,7 @@ package org.openedx.core.ui.theme import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.LocalOverscrollConfiguration +import androidx.compose.foundation.LocalOverscrollFactory import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material.MaterialTheme import androidx.compose.material.darkColors @@ -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, + infoVariant = 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,13 @@ 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, + gradeProgressBarBorder = dark_grade_progress_bar_color, + gradeProgressBarBackground = dark_grade_progress_bar_background, + assignmentCardBorder = dark_assignment_card_border, ) private val LightColorPalette = AppColors( @@ -98,10 +119,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 +132,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 +152,16 @@ private val LightColorPalette = AppColors( warning = light_warning, info = light_info, + infoVariant = 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 +169,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 +185,13 @@ 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, + gradeProgressBarBorder = light_grade_progress_bar_color, + gradeProgressBarBackground = light_grade_progress_bar_background, + assignmentCardBorder = light_assignment_card_border, ) val MaterialTheme.appColors: AppColors @@ -168,11 +210,11 @@ fun OpenEdXTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composabl MaterialTheme( colors = colors.material, - //typography = LocalTypography.current.material, + // typography = LocalTypography.current.material, shapes = LocalShapes.current.material, ) { CompositionLocalProvider( - LocalOverscrollConfiguration provides null, + LocalOverscrollFactory provides null, content = content ) } diff --git a/core/src/main/java/org/openedx/core/utils/EmailUtil.kt b/core/src/main/java/org/openedx/core/utils/EmailUtil.kt index c56b606ae..c163240ff 100644 --- a/core/src/main/java/org/openedx/core/utils/EmailUtil.kt +++ b/core/src/main/java/org/openedx/core/utils/EmailUtil.kt @@ -55,15 +55,16 @@ object EmailUtil { targetIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) it.startActivity(targetIntent) } - } catch (ex: ActivityNotFoundException) { - //There is no activity which can perform the intended share Intent + } catch (e: ActivityNotFoundException) { + // There is no activity which can perform the intended share Intent + e.printStackTrace() context?.let { Toast.makeText( - it, it.getString(R.string.core_email_client_not_present), + it, + it.getString(R.string.core_email_client_not_present), Toast.LENGTH_SHORT ).show() } } } - -} \ No newline at end of file +} diff --git a/core/src/main/java/org/openedx/core/utils/FileUtil.kt b/core/src/main/java/org/openedx/core/utils/FileUtil.kt index 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/IOUtils.kt b/core/src/main/java/org/openedx/core/utils/IOUtils.kt index 0405168d4..2c3ee5870 100644 --- a/core/src/main/java/org/openedx/core/utils/IOUtils.kt +++ b/core/src/main/java/org/openedx/core/utils/IOUtils.kt @@ -17,5 +17,4 @@ object IOUtils { fun copy(input: InputStream, out: OutputStream) { out.sink().buffer().writeAll(input.source()) } - } diff --git a/core/src/main/java/org/openedx/core/utils/LocaleUtils.kt b/core/src/main/java/org/openedx/core/utils/LocaleUtils.kt index dd2e4531c..2b22a00a5 100644 --- a/core/src/main/java/org/openedx/core/utils/LocaleUtils.kt +++ b/core/src/main/java/org/openedx/core/utils/LocaleUtils.kt @@ -3,10 +3,13 @@ package org.openedx.core.utils import org.openedx.core.AppDataConstants.USER_MAX_YEAR import org.openedx.core.AppDataConstants.defaultLocale import org.openedx.core.domain.model.RegistrationField -import java.util.* +import java.util.Calendar +import java.util.Locale object LocaleUtils { + private const val MIN_USER_AGE = 13 + fun getBirthYearsRange(): List { val currentYear = Calendar.getInstance().get(Calendar.YEAR) return (currentYear - USER_MAX_YEAR..currentYear - 0).reversed().map { @@ -17,7 +20,7 @@ object LocaleUtils { fun isProfileLimited(inputYear: String?): Boolean { val currentYear = Calendar.getInstance().get(Calendar.YEAR) return if (!inputYear.isNullOrEmpty()) { - currentYear - inputYear.toInt() < 13 + currentYear - inputYear.toInt() < MIN_USER_AGE } else { true } @@ -34,37 +37,43 @@ object LocaleUtils { fun getCountryByCountryCode(code: String): String? { val countryISO = Locale.getISOCountries().firstOrNull { it == code } return countryISO?.let { - Locale("", it).getDisplayCountry(defaultLocale) + Locale.Builder().setRegion(it).build().getDisplayCountry(defaultLocale) } } fun getLanguageByLanguageCode(code: String): String? { val countryISO = Locale.getISOLanguages().firstOrNull { it == code } return countryISO?.let { - Locale(it, "").getDisplayLanguage(defaultLocale) + Locale.Builder().setLanguage(it).build().getDisplayLanguage(defaultLocale) } } private fun getAvailableCountries() = Locale.getISOCountries() .asSequence() .map { - RegistrationField.Option(it, Locale("", it).getDisplayCountry(defaultLocale), "") + RegistrationField.Option( + it, + Locale.Builder().setRegion(it).build().getDisplayCountry(defaultLocale), + "" + ) } .sortedBy { it.name } .toList() - private fun getAvailableLanguages() = Locale.getISOLanguages() .asSequence() .filter { it.length == 2 } .map { - RegistrationField.Option(it, Locale(it, "").getDisplayLanguage(defaultLocale), "") + RegistrationField.Option( + it, + Locale.Builder().setLanguage(it).build().getDisplayLanguage(defaultLocale), + "" + ) } .sortedBy { it.name } .toList() fun getDisplayLanguage(languageCode: String): String { - return Locale(languageCode, "").getDisplayLanguage(defaultLocale) + return Locale.Builder().setLanguage(languageCode).build().getDisplayLanguage(defaultLocale) } - } diff --git a/core/src/main/java/org/openedx/core/utils/Logger.kt b/core/src/main/java/org/openedx/core/utils/Logger.kt index 41cd9a3a6..e08e2d357 100644 --- a/core/src/main/java/org/openedx/core/utils/Logger.kt +++ b/core/src/main/java/org/openedx/core/utils/Logger.kt @@ -1,9 +1,16 @@ package org.openedx.core.utils import android.util.Log +import com.google.firebase.crashlytics.FirebaseCrashlytics +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject import org.openedx.core.BuildConfig +import org.openedx.core.config.Config + +class Logger(private val tag: String) : KoinComponent { + + private val config by inject() -class Logger(private val tag: String) { fun d(message: () -> String) { if (BuildConfig.DEBUG) Log.d(tag, message()) } @@ -12,6 +19,13 @@ class Logger(private val tag: String) { if (BuildConfig.DEBUG) Log.e(tag, message()) } + fun e(throwable: Throwable, submitCrashReport: Boolean = false) { + if (BuildConfig.DEBUG) throwable.printStackTrace() + if (submitCrashReport && config.getFirebaseConfig().enabled) { + FirebaseCrashlytics.getInstance().recordException(throwable) + } + } + fun i(message: () -> String) { if (BuildConfig.DEBUG) Log.i(tag, message()) } diff --git a/core/src/main/java/org/openedx/core/utils/PreviewHelper.kt b/core/src/main/java/org/openedx/core/utils/PreviewHelper.kt new file mode 100644 index 000000000..dd3d65fdf --- /dev/null +++ b/core/src/main/java/org/openedx/core/utils/PreviewHelper.kt @@ -0,0 +1,172 @@ +package org.openedx.core.utils + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.media.MediaMetadataRetriever +import java.io.File +import java.io.FileOutputStream +import java.security.MessageDigest +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException + +data class VideoPreview( + val link: String? = null, + val bitmap: Bitmap? = null +) { + companion object { + fun createYoutubePreview(link: String): VideoPreview { + return VideoPreview(link = link) + } + + fun createEncodedVideoPreview(bitmap: Bitmap): VideoPreview { + return VideoPreview(bitmap = bitmap) + } + } +} + +object PreviewHelper { + + private const val TIMEOUT_MS = 5000L // 5 seconds + private val executor = Executors.newSingleThreadExecutor() + + fun getYouTubeThumbnailUrl(url: String): String { + val videoId = extractYouTubeVideoId(url) + return "https://img.youtube.com/vi/$videoId/0.jpg" + } + + private fun extractYouTubeVideoId(url: String): String { + val regex = Regex( + "^(?:https?://)?(?:www\\.)?(?:youtube\\.com/(?:[^/]+/.+/|(?:v|e(?:mbed)?)|.*[?&]v=)|youtu\\.be/)" + + "([^\"&?/\\s]{11})", + RegexOption.IGNORE_CASE + ) + val matchResult = regex.find(url) + return matchResult?.groups?.get(1)?.value ?: "" + } + + fun getVideoFrameBitmap(context: Context, isOnline: Boolean, videoUrl: String): Bitmap? { + var result: Bitmap? = null + if (isOnline || isLocalFile(videoUrl)) { + // Check cache first + val cacheFile = getCacheFile(context, videoUrl) + result = if (cacheFile.exists()) { + try { + BitmapFactory.decodeFile(cacheFile.absolutePath) + } catch (_: Exception) { + // If cache file is corrupted, try to extract from video with timeout + extractBitmapFromVideoWithTimeout(videoUrl, context) + } + } else { + // Extract from video with timeout + extractBitmapFromVideoWithTimeout(videoUrl, context) + } + } + return result + } + + private fun extractBitmapFromVideoWithTimeout(videoUrl: String, context: Context): Bitmap? { + return try { + val future = executor.submit { + extractBitmapFromVideo(videoUrl, context) + } + future.get(TIMEOUT_MS, TimeUnit.MILLISECONDS) + } catch (e: TimeoutException) { + // Server didn't respond within timeout, return null immediately + e.printStackTrace() + null + } catch (e: Exception) { + // Any other exception, return null immediately + e.printStackTrace() + null + } + } + + private fun extractBitmapFromVideo(videoUrl: String, context: Context): Bitmap? { + val retriever = MediaMetadataRetriever() + try { + if (isLocalFile(videoUrl)) { + retriever.setDataSource(videoUrl) + } else { + retriever.setDataSource(videoUrl, HashMap()) + } + val bitmap = retriever.getFrameAtTime(0) + + // Save bitmap to cache if it was successfully retrieved + bitmap?.let { + saveBitmapToCache(context, videoUrl, it) + } + + return bitmap + } catch (e: Exception) { + // Log the exception for debugging but don't crash + e.printStackTrace() + return null + } finally { + try { + retriever.release() + } catch (e: Exception) { + // Ignore release exceptions + e.printStackTrace() + } + } + } + + private fun isLocalFile(url: String): Boolean { + return url.startsWith("/") || url.startsWith("file://") + } + + private fun getCacheFile(context: Context, videoUrl: String): File { + val cacheDir = context.cacheDir + val fileName = generateFileName(videoUrl) + return File(cacheDir, "video_thumbnails/$fileName") + } + + private fun generateFileName(videoUrl: String): String { + val md = MessageDigest.getInstance("MD5") + val digest = md.digest(videoUrl.toByteArray()) + return digest.joinToString("") { "%02x".format(it) } + ".jpg" + } + + private fun saveBitmapToCache(context: Context, videoUrl: String, bitmap: Bitmap) { + try { + val cacheFile = getCacheFile(context, videoUrl) + cacheFile.parentFile?.mkdirs() // Create directories if they don't exist + + FileOutputStream(cacheFile).use { out -> + bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + + /** + * Clear the bitmap cache to free storage + */ + fun clearCache(context: Context) { + try { + val cacheDir = File(context.cacheDir, "video_thumbnails") + if (cacheDir.exists()) { + cacheDir.deleteRecursively() + } + } catch (e: Exception) { + e.printStackTrace() + } + } + + /** + * Remove a specific bitmap from cache + */ + fun removeFromCache(context: Context, videoUrl: String) { + try { + val cacheFile = getCacheFile(context, videoUrl) + if (cacheFile.exists()) { + cacheFile.delete() + } + } catch (e: Exception) { + e.printStackTrace() + } + } +} diff --git a/core/src/main/java/org/openedx/core/utils/Sha1Util.kt b/core/src/main/java/org/openedx/core/utils/Sha1Util.kt index 13a877e68..9839550e7 100644 --- a/core/src/main/java/org/openedx/core/utils/Sha1Util.kt +++ b/core/src/main/java/org/openedx/core/utils/Sha1Util.kt @@ -13,19 +13,28 @@ object Sha1Util { val sha1hash = md.digest() convertToHex(sha1hash) } catch (e: NoSuchAlgorithmException) { + e.printStackTrace() text } catch (e: UnsupportedEncodingException) { + e.printStackTrace() text } } + @Suppress("MagicNumber") fun convertToHex(data: ByteArray): String { val buf = StringBuilder() for (b in data) { var halfbyte = b.toInt() ushr 4 and 0x0F var twoHalfs = 0 do { - buf.append(if (halfbyte in 0..9) ('0'.code + halfbyte).toChar() else ('a'.code + (halfbyte - 10)).toChar()) + buf.append( + if (halfbyte in 0..9) { + ('0'.code + halfbyte).toChar() + } else { + ('a'.code + (halfbyte - 10)).toChar() + } + ) halfbyte = b.toInt() and 0x0F } while (twoHalfs++ < 1) } 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..cba6a5e8e 100644 --- a/core/src/main/java/org/openedx/core/utils/TimeUtils.kt +++ b/core/src/main/java/org/openedx/core/utils/TimeUtils.kt @@ -5,23 +5,104 @@ 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 import java.util.Calendar import java.util.Date import java.util.Locale -import java.util.concurrent.TimeUnit -import kotlin.math.ceil +import kotlin.math.absoluteValue +@Suppress("MagicNumber") object TimeUtils { private const val FORMAT_ISO_8601 = "yyyy-MM-dd'T'HH:mm:ss'Z'" private const val FORMAT_ISO_8601_WITH_TIME_ZONE = "yyyy-MM-dd'T'HH:mm:ssXXX" - + private const val FORMAT_MONTH_DAY = "MMM dd" private const val SEVEN_DAYS_IN_MILLIS = 604800000L + fun formatToString(context: Context, date: Date, useRelativeDates: Boolean): String { + if (!useRelativeDates) { + val locale = Locale.Builder().setLanguage(Locale.getDefault().language).build() + 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 formatToDueInString(context: Context, date: Date): String { + val now = Calendar.getInstance() + val dueDate = Calendar.getInstance().apply { time = date } + now.set(Calendar.HOUR_OF_DAY, 0) + now.set(Calendar.MINUTE, 0) + now.set(Calendar.SECOND, 0) + now.set(Calendar.MILLISECOND, 0) + dueDate.set(Calendar.HOUR_OF_DAY, 0) + dueDate.set(Calendar.MINUTE, 0) + dueDate.set(Calendar.SECOND, 0) + dueDate.set(Calendar.MILLISECOND, 0) + val daysDifference = + ((dueDate.timeInMillis - now.timeInMillis) / (24 * 60 * 60 * 1000)).toInt() + return when { + daysDifference < 0 -> context.getString(R.string.core_date_type_past_due) + daysDifference == 0 -> context.getString(R.string.core_date_type_today) + else -> context.getString(R.string.core_date_format_due_in_days, daysDifference) + } + } + + fun formatToMonthDay(date: Date): String { + val sdf = SimpleDateFormat(FORMAT_MONTH_DAY, Locale.getDefault()) + return sdf.format(date) + } + fun getCurrentTime(): Long { return Calendar.getInstance().timeInMillis } @@ -46,9 +127,13 @@ object TimeUtils { fun iso8601ToDateWithTime(context: Context, text: String): String { return try { - val courseDateFormat = SimpleDateFormat(FORMAT_ISO_8601, Locale.getDefault()) + val courseDateFormat = SimpleDateFormat( + FORMAT_ISO_8601, + Locale.getDefault() + ) val applicationDateFormat = SimpleDateFormat( - context.getString(R.string.core_full_date_with_time), Locale.getDefault() + context.getString(R.string.core_full_date_with_time), + Locale.getDefault() ) applicationDateFormat.format(courseDateFormat.parse(text)!!) } catch (e: Exception) { @@ -59,7 +144,8 @@ 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 ) } @@ -92,163 +178,154 @@ object TimeUtils { startType: String, startDisplay: String ): String { - val formattedDate: String val resourceManager = ResourceManager(context) - if (isDatePassed(today, start)) { - if (expiry != null) { - val dayDifferenceInMillis = if (today.after(expiry)) { - today.time - expiry.time - } else { - expiry.time - today.time - } + return when { + isDatePassed(today, start) -> handleDatePassedToday( + resourceManager, + today, + expiry, + start, + end, + startType, + startDisplay + ) - if (isDatePassed(today, expiry)) { - formattedDate = if (dayDifferenceInMillis > SEVEN_DAYS_IN_MILLIS) { - resourceManager.getString( - R.string.core_label_expired_on, - dateToCourseDate(resourceManager, expiry) - ) - } else { - val timeSpan = DateUtils.getRelativeTimeSpanString( - expiry.time, - today.time, - DateUtils.SECOND_IN_MILLIS, - DateUtils.FORMAT_ABBREV_RELATIVE - ).toString() - resourceManager.getString(R.string.core_label_expired, timeSpan) - } - } else { - formattedDate = if (dayDifferenceInMillis > SEVEN_DAYS_IN_MILLIS) { - resourceManager.getString( - R.string.core_label_expires_on, - dateToCourseDate(resourceManager, expiry) - ) - } else { - val timeSpan = DateUtils.getRelativeTimeSpanString( - expiry.time, - today.time, - DateUtils.SECOND_IN_MILLIS, - DateUtils.FORMAT_ABBREV_RELATIVE - ).toString() - resourceManager.getString(R.string.core_label_expires, timeSpan) - } - } - } else { - formattedDate = if (end == null) { - if (startType == StartType.TIMESTAMP.type && start != null) { - resourceManager.getString( - R.string.core_label_starting, dateToCourseDate(resourceManager, start) - ) - } else if (startType == StartType.STRING.type && start != null) { - resourceManager.getString(R.string.core_label_starting, startDisplay) - } else { - val soon = resourceManager.getString(R.string.core_assessment_soon) - resourceManager.getString(R.string.core_label_starting, soon) - } - } else if (isDatePassed(today, end)) { + else -> handleDateNotPassedToday(resourceManager, start, startType, startDisplay) + } + } + + private fun handleDatePassedToday( + resourceManager: ResourceManager, + today: Date, + expiry: Date?, + start: Date?, + end: Date?, + startType: String, + startDisplay: String + ): String { + return when { + expiry != null -> handleExpiry(resourceManager, today, expiry) + else -> handleNoExpiry(resourceManager, today, start, end, startType, startDisplay) + } + } + + private fun handleExpiry(resourceManager: ResourceManager, today: Date, expiry: Date): String { + val dayDifferenceInMillis = (today.time - expiry.time).absoluteValue + + return when { + isDatePassed(today, expiry) -> { + if (dayDifferenceInMillis > SEVEN_DAYS_IN_MILLIS) { resourceManager.getString( - R.string.core_label_ended, dateToCourseDate(resourceManager, end) + R.string.core_label_expired_on, + dateToCourseDate(resourceManager, expiry) ) } else { + val timeSpan = DateUtils.getRelativeTimeSpanString( + expiry.time, + today.time, + DateUtils.SECOND_IN_MILLIS, + DateUtils.FORMAT_ABBREV_RELATIVE + ).toString() + resourceManager.getString(R.string.core_label_access_expired, timeSpan) + } + } + + else -> { + if (dayDifferenceInMillis > SEVEN_DAYS_IN_MILLIS) { resourceManager.getString( - R.string.core_label_ending, dateToCourseDate(resourceManager, end) + R.string.core_label_expires, + dateToCourseDate(resourceManager, expiry) ) + } else { + val timeSpan = DateUtils.getRelativeTimeSpanString( + expiry.time, + today.time, + DateUtils.SECOND_IN_MILLIS, + DateUtils.FORMAT_ABBREV_RELATIVE + ).toString() + resourceManager.getString(R.string.core_label_expires, timeSpan) } } - } else { - formattedDate = if (startType == StartType.TIMESTAMP.type && start != null) { - resourceManager.getString( - R.string.core_label_starting, dateToCourseDate(resourceManager, start) - ) - } else if (startType == StartType.STRING.type && start != null) { - resourceManager.getString(R.string.core_label_starting, startDisplay) - } else { - val soon = resourceManager.getString(R.string.core_assessment_soon) - resourceManager.getString(R.string.core_label_starting, soon) - } } - return formattedDate } - /** - * Method to get the formatted time string in terms of relative time with minimum resolution of minutes. - * For example, if the time difference is 1 minute, it will return "1m ago". - * - * @param date Date object to be formatted. - */ - fun getFormattedTime(date: Date): String { - return DateUtils.getRelativeTimeSpanString( - date.time, - getCurrentTime(), - DateUtils.MINUTE_IN_MILLIS, - DateUtils.FORMAT_ABBREV_TIME - ).toString() - } + private fun handleNoExpiry( + resourceManager: ResourceManager, + today: Date, + start: Date?, + end: Date?, + startType: String, + startDisplay: String + ): String { + return when { + end == null -> handleNoEndDate(resourceManager, start, startType, startDisplay) + isDatePassed(today, end) -> resourceManager.getString( + R.string.core_label_ended, + dateToCourseDate(resourceManager, end) + ) - /** - * 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() + else -> resourceManager.getString( + R.string.core_label_ends, + dateToCourseDate(resourceManager, end) + ) } - val daysDifference = getDayDifference(inputDate) + } + private fun handleDateNotPassedToday( + resourceManager: ResourceManager, + start: Date?, + startType: String, + startDisplay: String + ): String { return when { - daysDifference == 0 -> { - context.getString(R.string.core_date_format_today) - } - - daysDifference == 1 -> { - context.getString(R.string.core_date_format_tomorrow) - } + startType == StartType.TIMESTAMP.type && start != null -> resourceManager.getString( + R.string.core_label_starting, + dateToCourseDate(resourceManager, start) + ) - daysDifference == -1 -> { - context.getString(R.string.core_date_format_yesterday) - } + startType == StartType.STRING.type && start != null -> resourceManager.getString( + R.string.core_label_starting, + startDisplay + ) - daysDifference in -2 downTo -7 -> { - context.getString( - R.string.core_date_format_days_ago, - ceil(-daysDifference.toDouble()).toInt().toString() - ) + else -> { + val soon = resourceManager.getString(R.string.core_assessment_soon) + resourceManager.getString(R.string.core_label_starting, soon) } + } + } - daysDifference in 2..7 -> { - DateUtils.formatDateTime( - context, - date.time, - DateUtils.FORMAT_SHOW_WEEKDAY - ) - } + private fun handleNoEndDate( + resourceManager: ResourceManager, + start: Date?, + startType: String, + startDisplay: String + ): String { + return when { + startType == StartType.TIMESTAMP.type && start != null -> resourceManager.getString( + R.string.core_label_starting, + dateToCourseDate(resourceManager, start) + ) - inputDate.get(Calendar.YEAR) != Calendar.getInstance().get(Calendar.YEAR) -> { - DateUtils.formatDateTime( - context, - date.time, - DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_YEAR - ) - } + startType == StartType.STRING.type && start != null -> resourceManager.getString( + R.string.core_label_starting, + startDisplay + ) else -> { - DateUtils.formatDateTime( - context, - date.time, - DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_NO_YEAR - ) + val soon = resourceManager.getString(R.string.core_assessment_soon) + resourceManager.getString(R.string.core_label_starting, soon) } } } /** - * 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 +373,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/utils/VideoUtil.kt b/core/src/main/java/org/openedx/core/utils/VideoUtil.kt index cb24868af..b86674068 100644 --- a/core/src/main/java/org/openedx/core/utils/VideoUtil.kt +++ b/core/src/main/java/org/openedx/core/utils/VideoUtil.kt @@ -40,5 +40,4 @@ object VideoUtil { } return false } - -} \ No newline at end of file +} 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..d7c7d12a7 --- /dev/null +++ b/core/src/main/java/org/openedx/core/worker/CalendarSyncWorker.kt @@ -0,0 +1,228 @@ +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) { + e.printStackTrace() + 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_download_waiting.png b/core/src/main/res/drawable/core_download_waiting.png new file mode 100644 index 000000000..c4a04af69 Binary files /dev/null and b/core/src/main/res/drawable/core_download_waiting.png differ diff --git a/core/src/main/res/drawable/core_ic_back.xml b/core/src/main/res/drawable/core_ic_back.xml deleted file mode 100644 index 912dc1200..000000000 --- a/core/src/main/res/drawable/core_ic_back.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - 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_chapter_icon.xml b/core/src/main/res/drawable/core_ic_chapter_icon.xml new file mode 100644 index 000000000..9ee00fed7 --- /dev/null +++ b/core/src/main/res/drawable/core_ic_chapter_icon.xml @@ -0,0 +1,30 @@ + + + + + + + + diff --git a/core/src/main/res/drawable/core_ic_check.xml b/core/src/main/res/drawable/core_ic_check.xml index 81badcbcd..381b4712a 100644 --- a/core/src/main/res/drawable/core_ic_check.xml +++ b/core/src/main/res/drawable/core_ic_check.xml @@ -1,13 +1,9 @@ - + android:width="16dp" + android:height="16dp" + android:viewportWidth="16" + android:viewportHeight="16"> + diff --git a/core/src/main/res/drawable/core_ic_edit.xml b/core/src/main/res/drawable/core_ic_edit.xml deleted file mode 100644 index 62f035a78..000000000 --- a/core/src/main/res/drawable/core_ic_edit.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - diff --git a/core/src/main/res/drawable/core_ic_error.xml b/core/src/main/res/drawable/core_ic_error.xml new file mode 100644 index 000000000..4454ecf7c --- /dev/null +++ b/core/src/main/res/drawable/core_ic_error.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/src/main/res/drawable/core_ic_forward.xml b/core/src/main/res/drawable/core_ic_forward.xml deleted file mode 100644 index 8c47ce201..000000000 --- a/core/src/main/res/drawable/core_ic_forward.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - diff --git a/core/src/main/res/drawable/core_ic_mountains.xml b/core/src/main/res/drawable/core_ic_mountains.xml new file mode 100644 index 000000000..eea9a0e6b --- /dev/null +++ b/core/src/main/res/drawable/core_ic_mountains.xml @@ -0,0 +1,34 @@ + + + + + + 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_screen_rotation.xml b/core/src/main/res/drawable/core_ic_screen_rotation.xml deleted file mode 100644 index 0d842b791..000000000 --- a/core/src/main/res/drawable/core_ic_screen_rotation.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - 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_check.xml b/core/src/main/res/drawable/ic_core_check.xml new file mode 100644 index 000000000..e636ca1d8 --- /dev/null +++ b/core/src/main/res/drawable/ic_core_check.xml @@ -0,0 +1,12 @@ + + + + diff --git a/core/src/main/res/drawable/ic_core_pointer.xml b/core/src/main/res/drawable/ic_core_pointer.xml new file mode 100644 index 000000000..cc777cf3e --- /dev/null +++ b/core/src/main/res/drawable/ic_core_pointer.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/src/main/res/drawable/ic_core_watch_later.xml b/core/src/main/res/drawable/ic_core_watch_later.xml new file mode 100644 index 000000000..4dd7cedf0 --- /dev/null +++ b/core/src/main/res/drawable/ic_core_watch_later.xml @@ -0,0 +1,14 @@ + + + + diff --git a/core/src/main/res/font/font.xml b/core/src/main/res/font/font.xml deleted file mode 100644 index 4cdad3af5..000000000 --- a/core/src/main/res/font/font.xml +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - 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..f4fabd553 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -2,7 +2,6 @@ @string/platform_name - Results Invalid credentials Slow or no internet connection Something went wrong @@ -16,13 +15,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 +44,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. @@ -58,7 +56,6 @@ Settings App Update Required This version of the OpenEdX app is out-of-date. To continue learning and get the latest features and fixes, please upgrade to the latest version. - Why do I need to update? Version: %1$s Up-to-date Tap to update to version %1$s @@ -76,6 +73,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 @@ -88,14 +87,13 @@ Completed Past Due Today + Due Tomorrow This Week Next Week Upcoming None - Today - Tomorrow - Yesterday - %1$s days ago + Due %1$s + Due in %1$d days %d Item Hidden %d Items Hidden @@ -132,9 +130,91 @@ Video download quality Manage Account - Home - Videos - Discussions - More - Dates + Syncing calendar… + + + Sync to 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 + + + No course content is currently available. + No videos available for this course. + Course dates are currently not available. + This course does not contain exams or graded assignments. + No assignments available for this course. + 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 + Previous + + Downloads + (Untitled) + Download + The videos you\'ve selected are larger than 1 GB. Do you want to download these videos? + 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\"? + 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. + Authorization + Please enter the system to continue with course enrollment. diff --git a/core/src/main/res/values/themes.xml b/core/src/main/res/values/themes.xml index e6859e022..e43010475 100644 --- a/core/src/main/res/values/themes.xml +++ b/core/src/main/res/values/themes.xml @@ -1,4 +1,4 @@ - + - " + " body {\n" + + " background-color: #${getColorFromULong(bgColor)};\n" + + " color: #${getColorFromULong(textColor)};\n" + + " }\n" + + "" val buff = StringBuffer().apply { if (bgColor != ULong.MIN_VALUE) append(darkThemeStyle) append(content) @@ -92,14 +107,15 @@ class HandoutsViewModel( return buff.toString() } + @Suppress("MagicNumber") private fun getColorFromULong(color: ULong): String { if (color == ULong.MIN_VALUE) return "black" return java.lang.Long.toHexString(color.toLong()).substring(2, 8) } 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..744af0d03 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,165 @@ 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/home/AssignmentsHomePagerCardContent.kt b/course/src/main/java/org/openedx/course/presentation/home/AssignmentsHomePagerCardContent.kt new file mode 100644 index 000000000..3c0afb1e1 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/home/AssignmentsHomePagerCardContent.kt @@ -0,0 +1,281 @@ +package org.openedx.course.presentation.home + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Card +import androidx.compose.material.Icon +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Assignment +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Timer +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.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import org.openedx.core.domain.model.Block +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appTypography +import org.openedx.core.utils.TimeUtils +import org.openedx.course.R +import org.openedx.course.presentation.contenttab.CourseContentAssignmentEmptyState +import java.util.Date +import org.openedx.core.R as coreR + +private const val MILLISECONDS_PER_SECOND = 1000 +private const val SECONDS_PER_MINUTE = 60 +private const val MINUTES_PER_HOUR = 60 +private const val HOURS_PER_DAY = 24 + +private const val MILLISECONDS_PER_DAY = + MILLISECONDS_PER_SECOND * SECONDS_PER_MINUTE * MINUTES_PER_HOUR * HOURS_PER_DAY + +@Composable +fun AssignmentsHomePagerCardContent( + uiState: CourseHomeUIState.CourseData, + onAssignmentClick: (Block) -> Unit, + onViewAllAssignmentsClick: () -> Unit, + getBlockParent: (blockId: String) -> Block?, +) { + if (uiState.courseAssignments.isEmpty()) { + CourseContentAssignmentEmptyState( + onReturnToCourseClick = {}, + showReturnButton = false + ) + return + } + + val completedAssignments = uiState.courseAssignments.count { it.isCompleted() } + val totalAssignments = uiState.courseAssignments.size + val firstIncompleteAssignment = uiState.courseAssignments.find { !it.isCompleted() } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + // Header with progress + Text( + text = stringResource(R.string.course_container_content_tab_assignment), + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textPrimary, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.height(12.dp)) + + // Progress section + Row( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = true) {}, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Assignment, + contentDescription = null, + tint = MaterialTheme.appColors.textPrimary, + modifier = Modifier.size(32.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "$completedAssignments/$totalAssignments", + style = MaterialTheme.appTypography.displaySmall, + color = MaterialTheme.appColors.textPrimary, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.course_assignments_completed), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textPrimaryVariant, + fontWeight = FontWeight.Medium + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Progress bar + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .clip(CircleShape), + progress = if (totalAssignments > 0) completedAssignments.toFloat() / totalAssignments else 0f, + color = MaterialTheme.appColors.progressBarColor, + backgroundColor = MaterialTheme.appColors.progressBarBackgroundColor + ) + + Spacer(modifier = Modifier.height(20.dp)) + + // First Incomplete Assignment section + if (firstIncompleteAssignment != null) { + AssignmentCard( + assignment = firstIncompleteAssignment, + sectionName = getBlockParent(firstIncompleteAssignment.id)?.displayName ?: "", + onAssignmentClick = onAssignmentClick, + background = MaterialTheme.appColors.background, + ) + } else { + CaughtUpMessage( + message = stringResource(R.string.course_assignments_caught_up) + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // View All Assignments button + ViewAllButton( + text = stringResource(R.string.course_view_all_assignments), + onClick = onViewAllAssignmentsClick + ) + } +} + +@Composable +private fun AssignmentCard( + assignment: Block, + sectionName: String, + onAssignmentClick: (Block) -> Unit, + background: Color = MaterialTheme.appColors.surface +) { + val isDuePast = assignment.due != null && assignment.due!! < Date() + + // Header text - "Past Due" or "Due Soon" + val headerText = if (isDuePast) { + stringResource(coreR.string.core_date_type_past_due) + } else { + stringResource(R.string.course_next_assignment) + } + + // Due date status text + val dueDateStatusText = assignment.due?.let { due -> + val formattedDate = TimeUtils.formatToMonthDay(due) + val daysDifference = ((due.time - Date().time) / MILLISECONDS_PER_DAY).toInt() + when { + daysDifference < 0 -> { + // Past due + val daysPastDue = -daysDifference + stringResource( + R.string.course_days_past_due, + daysPastDue, + formattedDate + ) + } + + daysDifference == 0 -> { + // Due today + stringResource( + R.string.course_due_today, + formattedDate + ) + } + + else -> { + // Due in the future + stringResource( + R.string.course_due_in_days, + daysDifference, + formattedDate + ) + } + } + } ?: "" + + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { onAssignmentClick(assignment) }, + backgroundColor = background, + border = BorderStroke(1.dp, MaterialTheme.appColors.cardViewBorder), + shape = RoundedCornerShape(8.dp), + elevation = 0.dp + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + // Header section with icon and status + if (assignment.due != null) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + if (isDuePast) { + Icon( + imageVector = Icons.Filled.Timer, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.appColors.warning + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text( + text = headerText, + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textPrimary, + fontWeight = FontWeight.Bold + ) + } + Spacer(modifier = Modifier.height(8.dp)) + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1f) + ) { + // Due date status text + if (dueDateStatusText.isNotEmpty()) { + Text( + text = dueDateStatusText, + style = MaterialTheme.appTypography.labelSmall, + color = MaterialTheme.appColors.primary + ) + Spacer(modifier = Modifier.height(4.dp)) + } + + // Assignment and section name + Text( + text = assignment.displayName, + style = MaterialTheme.appTypography.titleSmall, + color = MaterialTheme.appColors.textPrimary, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = sectionName, + style = MaterialTheme.appTypography.labelSmall, + color = MaterialTheme.appColors.textPrimaryVariant, + ) + } + + // Chevron arrow + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.appColors.textDark + ) + } + } + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseCompletionHomePagerCardContent.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseCompletionHomePagerCardContent.kt new file mode 100644 index 000000000..031a3a145 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseCompletionHomePagerCardContent.kt @@ -0,0 +1,161 @@ +package org.openedx.course.presentation.home + +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.openedx.core.Mock +import org.openedx.core.domain.model.Block +import org.openedx.core.domain.model.CourseDatesBannerInfo +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.presentation.progress.CourseCompletionCircularProgress +import org.openedx.course.presentation.ui.CourseSection + +@Composable +fun CourseCompletionHomePagerCardContent( + modifier: Modifier = Modifier, + uiState: CourseHomeUIState.CourseData, + onViewAllContentClick: () -> Unit, + onDownloadClick: (blockIds: List) -> Unit, + onSubSectionClick: (Block) -> Unit, +) { + val courseProgress = uiState.courseProgress?.completion ?: 0f + val courseProgressPercent = uiState.courseProgress?.completionPercent ?: 0 + + Column( + modifier = modifier + .fillMaxWidth() + .padding(16.dp) + ) { + // Title + Text( + text = stringResource(R.string.course_completion_title), + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textDark + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Progress Section + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier + .weight(1f) + .semantics(mergeDescendants = true) {}, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = stringResource(R.string.course_completion_progress_label), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textDark, + fontWeight = FontWeight.Bold + ) + Text( + text = stringResource( + R.string.course_completion_progress_description, + courseProgressPercent + ), + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.textDark + ) + } + + // Circular Progress + CourseCompletionCircularProgress( + progress = courseProgress, + progressPercent = courseProgressPercent, + completedText = stringResource(R.string.course_completion_completed) + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + uiState.next?.let { (chapter, subsection) -> + // Section progress + val subSections = uiState.courseSubSections[chapter.id] + val completedCount = subSections?.count { it.isCompleted() } ?: 0 + val totalCount = subSections?.size ?: 0 + val progress = if (totalCount > 0) completedCount.toFloat() / totalCount else 0f + + CourseSection( + section = chapter, + onItemClick = { + onSubSectionClick(subsection) + }, + isExpandable = false, + isSectionVisible = true, + showDueDate = false, + useRelativeDates = uiState.useRelativeDates, + subSections = listOf(subsection), + downloadedStateMap = uiState.downloadedState, + onSubSectionClick = onSubSectionClick, + onDownloadClick = onDownloadClick, + progress = progress, + background = MaterialTheme.appColors.background + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // View All Content Button + ViewAllButton( + text = stringResource(R.string.course_completion_view_all_content), + onClick = onViewAllContentClick, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + } +} + +@Preview +@Composable +private fun CourseCompletionHomePagerCardContentPreview() { + OpenEdXTheme { + CourseCompletionHomePagerCardContent( + uiState = CourseHomeUIState.CourseData( + courseStructure = Mock.mockCourseStructure, + courseProgress = null, // No course progress for preview + next = Pair(Mock.mockChapterBlock, Mock.mockChapterBlock), // Mock next section + downloadedState = mapOf(), + resumeComponent = Mock.mockChapterBlock, + resumeUnitTitle = "Resumed Unit", + courseSubSections = mapOf(), + subSectionsDownloadsCount = mapOf(), + datesBannerInfo = CourseDatesBannerInfo( + missedDeadlines = false, + missedGatedContent = false, + verifiedUpgradeLink = "", + contentTypeGatingEnabled = false, + hasEnded = false + ), + useRelativeDates = true, + courseVideos = mapOf(), + courseAssignments = emptyList(), + videoPreview = null, + videoProgress = 0f + ), + onViewAllContentClick = {}, + onDownloadClick = {}, + onSubSectionClick = {}, + ) + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseHomePagerTab.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseHomePagerTab.kt new file mode 100644 index 000000000..d18dad224 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseHomePagerTab.kt @@ -0,0 +1,8 @@ +package org.openedx.course.presentation.home + +enum class CourseHomePagerTab { + COURSE_COMPLETION, + VIDEOS, + ASSIGNMENT, + GRADES +} diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt new file mode 100644 index 000000000..241d51f31 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt @@ -0,0 +1,547 @@ +package org.openedx.course.presentation.home + +import android.content.res.Configuration.UI_MODE_NIGHT_NO +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.foundation.BorderStroke +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +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.Card +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.TextButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.List +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.platform.AndroidUriHandler +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentManager +import org.openedx.core.Mock +import org.openedx.core.NoContentScreenType +import org.openedx.core.domain.model.Block +import org.openedx.core.domain.model.CourseDatesBannerInfo +import org.openedx.core.ui.CircularProgress +import org.openedx.core.ui.HandleUIMessage +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.course.R +import org.openedx.course.presentation.container.CourseContentTab +import org.openedx.course.presentation.ui.CourseDatesBanner +import org.openedx.course.presentation.ui.CourseDatesBannerTablet +import org.openedx.course.presentation.ui.CourseMessage +import org.openedx.course.presentation.ui.ResumeCourseButton +import org.openedx.course.presentation.unit.container.CourseViewMode +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 org.openedx.core.R as coreR + +@Composable +fun CourseHomeScreen( + windowSize: WindowSize, + viewModel: CourseHomeViewModel, + fragmentManager: FragmentManager, + homePagerState: PagerState, + onResetDatesClick: () -> Unit, + onNavigateToContent: (CourseContentTab) -> Unit = {}, + onNavigateToProgress: () -> Unit = {}, +) { + 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) + } + } + + CourseHomeUI( + windowSize = windowSize, + uiState = uiState, + uiMessage = uiMessage, + homePagerState = homePagerState, + onSubSectionClick = { subSectionBlock -> + // Log section/subsection click event + viewModel.logSectionSubsectionClick( + subSectionBlock.blockId, + subSectionBlock.displayName + ) + if (viewModel.isCourseDropdownNavigationEnabled) { + viewModel.courseSubSectionUnit[subSectionBlock.id]?.let { unit -> + viewModel.courseRouter.navigateToCourseContainer( + fragmentManager, + courseId = viewModel.courseId, + unitId = unit.id, + mode = CourseViewMode.FULL + ) + } + } else { + viewModel.courseRouter.navigateToCourseSubsections( + fm = fragmentManager, + courseId = viewModel.courseId, + subSectionId = subSectionBlock.id, + mode = CourseViewMode.FULL + ) + } + }, + onResumeClick = { componentId -> + viewModel.openBlock( + fragmentManager, + componentId + ) + }, + onDownloadClick = { blocksIds -> + viewModel.downloadBlocks( + blocksIds = blocksIds, + fragmentManager = fragmentManager, + ) + }, + onResetDatesClick = { + viewModel.resetCourseDatesBanner( + onResetDates = { + onResetDatesClick() + } + ) + }, + onCertificateClick = { + viewModel.viewCertificateTappedEvent() + it.takeIfNotEmpty() + ?.let { url -> AndroidUriHandler(context).openUri(url) } + }, + onVideoClick = { videoBlock -> + viewModel.courseRouter.navigateToCourseContainer( + fragmentManager, + courseId = viewModel.courseId, + unitId = viewModel.getBlockParent(videoBlock.id)?.id ?: return@CourseHomeUI, + mode = CourseViewMode.VIDEOS + ) + viewModel.logVideoClick(videoBlock.id) + }, + onAssignmentClick = { assignmentBlock -> + viewModel.courseRouter.navigateToCourseContainer( + fragmentManager, + courseId = viewModel.courseId, + unitId = viewModel.getBlockParent(assignmentBlock.id)?.id ?: return@CourseHomeUI, + mode = CourseViewMode.FULL + ) + viewModel.logAssignmentClick(assignmentBlock.id) + }, + onNavigateToContent = onNavigateToContent, + onNavigateToProgress = onNavigateToProgress, + getBlockParent = viewModel::getBlockParent, + onViewAllContentClick = viewModel::logViewAllContentClick, + onViewAllVideosClick = viewModel::logViewAllVideosClick, + onViewAllAssignmentsClick = viewModel::logViewAllAssignmentsClick, + onViewProgressClick = viewModel::logViewProgressClick + ) +} + +@Composable +private fun CourseHomeUI( + windowSize: WindowSize, + uiState: CourseHomeUIState, + uiMessage: UIMessage?, + homePagerState: PagerState, + onSubSectionClick: (Block) -> Unit, + onResumeClick: (String) -> Unit, + onDownloadClick: (blockIds: List) -> Unit, + onResetDatesClick: () -> Unit, + onCertificateClick: (String) -> Unit, + onVideoClick: (Block) -> Unit, + onAssignmentClick: (Block) -> Unit, + onNavigateToContent: (CourseContentTab) -> Unit, + onNavigateToProgress: () -> Unit, + getBlockParent: (blockId: String) -> Block?, + onViewAllContentClick: () -> Unit, + onViewAllVideosClick: () -> Unit, + onViewAllAssignmentsClick: () -> Unit, + onViewProgressClick: () -> Unit, +) { + val scaffoldState = rememberScaffoldState() + + Scaffold( + modifier = Modifier + .fillMaxSize(), + 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() + ) + ) + } + + HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) + + Box( + modifier = Modifier + .fillMaxSize() + .padding(it) + .displayCutoutForLandscape(), + contentAlignment = Alignment.TopCenter + ) { + Surface( + modifier = screenWidth, + color = MaterialTheme.appColors.background + ) { + when (uiState) { + is CourseHomeUIState.CourseData -> { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + if (uiState.datesBannerInfo.isBannerAvailableForDashboard()) { + Box( + modifier = Modifier + .padding(all = 8.dp) + ) { + if (windowSize.isTablet) { + CourseDatesBannerTablet( + banner = uiState.datesBannerInfo, + resetDates = onResetDatesClick, + ) + } else { + CourseDatesBanner( + banner = uiState.datesBannerInfo, + resetDates = onResetDatesClick, + ) + } + } + } + + val certificate = uiState.courseStructure.certificate + if (certificate?.isCertificateEarned() == true) { + CourseMessage( + modifier = Modifier + .fillMaxWidth() + .padding( + vertical = 12.dp, + horizontal = 24.dp + ), + icon = painterResource(R.drawable.course_ic_certificate), + message = stringResource( + R.string.course_you_earned_certificate, + uiState.courseStructure.name + ), + action = stringResource(R.string.course_view_certificate), + onActionClick = { + onCertificateClick( + certificate.certificateURL ?: "" + ) + } + ) + } + + if (uiState.resumeComponent != null) { + ResumeCourseButton( + modifier = Modifier.padding(16.dp), + block = uiState.resumeComponent, + displayName = uiState.resumeUnitTitle, + onResumeClick = onResumeClick + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + CourseHomePager( + modifier = Modifier.fillMaxSize(), + pages = CourseHomePagerTab.entries, + pagerState = homePagerState + ) { tab -> + Card( + modifier = Modifier.fillMaxWidth(), + backgroundColor = MaterialTheme.appColors.cardViewBackground, + border = BorderStroke( + 1.dp, + MaterialTheme.appColors.cardViewBorder + ), + shape = MaterialTheme.appShapes.cardShape, + elevation = 0.dp, + ) { + when (tab) { + CourseHomePagerTab.COURSE_COMPLETION -> { + CourseCompletionHomePagerCardContent( + uiState = uiState, + onViewAllContentClick = { + onViewAllContentClick() + onNavigateToContent(CourseContentTab.ALL) + }, + onDownloadClick = onDownloadClick, + onSubSectionClick = onSubSectionClick + ) + } + + CourseHomePagerTab.VIDEOS -> { + VideosHomePagerCardContent( + uiState = uiState, + onVideoClick = onVideoClick, + onViewAllVideosClick = { + onViewAllVideosClick() + onNavigateToContent(CourseContentTab.VIDEOS) + } + ) + } + + CourseHomePagerTab.ASSIGNMENT -> { + AssignmentsHomePagerCardContent( + uiState = uiState, + onAssignmentClick = onAssignmentClick, + getBlockParent = getBlockParent, + onViewAllAssignmentsClick = { + onViewAllAssignmentsClick() + onNavigateToContent(CourseContentTab.ASSIGNMENTS) + } + ) + } + + CourseHomePagerTab.GRADES -> { + GradesHomePagerCardContent( + uiState = uiState, + onViewProgressClick = { + onViewProgressClick() + onNavigateToProgress() + } + ) + } + } + } + } + } + } + + CourseHomeUIState.Error -> { + NoContentScreen(noContentScreenType = NoContentScreenType.COURSE_OUTLINE) + } + + CourseHomeUIState.Loading -> { + CircularProgress() + } + + CourseHomeUIState.Waiting -> {} + } + } + } + } +} + +@Composable +fun CourseHomePager( + modifier: Modifier = Modifier, + pages: List, + pagerState: PagerState, + pageContent: @Composable (T) -> Unit +) { + HorizontalPager( + modifier = modifier, + state = pagerState, + contentPadding = PaddingValues(horizontal = 16.dp), + pageSpacing = 8.dp, + beyondViewportPageCount = pages.size, + verticalAlignment = Alignment.Top + ) { page -> + pageContent(pages[page]) + } +} + +@Composable +fun ViewAllButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + TextButton( + onClick = onClick, + modifier = modifier.fillMaxWidth() + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.List, + contentDescription = null, + tint = MaterialTheme.appColors.textAccent, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = text, + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textAccent + ) + } +} + +@Composable +fun CaughtUpMessage( + modifier: Modifier = Modifier, + message: String, +) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + modifier = Modifier.size(48.dp), + painter = painterResource(coreR.drawable.core_ic_check), + contentDescription = null, + tint = MaterialTheme.appColors.successGreen + ) + Text( + modifier = modifier + .fillMaxWidth(), + text = message, + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.bodyLarge, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center + ) + } +} + +@Preview(uiMode = UI_MODE_NIGHT_NO) +@Preview(uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun CourseHomeScreenPreview() { + OpenEdXTheme { + val previewPagerState = rememberPagerState( + initialPage = 0, + pageCount = { CourseHomePagerTab.entries.size } + ) + CourseHomeUI( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + uiState = CourseHomeUIState.CourseData( + courseStructure = Mock.mockCourseStructure, + courseProgress = null, // No course progress for preview + next = null, // No next section for preview + downloadedState = mapOf(), + resumeComponent = Mock.mockChapterBlock, + resumeUnitTitle = "Resumed Unit", + courseSubSections = mapOf(), + subSectionsDownloadsCount = mapOf(), + datesBannerInfo = CourseDatesBannerInfo( + missedDeadlines = false, + missedGatedContent = false, + verifiedUpgradeLink = "", + contentTypeGatingEnabled = false, + hasEnded = false + ), + useRelativeDates = true, + courseVideos = mapOf(), + courseAssignments = emptyList(), + videoPreview = null, + videoProgress = 0f + ), + uiMessage = null, + homePagerState = previewPagerState, + onSubSectionClick = {}, + onResumeClick = {}, + onDownloadClick = {}, + onResetDatesClick = {}, + onCertificateClick = {}, + onVideoClick = {}, + onAssignmentClick = {}, + onNavigateToContent = { _ -> }, + onNavigateToProgress = {}, + getBlockParent = { null }, + onViewAllContentClick = {}, + onViewAllVideosClick = {}, + onViewAllAssignmentsClick = {}, + onViewProgressClick = {}, + ) + } +} + +@Preview(uiMode = UI_MODE_NIGHT_NO, device = Devices.NEXUS_9) +@Preview(uiMode = UI_MODE_NIGHT_YES, device = Devices.NEXUS_9) +@Composable +private fun CourseHomeScreenTabletPreview() { + OpenEdXTheme { + val previewPagerState = rememberPagerState( + initialPage = 0, + pageCount = { CourseHomePagerTab.entries.size } + ) + CourseHomeUI( + windowSize = WindowSize(WindowType.Medium, WindowType.Medium), + uiState = CourseHomeUIState.CourseData( + courseStructure = Mock.mockCourseStructure, + courseProgress = null, // No course progress for preview + next = null, // No next section for preview + downloadedState = mapOf(), + resumeComponent = Mock.mockChapterBlock, + resumeUnitTitle = "Resumed Unit", + courseSubSections = mapOf(), + subSectionsDownloadsCount = mapOf(), + datesBannerInfo = CourseDatesBannerInfo( + missedDeadlines = false, + missedGatedContent = false, + verifiedUpgradeLink = "", + contentTypeGatingEnabled = false, + hasEnded = false + ), + useRelativeDates = true, + courseVideos = mapOf(), + courseAssignments = emptyList(), + videoPreview = null, + videoProgress = 0f + ), + uiMessage = null, + homePagerState = previewPagerState, + onSubSectionClick = {}, + onResumeClick = {}, + onDownloadClick = {}, + onResetDatesClick = {}, + onCertificateClick = {}, + onVideoClick = {}, + onAssignmentClick = {}, + onNavigateToContent = { _ -> }, + onNavigateToProgress = { }, + getBlockParent = { null }, + onViewAllContentClick = {}, + onViewAllVideosClick = {}, + onViewAllAssignmentsClick = {}, + onViewProgressClick = {}, + ) + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeUIState.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeUIState.kt new file mode 100644 index 000000000..773cb07df --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeUIState.kt @@ -0,0 +1,31 @@ +package org.openedx.course.presentation.home + +import org.openedx.core.domain.model.Block +import org.openedx.core.domain.model.CourseDatesBannerInfo +import org.openedx.core.domain.model.CourseProgress +import org.openedx.core.domain.model.CourseStructure +import org.openedx.core.module.db.DownloadedState +import org.openedx.core.utils.VideoPreview + +sealed class CourseHomeUIState { + data class CourseData( + val courseStructure: CourseStructure, + val courseProgress: CourseProgress?, + val next: Pair?, // section and subsection, nullable + val downloadedState: Map, + val resumeComponent: Block?, + val resumeUnitTitle: String, + val courseSubSections: Map>, + val subSectionsDownloadsCount: Map, + val datesBannerInfo: CourseDatesBannerInfo, + val useRelativeDates: Boolean, + val courseVideos: Map>, + val courseAssignments: List, + val videoPreview: VideoPreview?, + val videoProgress: Float?, + ) : CourseHomeUIState() + + data object Error : CourseHomeUIState() + data object Loading : CourseHomeUIState() + data object Waiting : CourseHomeUIState() +} diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt new file mode 100644 index 000000000..7d1381505 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt @@ -0,0 +1,668 @@ +package org.openedx.course.presentation.home + +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import org.openedx.core.BlockType +import org.openedx.core.R +import org.openedx.core.config.Config +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.helper.VideoPreviewHelper +import org.openedx.core.domain.model.Block +import org.openedx.core.domain.model.CourseComponentStatus +import org.openedx.core.domain.model.CourseDatesBannerInfo +import org.openedx.core.domain.model.CourseProgress +import org.openedx.core.domain.model.CourseStructure +import org.openedx.core.extension.getChapterBlocks +import org.openedx.core.extension.getSequentialBlocks +import org.openedx.core.extension.getVerticalBlocks +import org.openedx.core.extension.safeDivBy +import org.openedx.core.module.DownloadWorkerController +import org.openedx.core.module.db.DownloadDao +import org.openedx.core.module.download.BaseDownloadViewModel +import org.openedx.core.module.download.DownloadHelper +import org.openedx.core.presentation.CoreAnalytics +import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.system.notifier.CourseDatesShifted +import org.openedx.core.system.notifier.CourseNotifier +import org.openedx.core.system.notifier.CourseOpenBlock +import org.openedx.core.system.notifier.CourseProgressLoaded +import org.openedx.core.system.notifier.CourseStructureUpdated +import org.openedx.course.domain.interactor.CourseInteractor +import org.openedx.course.presentation.CourseAnalytics +import org.openedx.course.presentation.CourseAnalyticsEvent +import org.openedx.course.presentation.CourseAnalyticsKey +import org.openedx.course.presentation.CourseRouter +import org.openedx.course.presentation.unit.container.CourseViewMode +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager +import org.openedx.foundation.utils.FileUtil +import org.openedx.course.R as courseR + +class CourseHomeViewModel( + val courseId: String, + private val courseTitle: String, + private val config: Config, + private val interactor: CourseInteractor, + private val resourceManager: ResourceManager, + private val courseNotifier: CourseNotifier, + private val networkConnection: NetworkConnection, + private val preferencesManager: CorePreferences, + private val analytics: CourseAnalytics, + private val downloadDialogManager: DownloadDialogManager, + private val fileUtil: FileUtil, + val courseRouter: CourseRouter, + private val videoPreviewHelper: VideoPreviewHelper, + coreAnalytics: CoreAnalytics, + downloadDao: DownloadDao, + workerController: DownloadWorkerController, + downloadHelper: DownloadHelper, +) : BaseDownloadViewModel( + downloadDao, + preferencesManager, + workerController, + coreAnalytics, + downloadHelper +) { + val isCourseDropdownNavigationEnabled get() = config.getCourseUIConfig().isCourseDropdownNavigationEnabled + + private val _uiState = MutableStateFlow(CourseHomeUIState.Waiting) + val uiState: StateFlow + get() = _uiState.asStateFlow() + + private val _uiMessage = MutableSharedFlow() + val uiMessage: SharedFlow + get() = _uiMessage.asSharedFlow() + + private val _resumeBlockId = MutableSharedFlow() + val resumeBlockId: SharedFlow + get() = _resumeBlockId.asSharedFlow() + + private var resumeSectionBlock: Block? = null + private var resumeVerticalBlock: Block? = null + + private val isCourseExpandableSectionsEnabled get() = config.getCourseUIConfig().isCourseDropdownNavigationEnabled + + private val courseSubSections = mutableMapOf>() + private val subSectionsDownloadsCount = mutableMapOf() + val courseSubSectionUnit = mutableMapOf() + private val courseVideos = mutableMapOf>() + private val courseAssignments = mutableListOf() + + init { + viewModelScope.launch { + courseNotifier.notifier.collect { event -> + when (event) { + is CourseStructureUpdated -> { + if (event.courseId == courseId) { + getCourseData() + } + } + + is CourseOpenBlock -> { + _resumeBlockId.emit(event.blockId) + } + + is CourseProgressLoaded -> { + getCourseProgress() + } + } + } + } + + viewModelScope.launch { + downloadModelsStatusFlow.collect { + if (_uiState.value is CourseHomeUIState.CourseData) { + val state = _uiState.value as CourseHomeUIState.CourseData + _uiState.value = CourseHomeUIState.CourseData( + courseStructure = state.courseStructure, + downloadedState = it.toMap(), + resumeComponent = state.resumeComponent, + resumeUnitTitle = resumeVerticalBlock?.displayName ?: "", + courseSubSections = courseSubSections, + subSectionsDownloadsCount = subSectionsDownloadsCount, + datesBannerInfo = state.datesBannerInfo, + useRelativeDates = preferencesManager.isRelativeDatesEnabled, + next = state.next, + courseProgress = state.courseProgress, + courseVideos = courseVideos, + courseAssignments = courseAssignments, + videoPreview = state.videoPreview, + videoProgress = state.videoProgress + ) + } + } + } + + getCourseData() + } + + override fun saveDownloadModels(folder: String, courseId: String, id: String) { + if (preferencesManager.videoSettings.wifiDownloadOnly) { + if (networkConnection.isWifiConnected()) { + super.saveDownloadModels(folder, courseId, id) + } else { + viewModelScope.launch { + _uiMessage.emit( + UIMessage.ToastMessage( + resourceManager.getString(courseR.string.course_can_download_only_with_wifi) + ) + ) + } + } + } else { + super.saveDownloadModels(folder, courseId, id) + } + } + + fun getCourseData() { + getCourseDataInternal() + } + + private fun getCourseDataInternal() { + viewModelScope.launch { + val courseStructureFlow = interactor.getCourseStructureFlow(courseId, false) + .catch { emit(null) } + val courseStatusFlow = interactor.getCourseStatusFlow(courseId) + val courseDatesFlow = interactor.getCourseDatesFlow(courseId) + val courseProgressFlow = interactor.getCourseProgress(courseId, false, true) + combine( + courseStructureFlow, + courseStatusFlow, + courseDatesFlow, + courseProgressFlow + ) { courseStructure, courseStatus, courseDatesResult, courseProgress -> + if (courseStructure == null) return@combine + val blocks = courseStructure.blockData + val datesBannerInfo = courseDatesResult.courseBanner + + initializeCourseData( + blocks, + courseStructure, + courseStatus, + datesBannerInfo, + courseProgress + ) + }.catch { e -> + handleCourseDataError(e) + }.collect { } + } + } + + private suspend fun initializeCourseData( + blocks: List, + courseStructure: CourseStructure, + courseStatus: CourseComponentStatus, + datesBannerInfo: CourseDatesBannerInfo, + courseProgress: CourseProgress + ) { + setBlocks(blocks) + courseSubSections.clear() + courseSubSectionUnit.clear() + courseVideos.clear() + courseAssignments.clear() + + // Collect all assignments from the original blocks + val allAssignments = blocks + .filter { !it.assignmentProgress?.assignmentType.isNullOrEmpty() } + .filter { it.graded } + .sortedWith( + compareBy { it.due == null } + .thenBy { it.due } + ) + courseAssignments.addAll(allAssignments) + + sortBlocks(blocks) + initDownloadModelsStatus() + val nextSection = findFirstChapterWithIncompleteDescendants(blocks) + + // Get video data + val allVideos = courseVideos.values.flatten() + val firstIncompleteVideo = allVideos.find { !it.isCompleted() } + val videoProgress = if (firstIncompleteVideo != null) { + try { + val videoProgressEntity = interactor.getVideoProgress(firstIncompleteVideo.id) + val videoTime = videoProgressEntity.videoTime?.toFloat() + val videoDuration = videoProgressEntity.duration?.toFloat() + val progress = if (videoTime != null && videoDuration != null) { + videoTime.safeDivBy(videoDuration) + } else { + null + } + progress?.coerceIn(0f, 1f) + } catch (_: Exception) { + 0f + } + } else { + 0f + } + + _uiState.value = CourseHomeUIState.CourseData( + courseStructure = courseStructure, + next = nextSection, + downloadedState = getDownloadModelsStatus(), + resumeComponent = getResumeBlock(blocks, courseStatus.lastVisitedBlockId), + resumeUnitTitle = resumeVerticalBlock?.displayName ?: "", + courseSubSections = courseSubSections, + subSectionsDownloadsCount = subSectionsDownloadsCount, + datesBannerInfo = datesBannerInfo, + useRelativeDates = preferencesManager.isRelativeDatesEnabled, + courseProgress = courseProgress, + courseVideos = courseVideos, + courseAssignments = courseAssignments, + videoPreview = (_uiState.value as? CourseHomeUIState.CourseData)?.videoPreview, + videoProgress = videoProgress + ) + getVideoPreview(firstIncompleteVideo) + } + + private fun getVideoPreview(videoBlock: Block?) { + viewModelScope.launch(Dispatchers.IO) { + val videoPreview = videoBlock?.let { block -> + videoPreviewHelper.getVideoPreview(block, null) + } + _uiState.value = (_uiState.value as? CourseHomeUIState.CourseData) + ?.copy( + videoPreview = videoPreview + ) ?: return@launch + } + } + + private suspend fun handleCourseDataError(e: Throwable?) { + _uiState.value = CourseHomeUIState.Error + val errorMessage = when { + e?.isInternetError() == true -> R.string.core_error_no_connection + else -> R.string.core_error_unknown_error + } + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(errorMessage))) + } + + private fun sortBlocks(blocks: List): List { + if (blocks.isEmpty()) return emptyList() + + val resultBlocks = mutableListOf() + blocks.forEach { block -> + if (block.type == BlockType.CHAPTER) { + resultBlocks.add(block) + processDescendants(block, blocks) + } + } + return resultBlocks + } + + private fun processDescendants(block: Block, blocks: List) { + block.descendants.forEach { descendantId -> + val sequentialBlock = blocks.find { it.id == descendantId } ?: return@forEach + addSequentialBlockToSubSections(block, sequentialBlock) + courseSubSectionUnit[sequentialBlock.id] = + sequentialBlock.getFirstDescendantBlock(blocks) + subSectionsDownloadsCount[sequentialBlock.id] = + sequentialBlock.getDownloadsCount(blocks) + addDownloadableChildrenForSequentialBlock(sequentialBlock) + + // Add video processing logic + val verticalBlocks = blocks.filter { block -> + block.id in sequentialBlock.descendants + } + val videoBlocks = blocks.filter { block -> + verticalBlocks.any { vertical -> block.id in vertical.descendants } && block.type == BlockType.VIDEO + } + addToVideos(block, videoBlocks) + } + } + + private fun addSequentialBlockToSubSections(block: Block, sequentialBlock: Block) { + courseSubSections.getOrPut(block.id) { mutableListOf() }.add(sequentialBlock) + } + + private fun addToVideos(chapterBlock: Block, videoBlocks: List) { + courseVideos.getOrPut(chapterBlock.id) { mutableListOf() }.addAll(videoBlocks) + } + + fun getBlockParent(blockId: String): Block? { + return allBlocks.values.find { blockId in it.descendants } + } + + private fun getResumeBlock( + blocks: List, + continueBlockId: String, + ): Block? { + val resumeBlock = blocks.firstOrNull { it.id == continueBlockId } + resumeVerticalBlock = + blocks.getVerticalBlocks().find { it.descendants.contains(resumeBlock?.id) } + resumeSectionBlock = + blocks.getSequentialBlocks().find { it.descendants.contains(resumeVerticalBlock?.id) } + return resumeBlock + } + + /** + * Finds the first chapter which has incomplete descendants and returns it as a Pair + * where the first Block is the chapter and the second Block is the first incomplete subsection + */ + private fun findFirstChapterWithIncompleteDescendants(blocks: List): Pair? { + val incompleteChapterBlock = blocks.getChapterBlocks().find { !it.isCompleted() } + val incompleteSubsection = incompleteChapterBlock?.let { + findFirstIncompleteSubsection(it, blocks) + } + return if (incompleteChapterBlock != null && incompleteSubsection != null) { + Pair(incompleteChapterBlock, incompleteSubsection) + } else { + null + } + } + + private fun findFirstIncompleteSubsection(chapter: Block, blocks: List): Block? { + // Get all sequential blocks (subsections) in this chapter + val sequentialBlocks = chapter.descendants.mapNotNull { descendantId -> + blocks.find { it.id == descendantId && it.type == BlockType.SEQUENTIAL } + } + return sequentialBlocks.find { !it.isCompleted() } + } + + fun resetCourseDatesBanner(onResetDates: (Boolean) -> Unit) { + viewModelScope.launch { + try { + interactor.resetCourseDates(courseId = courseId) + getCourseData() + courseNotifier.send(CourseDatesShifted) + onResetDates(true) + } 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_dates_shift_dates_unsuccessful_msg) + ) + ) + } + onResetDates(false) + } + } + } + + 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 downloadBlocks(blocksIds: List, fragmentManager: FragmentManager) { + viewModelScope.launch { + val courseData = _uiState.value as? CourseHomeUIState.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, courseId, blockId) + } + ) + } + } + } + + fun getCourseProgress() { + viewModelScope.launch { + if (_uiState.value !is CourseHomeUIState.CourseData) { + _uiState.value = CourseHomeUIState.Loading + } + interactor.getCourseProgress(courseId, false, true) + .catch { e -> + if (_uiState.value !is CourseHomeUIState.CourseData) { + _uiState.value = CourseHomeUIState.Error + } + } + .collectLatest { progress -> + val currentState = _uiState.value + if (currentState is CourseHomeUIState.CourseData) { + _uiState.value = currentState.copy(courseProgress = progress) + } + } + } + } + + fun logVideoClick(blockId: String) { + val currentState = uiState.value + if (currentState is CourseHomeUIState.CourseData) { + analytics.logEvent( + CourseAnalyticsEvent.COURSE_HOME_VIDEO_CLICK.eventName, + buildMap { + put( + CourseAnalyticsKey.NAME.key, + CourseAnalyticsEvent.COURSE_HOME_VIDEO_CLICK.biValue + ) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + put(CourseAnalyticsKey.COURSE_NAME.key, currentState.courseStructure.name) + put(CourseAnalyticsKey.BLOCK_ID.key, blockId) + } + ) + } + } + + fun logAssignmentClick(blockId: String) { + val currentState = uiState.value + if (currentState is CourseHomeUIState.CourseData) { + analytics.logEvent( + CourseAnalyticsEvent.COURSE_HOME_ASSIGNMENT_CLICK.eventName, + buildMap { + put( + CourseAnalyticsKey.NAME.key, + CourseAnalyticsEvent.COURSE_HOME_ASSIGNMENT_CLICK.biValue + ) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + put(CourseAnalyticsKey.COURSE_NAME.key, currentState.courseStructure.name) + put(CourseAnalyticsKey.BLOCK_ID.key, blockId) + } + ) + } + } + + fun viewCertificateTappedEvent() { + analytics.logEvent( + CourseAnalyticsEvent.VIEW_CERTIFICATE.eventName, + buildMap { + put(CourseAnalyticsKey.NAME.key, CourseAnalyticsEvent.VIEW_CERTIFICATE.biValue) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + } + ) + } + + private fun resumeCourseTappedEvent(blockId: String) { + val currentState = uiState.value + if (currentState is CourseHomeUIState.CourseData) { + analytics.logEvent( + CourseAnalyticsEvent.RESUME_COURSE_CLICKED.eventName, + buildMap { + put( + CourseAnalyticsKey.NAME.key, + CourseAnalyticsEvent.RESUME_COURSE_CLICKED.biValue + ) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + put(CourseAnalyticsKey.COURSE_NAME.key, courseTitle) + put(CourseAnalyticsKey.BLOCK_ID.key, blockId) + } + ) + } + } + + fun logSectionSubsectionClick(blockId: String, blockName: String) { + val currentState = uiState.value + if (currentState is CourseHomeUIState.CourseData) { + analytics.logEvent( + CourseAnalyticsEvent.COURSE_HOME_SECTION_SUBSECTION_CLICK.eventName, + buildMap { + put( + CourseAnalyticsKey.NAME.key, + CourseAnalyticsEvent.COURSE_HOME_SECTION_SUBSECTION_CLICK.biValue + ) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + put(CourseAnalyticsKey.COURSE_NAME.key, currentState.courseStructure.name) + put(CourseAnalyticsKey.BLOCK_ID.key, blockId) + put(CourseAnalyticsKey.BLOCK_NAME.key, blockName) + } + ) + } + } + + fun logViewAllContentClick() { + val currentState = uiState.value + if (currentState is CourseHomeUIState.CourseData) { + analytics.logEvent( + CourseAnalyticsEvent.COURSE_HOME_VIEW_ALL_CONTENT.eventName, + buildMap { + put( + CourseAnalyticsKey.NAME.key, + CourseAnalyticsEvent.COURSE_HOME_VIEW_ALL_CONTENT.biValue + ) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + put(CourseAnalyticsKey.COURSE_NAME.key, currentState.courseStructure.name) + } + ) + } + } + + fun logViewAllVideosClick() { + val currentState = uiState.value + if (currentState is CourseHomeUIState.CourseData) { + analytics.logEvent( + CourseAnalyticsEvent.COURSE_HOME_VIEW_ALL_VIDEOS.eventName, + buildMap { + put( + CourseAnalyticsKey.NAME.key, + CourseAnalyticsEvent.COURSE_HOME_VIEW_ALL_VIDEOS.biValue + ) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + put(CourseAnalyticsKey.COURSE_NAME.key, currentState.courseStructure.name) + } + ) + } + } + + fun logViewAllAssignmentsClick() { + val currentState = uiState.value + if (currentState is CourseHomeUIState.CourseData) { + analytics.logEvent( + CourseAnalyticsEvent.COURSE_HOME_VIEW_ALL_ASSIGNMENTS.eventName, + buildMap { + put( + CourseAnalyticsKey.NAME.key, + CourseAnalyticsEvent.COURSE_HOME_VIEW_ALL_ASSIGNMENTS.biValue + ) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + put(CourseAnalyticsKey.COURSE_NAME.key, currentState.courseStructure.name) + } + ) + } + } + + fun logViewProgressClick() { + val currentState = uiState.value + if (currentState is CourseHomeUIState.CourseData) { + analytics.logEvent( + CourseAnalyticsEvent.COURSE_HOME_GRADES_VIEW_PROGRESS.eventName, + buildMap { + put( + CourseAnalyticsKey.NAME.key, + CourseAnalyticsEvent.COURSE_HOME_GRADES_VIEW_PROGRESS.biValue + ) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + put(CourseAnalyticsKey.COURSE_NAME.key, currentState.courseStructure.name) + } + ) + } + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/home/GradesHomePagerCardContent.kt b/course/src/main/java/org/openedx/course/presentation/home/GradesHomePagerCardContent.kt new file mode 100644 index 000000000..962732203 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/home/GradesHomePagerCardContent.kt @@ -0,0 +1,221 @@ +package org.openedx.course.presentation.home + +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.IntrinsicSize +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.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Card +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.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import org.openedx.core.domain.model.CourseProgress +import org.openedx.core.domain.model.CourseStructure +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.course.presentation.contenttab.CourseHomeGradesEmptyState +import org.openedx.course.presentation.progress.CurrentOverallGradeText +import org.openedx.course.presentation.progress.GradeProgressBar +import org.openedx.course.presentation.progress.RequiredGradeMarker + +@Composable +fun GradesHomePagerCardContent( + uiState: CourseHomeUIState.CourseData, + onViewProgressClick: () -> Unit +) { + val courseProgress = uiState.courseProgress + val gradingPolicy = courseProgress?.gradingPolicy + val assignmentPolicies = courseProgress?.getNotEmptyGradingPolicies() + val requiredGradeString = stringResource( + R.string.course_progress_required_grade_percent, + courseProgress?.requiredGradePercent.toString() + ) + + if (courseProgress == null || gradingPolicy == null || assignmentPolicies.isNullOrEmpty()) { + CourseHomeGradesEmptyState() + return + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = stringResource(R.string.course_grades_title), + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textPrimary, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.course_grades_description), + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textPrimaryVariant, + ) + Spacer(modifier = Modifier.height(16.dp)) + CurrentOverallGradeText(progress = courseProgress) + Spacer(modifier = Modifier.height(12.dp)) + Column( + modifier = Modifier + .semantics { + contentDescription = requiredGradeString + } + ) { + GradeProgressBar( + progress = courseProgress, + gradingPolicy = gradingPolicy, + notCompletedWeightedGradePercent = courseProgress.getNotCompletedWeightedGradePercent() + ) + RequiredGradeMarker(progress = courseProgress) + } + Spacer(modifier = Modifier.height(20.dp)) + GradeCardsGrid( + assignmentPolicies = assignmentPolicies, + assignmentColors = gradingPolicy.assignmentColors, + progress = courseProgress, + courseStructure = uiState.courseStructure + ) + Spacer(modifier = Modifier.height(8.dp)) + ViewAllButton( + text = stringResource(R.string.course_view_progress), + onClick = onViewProgressClick, + ) + } +} + +@Composable +private fun GradeCard( + policy: CourseProgress.GradingPolicy.AssignmentPolicy, + progress: CourseProgress, + courseStructure: CourseStructure?, + color: Color, + modifier: Modifier = Modifier +) { + val assignments = progress.getAssignmentSections(policy.type) + val earned = progress.getCompletedAssignmentCount(policy, courseStructure) + val possible = assignments.size + val gradePercent = if (possible > 0) (earned.toFloat() / possible * 100).toInt() else 0 + + Card( + modifier = modifier + .fillMaxWidth() + .semantics(mergeDescendants = true) {}, + backgroundColor = color.copy(alpha = 0.1f), + shape = MaterialTheme.appShapes.material.small, + elevation = 0.dp, + ) { + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .padding(vertical = 10.dp) + ) { + // Assignment type title + Text( + text = policy.type, + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textPrimary, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Grade percentage with colored bar + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min), + ) { + Box( + modifier = Modifier + .width(8.dp) + .fillMaxHeight() + .background( + color = color, + shape = CircleShape + ) + ) + Spacer(modifier = Modifier.width(8.dp)) + Column { + Text( + text = "$gradePercent%", + style = MaterialTheme.appTypography.bodyLarge, + color = MaterialTheme.appColors.textPrimary, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource( + R.string.course_progress_earned_possible_assignment_problems, + earned, + possible + ), + style = MaterialTheme.appTypography.labelSmall, + color = MaterialTheme.appColors.textPrimary, + ) + } + } + } + } +} + +@Composable +private fun GradeCardsGrid( + assignmentPolicies: List, + assignmentColors: List, + progress: CourseProgress, + courseStructure: CourseStructure? +) { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Group policies into rows of 2 + assignmentPolicies.chunked(2).forEach { rowPolicies -> + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.fillMaxWidth() + ) { + rowPolicies.forEachIndexed { index, policy -> + val policyIndex = assignmentPolicies.indexOf(policy) + GradeCard( + modifier = Modifier.weight(1f), + policy = policy, + progress = progress, + courseStructure = courseStructure, + color = if (assignmentColors.isNotEmpty()) { + assignmentColors[policyIndex % assignmentColors.size] + } else { + MaterialTheme.appColors.primary + }, + ) + } + // Fill remaining space if row has only 1 item + if (rowPolicies.size == 1) { + Spacer(modifier = Modifier.weight(1f)) + } + } + } + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/home/VideosHomePagerCardContent.kt b/course/src/main/java/org/openedx/course/presentation/home/VideosHomePagerCardContent.kt new file mode 100644 index 000000000..50c15500c --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/home/VideosHomePagerCardContent.kt @@ -0,0 +1,159 @@ +package org.openedx.course.presentation.home + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Icon +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Videocam +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.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import org.openedx.core.domain.model.Block +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appTypography +import org.openedx.course.R +import org.openedx.course.presentation.contenttab.CourseContentVideoEmptyState +import org.openedx.course.presentation.ui.CourseVideoItem + +@Composable +fun VideosHomePagerCardContent( + uiState: CourseHomeUIState.CourseData, + onVideoClick: (Block) -> Unit, + onViewAllVideosClick: () -> Unit +) { + val allVideos = uiState.courseVideos.values.flatten() + if (allVideos.isEmpty()) { + CourseContentVideoEmptyState( + onReturnToCourseClick = {}, + showReturnButton = false + ) + return + } + + val completedVideos = allVideos.count { it.isCompleted() } + val totalVideos = allVideos.size + val firstIncompleteVideo = allVideos.find { !it.isCompleted() } + val videoProgress = uiState.videoProgress ?: if (firstIncompleteVideo?.isCompleted() ?: false) { + 1f + } else { + 0f + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + // Header with progress + Text( + text = stringResource(R.string.course_container_content_tab_video), + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textPrimary, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.height(12.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = true) {}, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Filled.Videocam, + contentDescription = null, + tint = MaterialTheme.appColors.textPrimary, + modifier = Modifier.size(32.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "$completedVideos/$totalVideos", + style = MaterialTheme.appTypography.displaySmall, + color = MaterialTheme.appColors.textPrimary, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.course_videos_completed), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textPrimaryVariant, + fontWeight = FontWeight.Medium + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Progress bar + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .clip(CircleShape), + progress = if (totalVideos > 0) completedVideos.toFloat() / totalVideos else 0f, + color = MaterialTheme.appColors.progressBarColor, + backgroundColor = MaterialTheme.appColors.progressBarBackgroundColor + ) + + Spacer(modifier = Modifier.height(20.dp)) + + // Continue Watching section + if (firstIncompleteVideo != null) { + val title = if (videoProgress > 0) { + stringResource(R.string.course_continue_watching) + } else { + stringResource(R.string.course_next_video) + } + Text( + text = title, + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textPrimary, + fontWeight = FontWeight.SemiBold + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Video card using CourseVideoItem + CourseVideoItem( + modifier = Modifier + .fillMaxWidth() + .height(180.dp), + videoBlock = firstIncompleteVideo, + preview = uiState.videoPreview, + progress = videoProgress, + onClick = { + onVideoClick(firstIncompleteVideo) + }, + titleStyle = MaterialTheme.appTypography.titleMedium, + contentModifier = Modifier + .padding(horizontal = 16.dp, vertical = 8.dp), + progressModifier = Modifier.height(8.dp), + ) + } else { + CaughtUpMessage( + message = stringResource(R.string.course_videos_caught_up) + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // View All Videos button + ViewAllButton( + text = stringResource(R.string.course_view_all_videos), + onClick = onViewAllVideosClick + ) + } +} 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..0356b0164 --- /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.R +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.foundation.extension.toFileSize +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue + +@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.core_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.core_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.core_cancel_course_download), + backgroundColor = MaterialTheme.appColors.background, + borderColor = MaterialTheme.appColors.error, + textColor = MaterialTheme.appColors.error, + onClick = onCancelDownloadClick, + content = { + IconText( + text = stringResource(R.string.core_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(R.string.core_edit) + } else { + stringResource(R.string.core_label_done) + } + + LaunchedEffect(isDownloading) { + if (isDownloading) { + isEditingEnabled = false + } + } + + Column { + Row { + Text( + modifier = Modifier.weight(1f), + text = stringResource(R.string.core_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.core_remove_all_downloads), + backgroundColor = MaterialTheme.appColors.background, + borderColor = MaterialTheme.appColors.error, + textColor = MaterialTheme.appColors.error, + onClick = onDeleteAllClick, + content = { + IconText( + text = stringResource(R.string.core_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.core_downloaded), + icon = Icons.Default.CloudDone, + color = MaterialTheme.appColors.successGreen, + textStyle = MaterialTheme.appTypography.labelLarge + ) + if (!uiState.isDownloading) { + IconText( + text = stringResource(R.string.core_ready_to_download), + icon = Icons.Outlined.CloudDownload, + color = MaterialTheme.appColors.textDark, + textStyle = MaterialTheme.appTypography.labelLarge + ) + } else { + IconText( + text = stringResource(R.string.core_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.core_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.core_0mb), + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textFieldHint + ) + Spacer(modifier = Modifier.height(4.dp)) + IconText( + text = stringResource(R.string.core_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.core_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..620b79012 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt @@ -0,0 +1,239 @@ +package org.openedx.course.presentation.offline + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.InsertDriveFile +import androidx.compose.material.icons.outlined.SmartDisplay +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.openedx.core.BlockType +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.Block +import org.openedx.core.extension.safeDivBy +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.presentation.dialog.downloaddialog.DownloadDialogItem +import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.system.notifier.CourseNotifier +import org.openedx.core.system.notifier.CourseStructureGot +import org.openedx.course.domain.interactor.CourseInteractor +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, + private val courseNotifier: CourseNotifier, + coreAnalytics: CoreAnalytics, + downloadDao: DownloadDao, + workerController: DownloadWorkerController, + downloadHelper: DownloadHelper, +) : BaseDownloadViewModel( + 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) } + } + } + collectCourseNotifier() + } + + 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, courseId, 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, + ) + 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).toFloat() + val realDownloadedSize = completedDownloads.sumOf { it.size } + val largestDownloads = completedDownloads + .sortedByDescending { it.size } + .take(n = 5) + val progressBarValue = downloadedSize.safeDivBy(totalDownloadableSize.toFloat()) + val readyToDownloadSize = if (progressBarValue >= 1) { + 0 + } else { + totalDownloadableSize - realDownloadedSize + } + _uiState.update { + it.copy( + isHaveDownloadableBlocks = true, + largestDownloads = largestDownloads, + readyToDownloadSize = readyToDownloadSize.toFileSize(1, false), + downloadedSize = realDownloadedSize.toFileSize(1, false), + progressBarValue = progressBarValue + ) + } + } + + 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 + } + } + } + + private fun collectCourseNotifier() { + viewModelScope.launch { + courseNotifier.notifier.collect { event -> + when (event) { + is CourseStructureGot -> { + async { initDownloadFragment() }.await() + getOfflineData() + } + } + } + } + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllScreen.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllScreen.kt new file mode 100644 index 000000000..e8355387b --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllScreen.kt @@ -0,0 +1,431 @@ +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.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.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.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.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.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.BlockType +import org.openedx.core.Mock +import org.openedx.core.domain.model.Block +import org.openedx.core.domain.model.CourseDatesBannerInfo +import org.openedx.core.domain.model.Progress +import org.openedx.core.extension.getChapterBlocks +import org.openedx.core.ui.CircularProgress +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.course.R +import org.openedx.course.presentation.contenttab.CourseContentAllEmptyState +import org.openedx.course.presentation.ui.CourseDatesBanner +import org.openedx.course.presentation.ui.CourseDatesBannerTablet +import org.openedx.course.presentation.ui.CourseMessage +import org.openedx.course.presentation.ui.CourseProgress +import org.openedx.course.presentation.ui.CourseSection +import org.openedx.course.presentation.ui.ResumeCourseButton +import org.openedx.course.presentation.unit.container.CourseViewMode +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 + +@Composable +fun CourseContentAllScreen( + windowSize: WindowSize, + viewModel: CourseContentAllViewModel, + fragmentManager: FragmentManager, + onNavigateToHome: () -> Unit = {}, +) { + 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) + } + } + + CourseContentAllUI( + windowSize = windowSize, + uiState = uiState, + uiMessage = uiMessage, + onNavigateToHome = onNavigateToHome, + onExpandClick = { block -> + if (viewModel.switchCourseSections(block.id)) { + viewModel.sequentialClickedEvent( + block.blockId, + block.displayName + ) + } + }, + onSubSectionClick = { subSectionBlock -> + 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 + ) + viewModel.courseRouter.navigateToCourseSubsections( + fm = fragmentManager, + courseId = viewModel.courseId, + subSectionId = subSectionBlock.id, + mode = CourseViewMode.FULL + ) + } + }, + onResumeClick = { componentId -> + viewModel.openBlock( + fragmentManager, + componentId + ) + }, + onDownloadClick = { blocksIds -> + viewModel.downloadBlocks( + blocksIds = blocksIds, + fragmentManager = fragmentManager, + ) + }, + onResetDatesClick = { + viewModel.resetCourseDatesBanner() + }, + onCertificateClick = { + viewModel.viewCertificateTappedEvent() + it.takeIfNotEmpty() + ?.let { url -> AndroidUriHandler(context).openUri(url) } + } + ) +} + +@Composable +private fun CourseContentAllUI( + windowSize: WindowSize, + uiState: CourseContentAllUIState, + uiMessage: UIMessage?, + onNavigateToHome: () -> Unit, + onExpandClick: (Block) -> Unit, + onSubSectionClick: (Block) -> Unit, + onResumeClick: (String) -> Unit, + onDownloadClick: (blockIds: List) -> Unit, + onResetDatesClick: () -> Unit, + onCertificateClick: (String) -> Unit, +) { + val scaffoldState = rememberScaffoldState() + + Scaffold( + modifier = Modifier + .fillMaxSize(), + 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() + ) + ) + } + + val listBottomPadding by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = PaddingValues(bottom = 24.dp), + compact = PaddingValues(bottom = 24.dp) + ) + ) + } + + val listPadding by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.padding(horizontal = 6.dp), + compact = Modifier.padding(horizontal = 24.dp) + ) + ) + } + + HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) + + Box( + modifier = Modifier + .fillMaxSize() + .padding(it) + .displayCutoutForLandscape(), + contentAlignment = Alignment.TopCenter + ) { + Surface( + modifier = screenWidth, + color = MaterialTheme.appColors.background + ) { + Box { + when (uiState) { + is CourseContentAllUIState.CourseData -> { + if (uiState.courseStructure.blockData.isEmpty()) { + CourseContentAllEmptyState( + onReturnToCourseClick = onNavigateToHome + ) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = listBottomPadding + ) { + if (uiState.datesBannerInfo.isBannerAvailableForDashboard()) { + item { + Box( + modifier = Modifier + .padding(all = 8.dp) + ) { + if (windowSize.isTablet) { + CourseDatesBannerTablet( + banner = uiState.datesBannerInfo, + resetDates = onResetDatesClick, + ) + } else { + CourseDatesBanner( + banner = uiState.datesBannerInfo, + resetDates = onResetDatesClick, + ) + } + } + } + } + + val certificate = uiState.courseStructure.certificate + if (certificate?.isCertificateEarned() == true) { + item { + CourseMessage( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp) + .then(listPadding), + icon = painterResource(R.drawable.course_ic_certificate), + message = stringResource( + R.string.course_you_earned_certificate, + uiState.courseStructure.name + ), + action = stringResource(R.string.course_view_certificate), + onActionClick = { + onCertificateClick( + certificate.certificateURL ?: "" + ) + } + ) + } + } + + val sections = + uiState.courseStructure.blockData.getChapterBlocks() + val progress = Progress( + total = sections.size, + completed = sections.filter { it.isCompleted() }.size + ) + item { + CourseProgress( + modifier = Modifier + .fillMaxWidth() + .padding( + start = 24.dp, + end = 24.dp + ), + progress = progress, + description = pluralStringResource( + R.plurals.course_sections_complete, + progress.completed, + progress.completed, + progress.total + ) + ) + } + + if (uiState.resumeComponent != null) { + item { + Box(listPadding) { + ResumeCourseButton( + modifier = Modifier.padding(vertical = 16.dp), + block = uiState.resumeComponent, + displayName = uiState.resumeUnitTitle, + onResumeClick = onResumeClick + ) + } + } + } + + item { + Spacer(modifier = Modifier.height(12.dp)) + } + uiState.courseStructure.blockData.forEach { section -> + val courseSubSections = + uiState.courseSubSections[section.id] + val courseSectionsState = + uiState.courseSectionsState[section.id] + + item { + CourseSection( + modifier = listPadding.padding(vertical = 4.dp), + section = section, + onItemClick = onExpandClick, + useRelativeDates = uiState.useRelativeDates, + isSectionVisible = courseSectionsState, + subSections = courseSubSections, + downloadedStateMap = uiState.downloadedState, + onSubSectionClick = onSubSectionClick, + onDownloadClick = onDownloadClick + ) + } + } + } + } + } + + CourseContentAllUIState.Error -> { + CourseContentAllEmptyState( + modifier = Modifier.verticalScroll(rememberScrollState()), + onReturnToCourseClick = onNavigateToHome + ) + } + + CourseContentAllUIState.Loading -> { + CircularProgress() + } + } + } + } + } + } +} + +fun getUnitBlockIcon(block: Block): Int { + return when (block.type) { + BlockType.VIDEO -> R.drawable.course_ic_video + BlockType.PROBLEM -> R.drawable.course_ic_pen + BlockType.DISCUSSION -> R.drawable.course_ic_discussion + else -> R.drawable.course_ic_block + } +} + +@Preview(uiMode = UI_MODE_NIGHT_NO) +@Preview(uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun CourseOutlineScreenPreview() { + OpenEdXTheme { + CourseContentAllUI( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + uiState = CourseContentAllUIState.CourseData( + Mock.mockCourseStructure, + mapOf(), + Mock.mockChapterBlock, + "Resumed Unit", + mapOf(), + mapOf(), + mapOf(), + CourseDatesBannerInfo( + missedDeadlines = false, + missedGatedContent = false, + verifiedUpgradeLink = "", + contentTypeGatingEnabled = false, + hasEnded = false + ), + true + ), + uiMessage = null, + onExpandClick = {}, + onSubSectionClick = {}, + onResumeClick = {}, + onDownloadClick = {}, + onResetDatesClick = {}, + onCertificateClick = {}, + onNavigateToHome = {}, + ) + } +} + +@Preview(uiMode = UI_MODE_NIGHT_NO, device = Devices.NEXUS_9) +@Preview(uiMode = UI_MODE_NIGHT_YES, device = Devices.NEXUS_9) +@Composable +private fun CourseContentAllScreenTabletPreview() { + OpenEdXTheme { + CourseContentAllUI( + windowSize = WindowSize(WindowType.Medium, WindowType.Medium), + uiState = CourseContentAllUIState.CourseData( + Mock.mockCourseStructure, + mapOf(), + Mock.mockChapterBlock, + "Resumed Unit", + mapOf(), + mapOf(), + mapOf(), + CourseDatesBannerInfo( + missedDeadlines = false, + missedGatedContent = false, + verifiedUpgradeLink = "", + contentTypeGatingEnabled = false, + hasEnded = false + ), + true + ), + uiMessage = null, + onExpandClick = {}, + onSubSectionClick = {}, + onResumeClick = {}, + onDownloadClick = {}, + onResetDatesClick = {}, + onCertificateClick = {}, + onNavigateToHome = {}, + ) + } +} + +@Preview(uiMode = UI_MODE_NIGHT_NO) +@Preview(uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun ResumeCoursePreview() { + OpenEdXTheme { + ResumeCourseButton(block = Mock.mockChapterBlock, displayName = "Resumed Unit") {} + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineUIState.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllUIState.kt similarity index 72% rename from course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineUIState.kt rename to course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllUIState.kt index 0307b1f8e..9a2deed32 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineUIState.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllUIState.kt @@ -5,16 +5,19 @@ import org.openedx.core.domain.model.CourseDatesBannerInfo import org.openedx.core.domain.model.CourseStructure import org.openedx.core.module.db.DownloadedState -sealed class CourseOutlineUIState { +sealed class CourseContentAllUIState { data class CourseData( val courseStructure: CourseStructure, val downloadedState: Map, val resumeComponent: Block?, + val resumeUnitTitle: String, val courseSubSections: Map>, val courseSectionsState: Map, val subSectionsDownloadsCount: Map, val datesBannerInfo: CourseDatesBannerInfo, - ) : CourseOutlineUIState() + val useRelativeDates: Boolean, + ) : CourseContentAllUIState() - data object Loading : CourseOutlineUIState() + data object Error : CourseContentAllUIState() + data object Loading : CourseContentAllUIState() } diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllViewModel.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllViewModel.kt new file mode 100644 index 000000000..d373467a0 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllViewModel.kt @@ -0,0 +1,512 @@ +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 +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import org.openedx.core.BlockType +import org.openedx.core.R +import org.openedx.core.config.Config +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.Block +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.CourseStructure +import org.openedx.core.extension.getChapterBlocks +import org.openedx.core.extension.getSequentialBlocks +import org.openedx.core.extension.getVerticalBlocks +import org.openedx.core.module.DownloadWorkerController +import org.openedx.core.module.db.DownloadDao +import org.openedx.core.module.download.BaseDownloadViewModel +import org.openedx.core.module.download.DownloadHelper +import org.openedx.core.presentation.CoreAnalytics +import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager +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.CourseDatesShifted +import org.openedx.core.system.notifier.CourseNotifier +import org.openedx.core.system.notifier.CourseOpenBlock +import org.openedx.core.system.notifier.CourseStructureUpdated +import org.openedx.course.domain.interactor.CourseInteractor +import org.openedx.course.presentation.CourseAnalytics +import org.openedx.course.presentation.CourseAnalyticsEvent +import org.openedx.course.presentation.CourseAnalyticsKey +import org.openedx.course.presentation.CourseRouter +import org.openedx.course.presentation.unit.container.CourseViewMode +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager +import org.openedx.foundation.utils.FileUtil +import org.openedx.course.R as courseR + +class CourseContentAllViewModel( + val courseId: String, + private val courseTitle: String, + private val config: Config, + private val interactor: CourseInteractor, + private val resourceManager: ResourceManager, + private val courseNotifier: CourseNotifier, + 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( + downloadDao, + preferencesManager, + workerController, + coreAnalytics, + downloadHelper +) { + val isCourseDropdownNavigationEnabled get() = config.getCourseUIConfig().isCourseDropdownNavigationEnabled + + private val _uiState = + MutableStateFlow(CourseContentAllUIState.Loading) + val uiState: StateFlow + get() = _uiState.asStateFlow() + + private val _uiMessage = MutableSharedFlow() + val uiMessage: SharedFlow + get() = _uiMessage.asSharedFlow() + + private val _resumeBlockId = MutableSharedFlow() + val resumeBlockId: SharedFlow + get() = _resumeBlockId.asSharedFlow() + + private var resumeSectionBlock: Block? = null + private var resumeVerticalBlock: Block? = null + + private val isCourseExpandableSectionsEnabled get() = config.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) { + is CourseStructureUpdated -> { + if (event.courseId == courseId) { + getCourseData() + } + } + + is CourseOpenBlock -> { + _resumeBlockId.emit(event.blockId) + } + } + } + } + + viewModelScope.launch { + downloadModelsStatusFlow.collect { + if (_uiState.value is CourseContentAllUIState.CourseData) { + val state = _uiState.value as CourseContentAllUIState.CourseData + _uiState.value = CourseContentAllUIState.CourseData( + 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, courseId: String, id: String) { + if (preferencesManager.videoSettings.wifiDownloadOnly) { + if (networkConnection.isWifiConnected()) { + super.saveDownloadModels(folder, courseId, id) + } else { + viewModelScope.launch { + _uiMessage.emit( + UIMessage.ToastMessage( + resourceManager.getString(courseR.string.course_can_download_only_with_wifi) + ) + ) + } + } + } else { + super.saveDownloadModels(folder, courseId, id) + } + } + + fun getCourseData() { + getCourseDataInternal() + } + + fun switchCourseSections(blockId: String): Boolean { + return if (_uiState.value is CourseContentAllUIState.CourseData) { + val state = _uiState.value as CourseContentAllUIState.CourseData + val courseSectionsState = state.courseSectionsState.toMutableMap() + courseSectionsState[blockId] = !(state.courseSectionsState[blockId] ?: false) + + _uiState.value = CourseContentAllUIState.CourseData( + 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 + } else { + false + } + } + + private fun getCourseDataInternal() { + viewModelScope.launch { + val courseStructureFlow = interactor.getCourseStructureFlow(courseId, false) + .catch { emit(null) } + val courseStatusFlow = interactor.getCourseStatusFlow(courseId) + val courseDatesFlow = interactor.getCourseDatesFlow(courseId) + combine( + courseStructureFlow, + courseStatusFlow, + courseDatesFlow + ) { courseStructure, courseStatus, courseDatesResult -> + Triple(courseStructure, courseStatus, courseDatesResult) + }.catch { e -> + handleCourseDataError(e) + }.collect { (courseStructure, courseStatus, courseDates) -> + if (courseStructure == null) return@collect + val blocks = courseStructure.blockData + val datesBannerInfo = courseDates.courseBanner + + checkIfCalendarOutOfDate(courseDates.datesSection.values.flatten()) + updateOutdatedOfflineXBlocks(courseStructure) + + initializeCourseData(blocks, courseStructure, courseStatus, datesBannerInfo) + } + } + } + + private suspend fun initializeCourseData( + blocks: List, + courseStructure: CourseStructure, + courseStatus: CourseComponentStatus, + datesBannerInfo: CourseDatesBannerInfo + ) { + setBlocks(blocks) + courseSubSections.clear() + courseSubSectionUnit.clear() + val sortedStructure = courseStructure.copy(blockData = sortBlocks(blocks)) + initDownloadModelsStatus() + + val courseSectionsState = + (_uiState.value as? CourseContentAllUIState.CourseData)?.courseSectionsState + ?: blocks.getChapterBlocks().associate { it.id to !it.isCompleted() } + + _uiState.value = CourseContentAllUIState.CourseData( + courseStructure = sortedStructure, + downloadedState = getDownloadModelsStatus(), + resumeComponent = getResumeBlock(blocks, courseStatus.lastVisitedBlockId), + resumeUnitTitle = resumeVerticalBlock?.displayName ?: "", + courseSubSections = courseSubSections, + courseSectionsState = courseSectionsState, + subSectionsDownloadsCount = subSectionsDownloadsCount, + datesBannerInfo = datesBannerInfo, + useRelativeDates = preferencesManager.isRelativeDatesEnabled + ) + } + + private suspend fun handleCourseDataError(e: Throwable?) { + _uiState.value = CourseContentAllUIState.Error + val errorMessage = when { + e?.isInternetError() == true -> R.string.core_error_no_connection + else -> R.string.core_error_unknown_error + } + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(errorMessage))) + } + + private fun sortBlocks(blocks: List): List { + if (blocks.isEmpty()) return emptyList() + + val resultBlocks = mutableListOf() + blocks.forEach { block -> + if (block.type == BlockType.CHAPTER) { + resultBlocks.add(block) + processDescendants(block, blocks) + } + } + return resultBlocks + } + + private fun processDescendants(block: Block, blocks: List) { + block.descendants.forEach { descendantId -> + val sequentialBlock = blocks.find { it.id == descendantId } ?: return@forEach + addSequentialBlockToSubSections(block, sequentialBlock) + courseSubSectionUnit[sequentialBlock.id] = + sequentialBlock.getFirstDescendantBlock(blocks) + subSectionsDownloadsCount[sequentialBlock.id] = + sequentialBlock.getDownloadsCount(blocks) + addDownloadableChildrenForSequentialBlock(sequentialBlock) + } + } + + private fun addSequentialBlockToSubSections(block: Block, sequentialBlock: Block) { + courseSubSections.getOrPut(block.id) { mutableListOf() }.add(sequentialBlock) + } + + private fun getResumeBlock( + blocks: List, + continueBlockId: String, + ): Block? { + val resumeBlock = blocks.firstOrNull { it.id == continueBlockId } + resumeVerticalBlock = + blocks.getVerticalBlocks().find { it.descendants.contains(resumeBlock?.id) } + resumeSectionBlock = + blocks.getSequentialBlocks().find { it.descendants.contains(resumeVerticalBlock?.id) } + return resumeBlock + } + + fun resetCourseDatesBanner() { + viewModelScope.launch { + try { + interactor.resetCourseDates(courseId = courseId) + getCourseData() + courseNotifier.send(CourseDatesShifted) + } 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_dates_shift_dates_unsuccessful_msg) + ) + ) + } + } + } + } + + 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, + buildMap { + put(CourseAnalyticsKey.NAME.key, CourseAnalyticsEvent.VIEW_CERTIFICATE.biValue) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + } + ) + } + + private fun resumeCourseTappedEvent(blockId: String) { + val currentState = uiState.value + if (currentState is CourseContentAllUIState.CourseData) { + analytics.logEvent( + CourseAnalyticsEvent.RESUME_COURSE_CLICKED.eventName, + buildMap { + put( + CourseAnalyticsKey.NAME.key, + CourseAnalyticsEvent.RESUME_COURSE_CLICKED.biValue + ) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + put(CourseAnalyticsKey.COURSE_NAME.key, courseTitle) + put(CourseAnalyticsKey.BLOCK_ID.key, blockId) + } + ) + } + } + + fun sequentialClickedEvent(blockId: String, blockName: String) { + val currentState = uiState.value + if (currentState is CourseContentAllUIState.CourseData) { + analytics.sequentialClickedEvent( + courseId, + currentState.courseStructure.name, + blockId, + blockName + ) + } + } + + fun logUnitDetailViewedEvent(blockId: String, blockName: String) { + val currentState = uiState.value + if (currentState is CourseContentAllUIState.CourseData) { + analytics.logEvent( + CourseAnalyticsEvent.UNIT_DETAIL.eventName, + buildMap { + put(CourseAnalyticsKey.NAME.key, CourseAnalyticsEvent.UNIT_DETAIL.biValue) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + put(CourseAnalyticsKey.COURSE_NAME.key, courseTitle) + put(CourseAnalyticsKey.BLOCK_ID.key, blockId) + put(CourseAnalyticsKey.BLOCK_NAME.key, blockName) + } + ) + } + } + + private fun checkIfCalendarOutOfDate(courseDates: List) { + viewModelScope.launch { + courseNotifier.send( + CreateCalendarSyncEvent( + courseDates = courseDates, + dialogType = CalendarSyncDialogType.NONE.name, + checkOutOfSync = true, + ) + ) + } + } + + fun downloadBlocks(blocksIds: List, fragmentManager: FragmentManager) { + viewModelScope.launch { + val courseData = _uiState.value as? CourseContentAllUIState.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, courseId, 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, + courseId, + outdatedBlockIds + ) + } + isOfflineBlocksUpToDate = true + } + } + } +} 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 deleted file mode 100644 index 7e950cba8..000000000 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt +++ /dev/null @@ -1,632 +0,0 @@ -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 -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.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.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.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.platform.AndroidUriHandler -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow -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.BlockType -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.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.presentation.course.CourseViewMode -import org.openedx.core.ui.HandleUIMessage -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 java.util.Date -import org.openedx.core.R as CoreR - -@Composable -fun CourseOutlineScreen( - windowSize: WindowSize, - courseOutlineViewModel: CourseOutlineViewModel, - courseRouter: CourseRouter, - fragmentManager: FragmentManager, - onResetDatesClick: () -> Unit -) { - val uiState by courseOutlineViewModel.uiState.collectAsState() - val uiMessage by courseOutlineViewModel.uiMessage.collectAsState(null) - val context = LocalContext.current - - CourseOutlineUI( - windowSize = windowSize, - uiState = uiState, - isCourseNestedListEnabled = courseOutlineViewModel.isCourseNestedListEnabled, - uiMessage = uiMessage, - onItemClick = { block -> - courseOutlineViewModel.sequentialClickedEvent( - block.blockId, - block.displayName - ) - courseRouter.navigateToCourseSubsections( - fm = fragmentManager, - courseId = courseOutlineViewModel.courseId, - subSectionId = block.id, - mode = CourseViewMode.FULL - ) - }, - onExpandClick = { block -> - if (courseOutlineViewModel.switchCourseSections(block.id)) { - courseOutlineViewModel.sequentialClickedEvent( - block.blockId, - block.displayName - ) - } - }, - onSubSectionClick = { subSectionBlock -> - courseOutlineViewModel.courseSubSectionUnit[subSectionBlock.id]?.let { unit -> - courseOutlineViewModel.logUnitDetailViewedEvent( - unit.blockId, - unit.displayName - ) - courseRouter.navigateToCourseContainer( - fragmentManager, - courseId = courseOutlineViewModel.courseId, - unitId = unit.id, - mode = CourseViewMode.FULL - ) - } - }, - onResumeClick = { componentId -> - courseOutlineViewModel.resumeSectionBlock?.let { subSection -> - courseOutlineViewModel.resumeCourseTappedEvent(subSection.id) - courseOutlineViewModel.resumeVerticalBlock?.let { unit -> - if (courseOutlineViewModel.isCourseExpandableSectionsEnabled) { - courseRouter.navigateToCourseContainer( - fm = fragmentManager, - courseId = courseOutlineViewModel.courseId, - unitId = unit.id, - componentId = componentId, - mode = CourseViewMode.FULL - ) - } else { - courseRouter.navigateToCourseSubsections( - fragmentManager, - courseId = courseOutlineViewModel.courseId, - subSectionId = subSection.id, - mode = CourseViewMode.FULL, - unitId = unit.id, - componentId = componentId - ) - } - } - } - }, - onDownloadClick = { - if (courseOutlineViewModel.isBlockDownloading(it.id)) { - courseRouter.navigateToDownloadQueue( - fm = fragmentManager, - courseOutlineViewModel.getDownloadableChildren(it.id) - ?: arrayListOf() - ) - } else if (courseOutlineViewModel.isBlockDownloaded(it.id)) { - courseOutlineViewModel.removeDownloadModels(it.id) - } else { - courseOutlineViewModel.saveDownloadModels( - context.externalCacheDir.toString() + - File.separator + - context - .getString(CoreR.string.app_name) - .replace(Regex("\\s"), "_"), it.id - ) - } - }, - onResetDatesClick = { - courseOutlineViewModel.resetCourseDatesBanner( - onResetDates = { - onResetDatesClick() - } - ) - }, - onCertificateClick = { - courseOutlineViewModel.viewCertificateTappedEvent() - it.takeIfNotEmpty() - ?.let { url -> AndroidUriHandler(context).openUri(url) } - } - ) -} - -@Composable -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, - onResetDatesClick: () -> Unit, - onCertificateClick: (String) -> Unit, -) { - val scaffoldState = rememberScaffoldState() - - Scaffold( - modifier = Modifier - .fillMaxSize(), - 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() - ) - ) - } - - val listBottomPadding by remember(key1 = windowSize) { - mutableStateOf( - windowSize.windowSizeValue( - expanded = PaddingValues(bottom = 24.dp), - compact = PaddingValues(bottom = 24.dp) - ) - ) - } - - val listPadding by remember(key1 = windowSize) { - mutableStateOf( - windowSize.windowSizeValue( - expanded = Modifier.padding(horizontal = 6.dp), - compact = Modifier.padding(horizontal = 24.dp) - ) - ) - } - - HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) - - Box( - modifier = Modifier - .fillMaxSize() - .padding(it) - .displayCutoutForLandscape(), - contentAlignment = Alignment.TopCenter - ) { - Surface( - modifier = screenWidth, - color = MaterialTheme.appColors.background - ) { - 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.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 - ) - } - } - } - } - - if (isCourseNestedListEnabled) { - uiState.courseStructure.blockData.forEach { section -> - val courseSubSections = - uiState.courseSubSections[section.id] - val courseSectionsState = - uiState.courseSectionsState[section.id] - - item { - Column { - CourseExpandableChapterCard( - modifier = listPadding, - block = section, - onItemClick = onExpandClick, - arrowDegrees = if (courseSectionsState == true) -90f else 90f - ) - Divider() - } - } - - courseSubSections?.forEach { subSectionBlock -> - item { - Column { - AnimatedVisibility( - visible = courseSectionsState == true - ) { - Column { - val downloadsCount = - uiState.subSectionsDownloadsCount[subSectionBlock.id] - ?: 0 - - CourseSubSectionItem( - modifier = listPadding, - block = subSectionBlock, - downloadedState = uiState.downloadedState[subSectionBlock.id], - downloadsCount = downloadsCount, - onClick = onSubSectionClick, - onDownloadClick = onDownloadClick - ) - Divider() - } - } - } - } - } - } - return@LazyColumn - } - - items(uiState.courseStructure.blockData) { block -> - Column(listPadding) { - if (block.type == BlockType.CHAPTER) { - Text( - modifier = Modifier.padding( - top = 36.dp, - bottom = 8.dp - ), - text = block.displayName, - style = MaterialTheme.appTypography.titleMedium, - color = MaterialTheme.appColors.textPrimaryVariant - ) - } else { - CourseSectionCard( - block = block, - downloadedState = uiState.downloadedState[block.id], - onItemClick = onItemClick, - onDownloadClick = onDownloadClick - ) - Divider() - } - } - } - } - } - - CourseOutlineUIState.Loading -> {} - } - } - } - } - } -} - -@Composable -private fun ResumeCourse( - modifier: Modifier = Modifier, - block: Block, - onResumeClick: (String) -> Unit, -) { - Column( - modifier = modifier.fillMaxWidth() - ) { - Text( - text = stringResource(id = R.string.course_continue_with), - style = MaterialTheme.appTypography.labelMedium, - color = MaterialTheme.appColors.textPrimaryVariant - ) - Spacer(Modifier.height(6.dp)) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - Icon( - modifier = Modifier.size(24.dp), - painter = painterResource(id = getUnitBlockIcon(block)), - contentDescription = null, - tint = MaterialTheme.appColors.textPrimary - ) - Text( - text = block.displayName, - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.titleMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - Spacer(Modifier.height(24.dp)) - OpenEdXButton( - text = stringResource(id = R.string.course_resume), - onClick = { - onResumeClick(block.id) - }, - content = { - TextIcon( - text = stringResource(id = R.string.course_resume), - painter = painterResource(id = CoreR.drawable.core_ic_forward), - color = MaterialTheme.appColors.buttonText, - textStyle = MaterialTheme.appTypography.labelLarge - ) - } - ) - } -} - - -@Composable -private fun ResumeCourseTablet( - modifier: Modifier = Modifier, - block: Block, - onResumeClick: (String) -> Unit, -) { - Row( - modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Column( - Modifier - .weight(1f) - .padding(end = 35.dp) - ) { - Text( - text = stringResource(id = R.string.course_continue_with), - style = MaterialTheme.appTypography.labelMedium, - color = MaterialTheme.appColors.textPrimaryVariant - ) - Spacer(Modifier.height(6.dp)) - Row( - verticalAlignment = Alignment.Top, - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - Icon( - modifier = Modifier.size((MaterialTheme.appTypography.titleMedium.fontSize.value + 4).dp), - painter = painterResource(id = getUnitBlockIcon(block)), - contentDescription = null, - tint = MaterialTheme.appColors.textPrimary - ) - Text( - text = block.displayName, - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.titleMedium, - overflow = TextOverflow.Ellipsis, - maxLines = 4 - ) - } - } - OpenEdXButton( - modifier = Modifier.width(210.dp), - text = stringResource(id = R.string.course_resume), - onClick = { - onResumeClick(block.id) - }, - content = { - TextIcon( - text = stringResource(id = R.string.course_resume), - painter = painterResource(id = CoreR.drawable.core_ic_forward), - color = MaterialTheme.appColors.buttonText, - textStyle = MaterialTheme.appTypography.labelLarge - ) - } - ) - } -} - -fun getUnitBlockIcon(block: Block): Int { - return when (block.type) { - BlockType.VIDEO -> R.drawable.ic_course_video - BlockType.PROBLEM -> R.drawable.ic_course_pen - BlockType.DISCUSSION -> R.drawable.ic_course_discussion - else -> R.drawable.ic_course_block - } -} - -@Preview(uiMode = UI_MODE_NIGHT_NO) -@Preview(uiMode = UI_MODE_NIGHT_YES) -@Composable -private fun CourseOutlineScreenPreview() { - OpenEdXTheme { - CourseOutlineUI( - windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - uiState = CourseOutlineUIState.CourseData( - mockCourseStructure, - mapOf(), - mockChapterBlock, - mapOf(), - mapOf(), - mapOf(), - CourseDatesBannerInfo( - missedDeadlines = false, - missedGatedContent = false, - verifiedUpgradeLink = "", - contentTypeGatingEnabled = false, - hasEnded = false - ) - ), - isCourseNestedListEnabled = true, - uiMessage = null, - onItemClick = {}, - onExpandClick = {}, - onSubSectionClick = {}, - onResumeClick = {}, - onDownloadClick = {}, - onResetDatesClick = {}, - onCertificateClick = {}, - ) - } -} - -@Preview(uiMode = UI_MODE_NIGHT_NO, device = Devices.NEXUS_9) -@Preview(uiMode = UI_MODE_NIGHT_YES, device = Devices.NEXUS_9) -@Composable -private fun CourseOutlineScreenTabletPreview() { - OpenEdXTheme { - CourseOutlineUI( - windowSize = WindowSize(WindowType.Medium, WindowType.Medium), - uiState = CourseOutlineUIState.CourseData( - mockCourseStructure, - mapOf(), - mockChapterBlock, - mapOf(), - mapOf(), - mapOf(), - CourseDatesBannerInfo( - missedDeadlines = false, - missedGatedContent = false, - verifiedUpgradeLink = "", - contentTypeGatingEnabled = false, - hasEnded = false - ) - ), - isCourseNestedListEnabled = true, - uiMessage = null, - onItemClick = {}, - onExpandClick = {}, - onSubSectionClick = {}, - onResumeClick = {}, - onDownloadClick = {}, - onResetDatesClick = {}, - onCertificateClick = {}, - ) - } -} - -@Preview(uiMode = UI_MODE_NIGHT_NO) -@Preview(uiMode = UI_MODE_NIGHT_YES) -@Composable -private fun ResumeCoursePreview() { - OpenEdXTheme { - ResumeCourse(block = mockChapterBlock) {} - } -} - -private val mockChapterBlock = Block( - id = "id", - blockId = "blockId", - lmsWebUrl = "lmsWebUrl", - legacyWebUrl = "legacyWebUrl", - studentViewUrl = "studentViewUrl", - type = BlockType.CHAPTER, - displayName = "Chapter", - graded = false, - studentViewData = null, - studentViewMultiDevice = false, - blockCounts = BlockCounts(1), - descendants = emptyList(), - descendantsType = BlockType.CHAPTER, - completion = 0.0, - containsGatedContent = false -) -private val mockSequentialBlock = Block( - id = "id", - blockId = "blockId", - lmsWebUrl = "lmsWebUrl", - legacyWebUrl = "legacyWebUrl", - studentViewUrl = "studentViewUrl", - type = BlockType.SEQUENTIAL, - displayName = "Sequential", - graded = false, - studentViewData = null, - studentViewMultiDevice = false, - blockCounts = BlockCounts(1), - descendants = emptyList(), - descendantsType = BlockType.CHAPTER, - completion = 0.0, - containsGatedContent = false -) - -private val mockCourseStructure = CourseStructure( - root = "", - blockData = listOf(mockSequentialBlock, mockSequentialBlock), - id = "id", - name = "Course name", - number = "", - org = "Org", - start = Date(), - startDisplay = "", - startType = "", - end = Date(), - coursewareAccess = CoursewareAccess( - true, - "", - "", - "", - "", - "" - ), - media = null, - certificate = null, - isSelfPaced = false -) 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 deleted file mode 100644 index 569498ab6..000000000 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt +++ /dev/null @@ -1,349 +0,0 @@ -package org.openedx.course.presentation.outline - -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import org.openedx.core.BlockType -import org.openedx.core.R -import org.openedx.core.UIMessage -import org.openedx.core.config.Config -import org.openedx.core.data.storage.CorePreferences -import org.openedx.core.domain.model.Block -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.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.presentation.CoreAnalytics -import org.openedx.core.system.ResourceManager -import org.openedx.core.system.connection.NetworkConnection -import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEvent -import org.openedx.core.system.notifier.CourseDataReady -import org.openedx.core.system.notifier.CourseDatesShifted -import org.openedx.core.system.notifier.CourseLoading -import org.openedx.core.system.notifier.CourseNotifier -import org.openedx.core.system.notifier.CourseStructureUpdated -import org.openedx.course.domain.interactor.CourseInteractor -import org.openedx.course.presentation.CourseAnalytics -import org.openedx.course.presentation.CourseAnalyticsEvent -import org.openedx.course.presentation.CourseAnalyticsKey -import org.openedx.course.presentation.calendarsync.CalendarSyncDialogType -import org.openedx.course.R as courseR - -class CourseOutlineViewModel( - val courseId: String, - private val courseTitle: String, - private val config: Config, - private val interactor: CourseInteractor, - private val resourceManager: ResourceManager, - private val courseNotifier: CourseNotifier, - private val networkConnection: NetworkConnection, - private val preferencesManager: CorePreferences, - private val analytics: CourseAnalytics, - coreAnalytics: CoreAnalytics, - downloadDao: DownloadDao, - workerController: DownloadWorkerController, -) : BaseDownloadViewModel( - courseId, - downloadDao, - preferencesManager, - workerController, - coreAnalytics -) { - val isCourseNestedListEnabled get() = config.isCourseNestedListEnabled() - - private val _uiState = MutableStateFlow(CourseOutlineUIState.Loading) - val uiState: StateFlow - get() = _uiState.asStateFlow() - - private val _uiMessage = MutableSharedFlow() - val uiMessage: SharedFlow - get() = _uiMessage.asSharedFlow() - - var resumeSectionBlock: Block? = null - private set - var resumeVerticalBlock: Block? = null - private set - - val isCourseExpandableSectionsEnabled get() = config.isCourseNestedListEnabled() - - private val courseSubSections = mutableMapOf>() - private val subSectionsDownloadsCount = mutableMapOf() - val courseSubSectionUnit = mutableMapOf() - - init { - viewModelScope.launch { - courseNotifier.notifier.collect { event -> - when(event) { - is CourseStructureUpdated -> { - if (event.courseId == courseId) { - updateCourseData() - } - } - is CourseDataReady -> { - getCourseData() - } - } - } - } - - viewModelScope.launch { - downloadModelsStatusFlow.collect { - if (_uiState.value is CourseOutlineUIState.CourseData) { - val state = _uiState.value as CourseOutlineUIState.CourseData - _uiState.value = CourseOutlineUIState.CourseData( - courseStructure = state.courseStructure, - downloadedState = it.toMap(), - resumeComponent = state.resumeComponent, - courseSubSections = courseSubSections, - courseSectionsState = state.courseSectionsState, - subSectionsDownloadsCount = subSectionsDownloadsCount, - datesBannerInfo = state.datesBannerInfo, - ) - } - } - } - } - - override fun saveDownloadModels(folder: String, id: String) { - if (preferencesManager.videoSettings.wifiDownloadOnly) { - if (networkConnection.isWifiConnected()) { - super.saveDownloadModels(folder, id) - } else { - viewModelScope.launch { - _uiMessage.emit(UIMessage.ToastMessage(resourceManager.getString(courseR.string.course_can_download_only_with_wifi))) - } - } - } else { - super.saveDownloadModels(folder, id) - } - } - - fun updateCourseData() { - getCourseDataInternal() - } - - fun getCourseData() { - viewModelScope.launch { - courseNotifier.send(CourseLoading(true)) - } - getCourseDataInternal() - } - - fun switchCourseSections(blockId: String): Boolean { - return if (_uiState.value is CourseOutlineUIState.CourseData) { - val state = _uiState.value as CourseOutlineUIState.CourseData - val courseSectionsState = state.courseSectionsState.toMutableMap() - courseSectionsState[blockId] = !(state.courseSectionsState[blockId] ?: false) - - _uiState.value = CourseOutlineUIState.CourseData( - courseStructure = state.courseStructure, - downloadedState = state.downloadedState, - resumeComponent = state.resumeComponent, - courseSubSections = courseSubSections, - courseSectionsState = courseSectionsState, - subSectionsDownloadsCount = subSectionsDownloadsCount, - datesBannerInfo = state.datesBannerInfo, - ) - - courseSectionsState[blockId] ?: false - - } else { - false - } - } - - private fun getCourseDataInternal() { - viewModelScope.launch { - try { - var courseStructure = interactor.getCourseStructureFromCache() - val blocks = courseStructure.blockData - - val courseStatus = if (networkConnection.isOnline()) { - interactor.getCourseStatus(courseId) - } else { - CourseComponentStatus("") - } - - val courseDatesResult = if (networkConnection.isOnline()) { - interactor.getCourseDates(courseId) - } else { - CourseDatesResult( - datesSection = linkedMapOf(), - courseBanner = CourseDatesBannerInfo( - missedDeadlines = false, - missedGatedContent = false, - verifiedUpgradeLink = "", - contentTypeGatingEnabled = false, - hasEnded = false - ) - ) - } - val datesBannerInfo = courseDatesResult.courseBanner - - checkIfCalendarOutOfDate(courseDatesResult.datesSection.values.flatten()) - - setBlocks(blocks) - courseSubSections.clear() - courseSubSectionUnit.clear() - courseStructure = courseStructure.copy(blockData = sortBlocks(blocks)) - initDownloadModelsStatus() - - val courseSectionsState = - (_uiState.value as? CourseOutlineUIState.CourseData)?.courseSectionsState.orEmpty() - - _uiState.value = CourseOutlineUIState.CourseData( - courseStructure = courseStructure, - downloadedState = getDownloadModelsStatus(), - resumeComponent = getResumeBlock(blocks, courseStatus.lastVisitedBlockId), - courseSubSections = courseSubSections, - courseSectionsState = courseSectionsState, - subSectionsDownloadsCount = subSectionsDownloadsCount, - datesBannerInfo = datesBannerInfo, - ) - courseNotifier.send(CourseLoading(false)) - } 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))) - } - } - } - } - - private fun sortBlocks(blocks: List): List { - val resultBlocks = mutableListOf() - if (blocks.isEmpty()) return emptyList() - blocks.forEach { block -> - if (block.type == BlockType.CHAPTER) { - 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) - } - addDownloadableChildrenForSequentialBlock(sequentialBlock) - } - } - } - } - return resultBlocks.toList() - } - - private fun getResumeBlock( - blocks: List, - continueBlockId: String, - ): Block? { - val resumeBlock = blocks.firstOrNull { it.id == continueBlockId } - resumeVerticalBlock = - blocks.getVerticalBlocks().find { it.descendants.contains(resumeBlock?.id) } - resumeSectionBlock = - blocks.getSequentialBlocks().find { it.descendants.contains(resumeVerticalBlock?.id) } - return resumeBlock - } - - fun resetCourseDatesBanner(onResetDates: (Boolean) -> Unit) { - viewModelScope.launch { - try { - interactor.resetCourseDates(courseId = courseId) - updateCourseData() - courseNotifier.send(CourseDatesShifted) - onResetDates(true) - } 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_dates_shift_dates_unsuccessful_msg))) - } - onResetDates(false) - } - } - } - - fun viewCertificateTappedEvent() { - analytics.logEvent( - CourseAnalyticsEvent.VIEW_CERTIFICATE.eventName, - buildMap { - put(CourseAnalyticsKey.NAME.key, CourseAnalyticsEvent.VIEW_CERTIFICATE.biValue) - put(CourseAnalyticsKey.COURSE_ID.key, courseId) - } - ) - } - - fun resumeCourseTappedEvent(blockId: String) { - val currentState = uiState.value - if (currentState is CourseOutlineUIState.CourseData) { - analytics.logEvent( - CourseAnalyticsEvent.RESUME_COURSE_CLICKED.eventName, - buildMap { - put( - CourseAnalyticsKey.NAME.key, - CourseAnalyticsEvent.RESUME_COURSE_CLICKED.biValue - ) - put(CourseAnalyticsKey.COURSE_ID.key, courseId) - put(CourseAnalyticsKey.COURSE_NAME.key, courseTitle) - put(CourseAnalyticsKey.BLOCK_ID.key, blockId) - } - ) - } - } - - fun sequentialClickedEvent(blockId: String, blockName: String) { - val currentState = uiState.value - if (currentState is CourseOutlineUIState.CourseData) { - analytics.sequentialClickedEvent( - courseId, - currentState.courseStructure.name, - blockId, - blockName - ) - } - } - - fun logUnitDetailViewedEvent(blockId: String, blockName: String) { - val currentState = uiState.value - if (currentState is CourseOutlineUIState.CourseData) { - analytics.logEvent( - CourseAnalyticsEvent.UNIT_DETAIL.eventName, - buildMap { - put(CourseAnalyticsKey.NAME.key, CourseAnalyticsEvent.UNIT_DETAIL.biValue) - put(CourseAnalyticsKey.COURSE_ID.key, courseId) - put(CourseAnalyticsKey.COURSE_NAME.key, courseTitle) - put(CourseAnalyticsKey.BLOCK_ID.key, blockId) - put(CourseAnalyticsKey.BLOCK_NAME.key, blockName) - } - ) - } - } - - private fun checkIfCalendarOutOfDate(courseDates: List) { - viewModelScope.launch { - courseNotifier.send( - CreateCalendarSyncEvent( - courseDates = courseDates, - dialogType = CalendarSyncDialogType.NONE.name, - checkOutOfSync = true, - ) - ) - } - } -} diff --git a/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt new file mode 100644 index 000000000..c2954c84a --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt @@ -0,0 +1,589 @@ +package org.openedx.course.presentation.progress + +import androidx.compose.foundation.BorderStroke +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.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.offset +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.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +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.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.InsertDriveFile +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.semantics +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.unit.Dp +import androidx.compose.ui.unit.dp +import org.openedx.core.NoContentScreenType +import org.openedx.core.domain.model.CourseProgress +import org.openedx.core.ui.CircularProgress +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.NoContentScreen +import org.openedx.core.ui.displayCutoutForLandscape +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.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.windowSizeValue + +@Composable +fun CourseProgressScreen( + windowSize: WindowSize, + viewModel: CourseProgressViewModel, +) { + val uiState by viewModel.uiState.collectAsState() + val uiMessage by viewModel.uiMessage.collectAsState(null) + + when (val state = uiState) { + is CourseProgressUIState.Loading -> CircularProgress() + is CourseProgressUIState.Error -> NoContentScreen(NoContentScreenType.COURSE_PROGRESS) + is CourseProgressUIState.Data -> CourseProgressContent( + uiState = state, + uiMessage = uiMessage, + windowSize = windowSize, + ) + } +} + +@Composable +private fun CourseProgressContent( + uiState: CourseProgressUIState.Data, + uiMessage: UIMessage?, + windowSize: WindowSize +) { + val scaffoldState = rememberScaffoldState() + val gradingPolicy = uiState.progress.gradingPolicy + + Scaffold( + modifier = Modifier + .fillMaxSize(), + 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 + .fillMaxSize() + .padding(it) + .displayCutoutForLandscape(), + contentAlignment = Alignment.TopCenter + ) { + Surface( + modifier = screenWidth, + color = MaterialTheme.appColors.background, + ) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(vertical = 16.dp) + ) { + item { + CourseCompletionView( + progress = uiState.progress + ) + } + if (gradingPolicy == null) return@LazyColumn + val assignmentPolicies = uiState.progress.getNotEmptyGradingPolicies() + if (!assignmentPolicies.isNullOrEmpty()) { + item { + OverallGradeView( + progress = uiState.progress, + ) + } + item { + GradeDetailsHeaderView() + } + itemsIndexed(assignmentPolicies) { index, policy -> + AssignmentTypeRow( + uiState = uiState, + policy = policy, + color = if (gradingPolicy.assignmentColors.isNotEmpty()) { + gradingPolicy.assignmentColors[index % gradingPolicy.assignmentColors.size] + } else { + MaterialTheme.appColors.primary + } + ) + Divider( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + ) + } + item { + GradeDetailsFooterView( + progress = uiState.progress + ) + } + } else { + item { + Box( + modifier = Modifier + .fillMaxSize() + .padding(top = 60.dp), + contentAlignment = Alignment.Center + ) { + NoGradesView() + } + } + } + } + } + + HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) + } + } +} + +@Composable +private fun NoGradesView() { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + modifier = Modifier.size(60.dp), + imageVector = Icons.AutoMirrored.Outlined.InsertDriveFile, + contentDescription = null, + tint = MaterialTheme.appColors.divider + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource(R.string.course_progress_no_assignments), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textDark, + textAlign = TextAlign.Center + ) + } +} + +@Composable +private fun GradeDetailsHeaderView() { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = stringResource(R.string.course_progress_grade_details), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textDark, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.course_progress_assignment_type), + style = MaterialTheme.appTypography.bodySmall, + color = MaterialTheme.appColors.textPrimaryVariant, + ) + Text( + text = stringResource(R.string.course_progress_current_max), + style = MaterialTheme.appTypography.bodySmall, + color = MaterialTheme.appColors.textPrimaryVariant, + ) + } + } +} + +@Composable +fun GradeDetailsFooterView( + progress: CourseProgress, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.course_progress_current_overall), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textDark, + ) + Text( + text = "${progress.getTotalWeightPercent().toInt()}%", + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.primary, + fontWeight = FontWeight.SemiBold + ) + } +} + +@Composable +private fun OverallGradeView( + progress: CourseProgress, +) { + val gradingPolicy = progress.gradingPolicy + if (gradingPolicy == null) return + val notCompletedWeightedGradePercent = progress.getNotCompletedWeightedGradePercent() + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = stringResource(R.string.course_progress_overall_title), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textDark, + ) + Text( + text = stringResource(R.string.course_progress_overall_description), + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textDark, + ) + CurrentOverallGradeText(progress = progress) + Column { + GradeProgressBar( + progress = progress, + gradingPolicy = gradingPolicy, + notCompletedWeightedGradePercent = notCompletedWeightedGradePercent + ) + RequiredGradeMarker(progress = progress) + } + + Surface( + color = MaterialTheme.appColors.cardViewBackground, + shape = MaterialTheme.appShapes.cardShape, + border = BorderStroke( + width = 1.dp, + color = MaterialTheme.appColors.warning + ), + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.padding(vertical = 12.dp, horizontal = 16.dp), + ) { + Icon( + modifier = Modifier.size(16.dp), + painter = painterResource(id = android.R.drawable.ic_dialog_alert), + contentDescription = null, + tint = MaterialTheme.appColors.warning, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource( + R.string.course_progress_required_grade_percent, + progress.requiredGradePercent.toString() + ), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textDark, + ) + } + } + } +} + +@Composable +private fun CourseCompletionView( + progress: CourseProgress +) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = stringResource(R.string.course_progress_completion_title), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textDark, + ) + Text( + text = stringResource(R.string.course_progress_completion_description), + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textDark, + ) + } + CourseCompletionCircularProgress( + progress = progress.completion, + progressPercent = progress.completionPercent, + completedText = stringResource(R.string.course_completed) + ) + } +} + +@Composable +private fun AssignmentTypeRow( + uiState: CourseProgressUIState.Data, + policy: CourseProgress.GradingPolicy.AssignmentPolicy, + color: Color +) { + val assignments = uiState.progress.getAssignmentSections(policy.type) + val earned = uiState.progress.getCompletedAssignmentCount(policy, uiState.courseStructure) + val possible = assignments.size + Column( + modifier = Modifier + .semantics(mergeDescendants = true) {} + ) { + Text( + text = policy.type, + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textPrimary, + ) + Row( + Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .fillMaxHeight() + .width(7.dp) + .background( + color = color, + shape = CircleShape + ) + ) + Spacer(modifier = Modifier.width(8.dp)) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = stringResource( + R.string.course_progress_earned_possible_assignment_problems, + earned, + possible + ), + style = MaterialTheme.appTypography.bodySmall, + color = MaterialTheme.appColors.textDark, + ) + Text( + text = buildAnnotatedString { + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append("${(policy.weight * 100).toInt()}%") + } + append(" ") + append(stringResource(R.string.course_progress_of_grade)) + }, + style = MaterialTheme.appTypography.bodySmall, + color = MaterialTheme.appColors.textDark, + ) + } + Text( + stringResource( + R.string.course_progress_current_and_max_weighted_graded_percent, + uiState.progress.getAssignmentWeightedGradedPercent(policy).toInt(), + (policy.weight * 100).toInt() + ), + style = MaterialTheme.appTypography.bodyLarge, + fontWeight = FontWeight.W700, + color = MaterialTheme.appColors.textDark, + ) + } + } +} + +@Composable +fun CourseCompletionCircularProgress( + modifier: Modifier = Modifier, + progress: Float, + progressPercent: Int, + completedText: String +) { + Box( + modifier = modifier + .semantics(mergeDescendants = true) {} + ) { + CircularProgressIndicator( + modifier = Modifier + .size(100.dp) + .border( + width = 1.dp, + color = MaterialTheme.appColors.progressBarBackgroundColor, + shape = CircleShape + ) + .padding(3.dp), + progress = progress, + color = MaterialTheme.appColors.primary, + backgroundColor = MaterialTheme.appColors.progressBarBackgroundColor, + strokeWidth = 10.dp, + strokeCap = StrokeCap.Round + ) + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "$progressPercent%", + style = MaterialTheme.appTypography.headlineSmall, + color = MaterialTheme.appColors.primary, + ) + Text( + text = completedText, + style = MaterialTheme.appTypography.labelSmall, + color = MaterialTheme.appColors.textPrimaryVariant, + ) + } + } +} + +@Composable +fun GradeProgressBar( + progress: CourseProgress, + gradingPolicy: CourseProgress.GradingPolicy, + notCompletedWeightedGradePercent: Float +) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(CircleShape) + .border( + width = 1.dp, + color = MaterialTheme.appColors.gradeProgressBarBorder, + shape = CircleShape + ) + ) { + gradingPolicy.assignmentPolicies.forEachIndexed { index, assignmentPolicy -> + val assignmentColors = gradingPolicy.assignmentColors + val color = if (assignmentColors.isNotEmpty()) { + assignmentColors[ + gradingPolicy.assignmentPolicies.indexOf( + assignmentPolicy + ) % assignmentColors.size + ] + } else { + MaterialTheme.appColors.primary + } + val weightedPercent = + progress.getAssignmentWeightedGradedPercent(assignmentPolicy) + if (weightedPercent > 0f) { + Box( + modifier = Modifier + .weight(weightedPercent) + .background(color) + .fillMaxHeight() + ) + + // Add black separator between assignment policies (except after the last one) + if (index < gradingPolicy.assignmentPolicies.size - 1) { + Box( + modifier = Modifier + .width(1.dp) + .background(Color.Black) + .fillMaxHeight() + ) + } + } + } + if (notCompletedWeightedGradePercent > 0f) { + Box( + modifier = Modifier + .weight(notCompletedWeightedGradePercent) + .background(MaterialTheme.appColors.gradeProgressBarBackground) + .fillMaxHeight() + ) + } + } +} + +@Composable +fun RequiredGradeMarker( + progress: CourseProgress +) { + Box( + modifier = Modifier + .fillMaxWidth(progress.requiredGrade), + contentAlignment = Alignment.CenterEnd + ) { + Box( + modifier = Modifier.offset(x = 20.dp), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(id = R.drawable.ic_course_marker), + tint = MaterialTheme.appColors.warning, + contentDescription = null + ) + Text( + modifier = Modifier + .offset(y = 2.dp) + .clearAndSetSemantics { }, + text = "${progress.requiredGradePercent}%", + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textDark, + ) + } + } +} + +@Composable +fun CurrentOverallGradeText( + progress: CourseProgress +) { + Text( + text = buildAnnotatedString { + withStyle( + style = SpanStyle( + color = MaterialTheme.appColors.textDark, + fontSize = MaterialTheme.appTypography.labelMedium.fontSize, + fontFamily = MaterialTheme.appTypography.labelMedium.fontFamily, + fontWeight = MaterialTheme.appTypography.labelMedium.fontWeight + ) + ) { + append(stringResource(R.string.course_progress_current_overall) + " ") + } + withStyle( + style = SpanStyle( + color = MaterialTheme.appColors.primary, + fontSize = MaterialTheme.appTypography.labelMedium.fontSize, + fontFamily = MaterialTheme.appTypography.labelMedium.fontFamily, + fontWeight = FontWeight.SemiBold + ) + ) { + append("${progress.getTotalWeightPercent().toInt()}%") + } + }, + style = MaterialTheme.appTypography.labelMedium, + ) +} diff --git a/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressUIState.kt b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressUIState.kt new file mode 100644 index 000000000..ce504ce39 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressUIState.kt @@ -0,0 +1,13 @@ +package org.openedx.course.presentation.progress + +import org.openedx.core.domain.model.CourseProgress +import org.openedx.core.domain.model.CourseStructure + +sealed class CourseProgressUIState { + data object Error : CourseProgressUIState() + data object Loading : CourseProgressUIState() + data class Data( + val progress: CourseProgress, + val courseStructure: CourseStructure?, + ) : CourseProgressUIState() +} diff --git a/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressViewModel.kt b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressViewModel.kt new file mode 100644 index 000000000..805f486d1 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressViewModel.kt @@ -0,0 +1,76 @@ +package org.openedx.course.presentation.progress + +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.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import org.openedx.core.system.notifier.CourseLoading +import org.openedx.core.system.notifier.CourseNotifier +import org.openedx.core.system.notifier.CourseProgressLoaded +import org.openedx.core.system.notifier.CourseStructureUpdated +import org.openedx.core.system.notifier.RefreshProgress +import org.openedx.course.domain.interactor.CourseInteractor +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.UIMessage + +class CourseProgressViewModel( + val courseId: String, + private val interactor: CourseInteractor, + private val courseNotifier: CourseNotifier, +) : BaseViewModel() { + + private val _uiState = MutableStateFlow(CourseProgressUIState.Loading) + val uiState: StateFlow + get() = _uiState.asStateFlow() + + private val _uiMessage = MutableSharedFlow() + val uiMessage: SharedFlow + get() = _uiMessage.asSharedFlow() + + init { + collectData(false) + collectCourseNotifier() + } + + private fun collectData(isRefresh: Boolean) { + viewModelScope.launch { + val courseProgressFlow = interactor.getCourseProgress(courseId, isRefresh, false) + val courseStructureFlow = interactor.getCourseStructureFlow(courseId) + + combine( + courseProgressFlow, + courseStructureFlow + ) { courseProgress, courseStructure -> + courseProgress to courseStructure + }.catch { e -> + if (_uiState.value !is CourseProgressUIState.Data) { + _uiState.value = CourseProgressUIState.Error + } + courseNotifier.send(CourseLoading(false)) + }.collect { (courseProgress, courseStructure) -> + _uiState.value = CourseProgressUIState.Data( + courseProgress, + courseStructure + ) + courseNotifier.send(CourseLoading(false)) + courseNotifier.send(CourseProgressLoaded) + } + } + } + + private fun collectCourseNotifier() { + viewModelScope.launch { + courseNotifier.notifier.collect { event -> + when (event) { + is RefreshProgress, is CourseStructureUpdated -> collectData(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..36e20ce2c 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,13 @@ 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 +69,15 @@ 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.course.presentation.unit.container.CourseViewMode +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,14 +283,16 @@ 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 - ) + if (block.isCompleted()) { + painterResource(R.drawable.course_ic_task_alt) + } else { + painterResource( + CoreR.drawable.core_ic_chapter_icon + ) + } val completedIconColor = if (block.isCompleted()) MaterialTheme.appColors.primary else MaterialTheme.appColors.onSurface val completedIconDescription = if (block.isCompleted()) { @@ -286,8 +301,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 +333,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 +341,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 +355,12 @@ private fun CourseSectionScreenPreview() { mockBlock, mockBlock ), - mapOf(), "", "Course default" ), uiMessage = null, onBackClick = {}, onItemClick = {}, - onDownloadClick = {} ) } } @@ -419,14 +379,12 @@ private fun CourseSectionScreenTabletPreview() { mockBlock, mockBlock ), - mapOf(), "", "Course default", ), uiMessage = null, onBackClick = {}, onItemClick = {}, - onDownloadClick = {} ) } } @@ -446,5 +404,8 @@ private val mockBlock = Block( descendants = emptyList(), descendantsType = BlockType.HTML, completion = 0.0, - containsGatedContent = false + containsGatedContent = false, + assignmentProgress = AssignmentProgress("", 1f, 2f, "HM1"), + 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..166da30c2 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,14 +1,12 @@ 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() - object Loading : CourseSectionUIState() -} \ No newline at end of file + data object Loading : 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..8966ee45e 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.course.presentation.unit.container.CourseViewMode +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,9 +90,10 @@ class CourseSectionViewModel( if (blockDescendant != null) { if (blockDescendant.type == BlockType.VERTICAL) { resultList.add(blockDescendant) - addDownloadableChildrenForVerticalBlock(blockDescendant) } - } else continue + } else { + continue + } } return resultList } 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..19d3bb4b5 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 @@ -12,13 +14,16 @@ 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.defaultMinSize 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.size import androidx.compose.foundation.layout.statusBarsPadding @@ -26,7 +31,10 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults @@ -43,30 +51,39 @@ import androidx.compose.material.Snackbar import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight 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 import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale 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.TextStyle +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 @@ -77,17 +94,12 @@ 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.domain.model.Progress +import org.openedx.core.extension.safeDivBy import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.db.FileType @@ -95,114 +107,39 @@ import org.openedx.core.ui.BackBtn import org.openedx.core.ui.IconText import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.OpenEdXOutlinedButton +import org.openedx.core.ui.TextIcon 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.core.utils.VideoPreview 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) - } - }) - } - } - } -} +const val AUTO_SCROLL_DELAY = 3000L @Composable fun CourseSectionCard( block: Block, downloadedState: DownloadedState?, onItemClick: (Block) -> Unit, - onDownloadClick: (Block) -> Unit + onDownloadClick: (Block) -> Unit, ) { val iconModifier = Modifier.size(24.dp) - Column(Modifier.clickable { onItemClick(block) }) { + Column( + modifier = Modifier.clickable { onItemClick(block) } + ) { Row( Modifier .fillMaxWidth() @@ -214,12 +151,16 @@ fun CourseSectionCard( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { - val completedIconPainter = - if (block.isCompleted()) painterResource(R.drawable.course_ic_task_alt) else painterResource( - R.drawable.ic_course_chapter_icon - ) - val completedIconColor = - if (block.isCompleted()) MaterialTheme.appColors.primary else MaterialTheme.appColors.onSurface + val completedIconPainter = if (block.isCompleted()) { + painterResource(R.drawable.course_ic_task_alt) + } else { + painterResource(coreR.drawable.core_ic_chapter_icon) + } + val completedIconColor = if (block.isCompleted()) { + MaterialTheme.appColors.primary + } else { + MaterialTheme.appColors.onSurface + } val completedIconDescription = if (block.isCompleted()) { stringResource(id = R.string.course_accessibility_section_completed) } else { @@ -231,25 +172,18 @@ fun CourseSectionCard( tint = completedIconColor ) 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 - ) - Spacer(modifier = Modifier.width(16.dp)) Row( modifier = Modifier.fillMaxHeight(), 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) + if (downloadedState == DownloadedState.DOWNLOADED || + downloadedState == DownloadedState.NOT_DOWNLOADED + ) { + 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) { @@ -257,17 +191,21 @@ fun CourseSectionCard( } else { stringResource(id = R.string.course_accessibility_download_course_section) } - IconButton(modifier = iconModifier, - onClick = { onDownloadClick(block) }) { + IconButton( + modifier = iconModifier, + onClick = { onDownloadClick(block) } + ) { Icon( - painter = downloadIconPainter, + imageVector = downloadIcon, contentDescription = downloadIconDescription, tint = MaterialTheme.appColors.textPrimary ) } } else if (downloadedState != null) { Box(contentAlignment = Alignment.Center) { - if (downloadedState == DownloadedState.DOWNLOADING || downloadedState == DownloadedState.WAITING) { + if (downloadedState == DownloadedState.DOWNLOADING || + downloadedState == DownloadedState.WAITING + ) { CircularProgressIndicator( modifier = Modifier.size(34.dp), backgroundColor = Color.LightGray, @@ -277,10 +215,12 @@ fun CourseSectionCard( } IconButton( modifier = iconModifier.padding(top = 2.dp), - onClick = { onDownloadClick(block) }) { + onClick = { onDownloadClick(block) } + ) { Icon( imageVector = Icons.Filled.Close, - contentDescription = stringResource(id = R.string.course_accessibility_stop_downloading_course_section), + contentDescription = + stringResource(id = R.string.course_accessibility_stop_downloading_course_section), tint = MaterialTheme.appColors.error ) } @@ -299,7 +239,7 @@ fun OfflineQueueCard( downloadModel: DownloadModel, progressValue: Long, progressSize: Long, - onDownloadClick: (DownloadModel) -> Unit + onDownloadClick: (DownloadModel) -> Unit, ) { val iconModifier = Modifier.size(24.dp) @@ -316,22 +256,21 @@ fun OfflineQueueCard( .weight(1f) ) { Text( - text = downloadModel.title.ifEmpty { stringResource(id = R.string.course_download_untitled) }, + text = downloadModel.title.ifEmpty { stringResource(id = coreR.string.core_download_untitled) }, style = MaterialTheme.appTypography.titleSmall, color = MaterialTheme.appColors.textPrimary, overflow = TextOverflow.Ellipsis, 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 = progressValue.toFloat().safeDivBy(progressSize.toFloat()) LinearProgressIndicator( modifier = Modifier .fillMaxWidth() @@ -352,12 +291,14 @@ fun OfflineQueueCard( color = MaterialTheme.appColors.primary ) IconButton( - modifier = iconModifier - .padding(2.dp), - onClick = { onDownloadClick(downloadModel) }) { + modifier = iconModifier.padding(2.dp), + onClick = { onDownloadClick(downloadModel) } + ) { Icon( imageVector = Icons.Filled.Close, - contentDescription = stringResource(id = R.string.course_accessibility_stop_downloading_course_section), + contentDescription = stringResource( + id = R.string.course_accessibility_stop_downloading_course_section + ), tint = MaterialTheme.appColors.error ) } @@ -367,58 +308,17 @@ fun OfflineQueueCard( @Composable fun CardArrow( - degrees: Float + degrees: Float, + tint: Color = MaterialTheme.appColors.textDark, ) { Icon( - imageVector = Icons.Filled.ChevronRight, - tint = MaterialTheme.appColors.primary, - contentDescription = "Expandable Arrow", + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + tint = tint, + contentDescription = null, 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, @@ -439,9 +339,10 @@ fun NavigationUnitsButtons( nextButtonText: String, hasPrevBlock: Boolean, hasNextBlock: Boolean, + showFinishButton: Boolean = true, isVerticalNavigation: Boolean, onPrevClick: () -> Unit, - onNextClick: () -> Unit + onNextClick: () -> Unit, ) { val nextButtonIcon = if (hasNextBlock) { painterResource(id = coreR.drawable.core_ic_down) @@ -476,7 +377,7 @@ fun NavigationUnitsButtons( colors = ButtonDefaults.outlinedButtonColors( backgroundColor = MaterialTheme.appColors.background ), - border = BorderStroke(1.dp, MaterialTheme.appColors.primary), + border = BorderStroke(1.dp, MaterialTheme.appColors.textAccent), elevation = null, shape = MaterialTheme.appShapes.navigationButtonShape, onClick = onPrevClick, @@ -485,48 +386,66 @@ fun NavigationUnitsButtons( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center ) { + if (!isVerticalNavigation) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null, + tint = MaterialTheme.appColors.textAccent + ) + Spacer(Modifier.width(8.dp)) + } Text( text = stringResource(R.string.course_navigation_prev), - color = MaterialTheme.appColors.primary, + color = MaterialTheme.appColors.textAccent, style = MaterialTheme.appTypography.labelLarge ) - Spacer(Modifier.width(8.dp)) - Icon( - modifier = Modifier.rotate(if (isVerticalNavigation) 0f else -90f), - painter = painterResource(id = coreR.drawable.core_ic_up), - contentDescription = null, - tint = MaterialTheme.appColors.primary - ) + if (isVerticalNavigation) { + Spacer(Modifier.width(8.dp)) + Icon( + painter = painterResource(id = coreR.drawable.core_ic_up), + contentDescription = null, + tint = MaterialTheme.appColors.textAccent + ) + } } } Spacer(Modifier.width(16.dp)) } - Button( - modifier = Modifier - .height(42.dp), - colors = ButtonDefaults.buttonColors( - backgroundColor = MaterialTheme.appColors.buttonBackground - ), - elevation = null, - shape = MaterialTheme.appShapes.navigationButtonShape, - onClick = onNextClick - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center + if (hasNextBlock || showFinishButton) { + Button( + modifier = Modifier + .height(42.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = MaterialTheme.appColors.primaryButtonBackground + ), + elevation = null, + shape = MaterialTheme.appShapes.navigationButtonShape, + onClick = onNextClick ) { - Text( - text = nextButtonText, - color = MaterialTheme.appColors.buttonText, - style = MaterialTheme.appTypography.labelLarge - ) - Spacer(Modifier.width(8.dp)) - Icon( - modifier = Modifier.rotate(if (isVerticalNavigation || !hasNextBlock) 0f else -90f), - painter = nextButtonIcon, - contentDescription = null, - tint = MaterialTheme.appColors.buttonText - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Text( + text = nextButtonText, + color = MaterialTheme.appColors.primaryButtonText, + style = MaterialTheme.appTypography.labelLarge + ) + Spacer(Modifier.width(8.dp)) + if (isVerticalNavigation || !hasNextBlock) { + Icon( + painter = nextButtonIcon, + contentDescription = null, + tint = MaterialTheme.appColors.primaryButtonText + ) + } else { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null, + tint = MaterialTheme.appColors.primaryButtonText + ) + } + } } } } @@ -540,7 +459,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,16 +524,16 @@ fun Indicator( defaultColor: Color, defaultRadius: Dp, selectedSize: Dp, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { val size by animateDpAsState( targetValue = if (isSelected) selectedSize else defaultRadius, - animationSpec = tween(300), + animationSpec = tween(durationMillis = 300), label = "" ) val color by animateColorAsState( targetValue = if (isSelected) selectedColor else defaultColor, - animationSpec = tween(300), + animationSpec = tween(durationMillis = 300), label = "" ) @@ -634,10 +553,9 @@ fun VideoSubtitles( showSubtitleLanguage: Boolean, currentIndex: Int, onTranscriptClick: (Caption) -> Unit, - onSettingsClick: () -> Unit + onSettingsClick: () -> Unit, ) { timedTextObject?.let { - val autoScrollDelay = 3000L var lastScrollTime by remember { mutableLongStateOf(0L) } @@ -646,14 +564,18 @@ fun VideoSubtitles( } LaunchedEffect(key1 = currentIndex) { - if (currentIndex > 1 && lastScrollTime + autoScrollDelay < Date().time) { + if (currentIndex > 1 && lastScrollTime + AUTO_SCROLL_DELAY < Date().time) { listState.animateScrollToItem(currentIndex - 1) } } 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 +609,11 @@ fun VideoSubtitles( } else { MaterialTheme.appColors.textFieldBorder } + val fontWeight = if (currentIndex == index) { + FontWeight.SemiBold + } else { + FontWeight.Normal + } Text( modifier = Modifier .fillMaxWidth() @@ -695,7 +622,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 +634,476 @@ fun VideoSubtitles( } @Composable -fun CourseExpandableChapterCard( - modifier: Modifier, +fun CourseVideoSection( block: Block, - onItemClick: (Block) -> Unit, - arrowDegrees: Float = 0f + videoBlocks: List, + preview: Map, + progress: Map, + downloadedStateMap: Map, + onVideoClick: (Block) -> Unit, + onDownloadClick: (blocksIds: List) -> Unit, ) { - Column(modifier = Modifier - .clickable { onItemClick(block) } - .background(if (block.isCompleted()) MaterialTheme.appColors.surface else Color.Transparent) + val state = rememberLazyListState() + val subSectionIds = videoBlocks.map { it.id } + val filteredStatuses = downloadedStateMap.filterKeys { it in subSectionIds }.values + val downloadedState = when { + filteredStatuses.isEmpty() -> null + filteredStatuses.all { it.isDownloaded } -> DownloadedState.DOWNLOADED + filteredStatuses.any { it.isWaitingOrDownloading } -> DownloadedState.DOWNLOADING + else -> DownloadedState.NOT_DOWNLOADED + } + val videoCardWidth = 192.dp + val rowHorizontalArrangement = 8.dp + + LaunchedEffect(Unit) { + try { + val uncompletedBlockIndex = videoBlocks.indexOf(videoBlocks.find { !it.isCompleted() }) + state.scrollToItem(uncompletedBlockIndex) + } catch (e: Exception) { + e.printStackTrace() + } + } + + Column( + modifier = Modifier.padding(vertical = 8.dp) ) { - Row( - modifier - .fillMaxWidth() - .height(60.dp) - .padding( - vertical = 8.dp - ), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween + CourseVideoSectionHeader( + block = block, + downloadedState = downloadedState, + videoBlocks = videoBlocks, + onDownloadClick = { + onDownloadClick(block.descendants) + } + ) + LazyRow( + state = state, + horizontalArrangement = Arrangement.spacedBy(rowHorizontalArrangement), + contentPadding = PaddingValues( + top = 8.dp, + bottom = 16.dp, + start = 16.dp, + end = videoCardWidth + rowHorizontalArrangement, + ) ) { - 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) + items(videoBlocks) { block -> + val localProgress = progress[block.id] + val progress = localProgress ?: if (block.isCompleted()) { + 1f + } else { + 0f + } + CourseVideoItem( + modifier = Modifier + .width(videoCardWidth) + .height(108.dp) + .clip(MaterialTheme.appShapes.videoPreviewShape), + videoBlock = block, + preview = preview[block.id], + progress = progress, + onClick = { + onVideoClick(block) + } + ) + } + } + Divider(modifier = Modifier.fillMaxWidth()) + } +} - Icon( - painter = completedIconPainter, - contentDescription = completedIconDescription, - tint = completedIconColor +@Composable +fun CourseVideoItem( + modifier: Modifier = Modifier, + videoBlock: Block, + preview: VideoPreview?, + progress: Float, + onClick: () -> Unit, + titleStyle: TextStyle = MaterialTheme.appTypography.bodySmall, + contentModifier: Modifier = Modifier.padding(8.dp), + progressModifier: Modifier = Modifier.height(4.dp), + playButtonSize: Dp = 32.dp, + borderColor: Color? = null, + borderWidth: Dp = 3.dp, +) { + val borderColor = borderColor ?: if (videoBlock.isCompleted()) { + MaterialTheme.appColors.successGreen + } else { + Color.Transparent + } + Box( + modifier = modifier + .clip(MaterialTheme.appShapes.videoPreviewShape) + .border( + width = borderWidth, + color = borderColor, + shape = MaterialTheme.appShapes.videoPreviewShape + ) + .clickable { onClick() } + ) { + AsyncImage( + modifier = Modifier + .fillMaxSize(), + model = ImageRequest.Builder(LocalContext.current) + .data(preview?.link ?: preview?.bitmap) + .error(coreR.drawable.core_no_image_course) + .placeholder(coreR.drawable.core_no_image_course) + .build(), + contentDescription = stringResource(R.string.course_accessibility_video_player), + contentScale = ContentScale.Crop + ) + + Box( + modifier = Modifier + .fillMaxSize() + .background( + brush = Brush.verticalGradient( + colors = listOf( + Color.Black.copy(alpha = 0.6f), + Color.Transparent, + ), + startY = 0f, + endY = Float.POSITIVE_INFINITY + ) ) - Spacer(modifier = Modifier.width(16.dp)) + ) + + Box( + modifier = contentModifier.fillMaxSize() + ) { + Image( + modifier = Modifier + .size(playButtonSize) + .align(Alignment.Center), + painter = painterResource(id = R.drawable.course_video_play_button), + contentDescription = null, + ) + + // Title (top-left) + Text( + text = videoBlock.displayName, + color = Color.White, + style = titleStyle, + modifier = Modifier + .align(Alignment.TopStart), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + // Progress bar (bottom) + Box( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + contentAlignment = Alignment.Center + ) { + if (progress > 0.0f) { + LinearProgressIndicator( + modifier = progressModifier + .fillMaxWidth() + .clip(CircleShape), + progress = progress, + color = if (videoBlock.isCompleted() && progress > 0.95f) { + MaterialTheme.appColors.progressBarColor + } else { + MaterialTheme.appColors.info + }, + backgroundColor = MaterialTheme.appColors.progressBarBackgroundColor + ) + } + if (videoBlock.isCompleted()) { + Image( + modifier = Modifier + .align(Alignment.BottomEnd) + .size(16.dp) + .offset(x = 1.dp), + painter = painterResource(id = coreR.drawable.ic_core_check), + contentDescription = stringResource(R.string.course_accessibility_video_watched), + ) + } else { + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .size(16.dp) + .offset(x = 1.dp), + ) + } } + } + } +} + +@Composable +fun CourseVideoSectionHeader( + modifier: Modifier = Modifier, + block: Block, + videoBlocks: List?, + downloadedState: DownloadedState?, + onDownloadClick: () -> Unit, +) { + Row( + modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Column( + modifier = Modifier.weight(1f), + ) { Text( - modifier = Modifier.weight(1f), text = block.displayName, style = MaterialTheme.appTypography.titleSmall, color = MaterialTheme.appColors.textPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis ) - Spacer(modifier = Modifier.width(16.dp)) + Text( + text = stringResource( + R.string.course_video_watched, + videoBlocks?.filter { it.isCompleted() }?.size ?: 0, + videoBlocks?.size ?: 0 + ), + style = MaterialTheme.appTypography.bodySmall, + color = MaterialTheme.appColors.textPrimary, + ) + } + DownloadIcon( + downloadedState = downloadedState, + onDownloadClick = onDownloadClick + ) + } +} + +@Composable +fun DownloadIcon( + downloadedState: DownloadedState?, + onDownloadClick: () -> Unit, +) { + val iconModifier = Modifier.size(24.dp) + Box( + modifier = Modifier.fillMaxHeight(), + contentAlignment = Alignment.Center + ) { + 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.primary + } + 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 = coreR.drawable.core_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 CourseSection( + modifier: Modifier = Modifier, + section: Block, + useRelativeDates: Boolean, + showDueDate: Boolean = true, + isExpandable: Boolean = true, + onItemClick: (Block) -> Unit, + isSectionVisible: Boolean?, + subSections: List?, + downloadedStateMap: Map, + onSubSectionClick: (Block) -> Unit, + onDownloadClick: (blocksIds: List) -> Unit, + progress: Float? = null, + background: Color = MaterialTheme.appColors.cardViewBackground +) { + val arrowRotation by animateFloatAsState( + targetValue = if (isSectionVisible == true) { + -90f + } else { + 90f + }, + label = "" + ) + val subSectionIds = subSections?.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 + } + + // Section progress + val completedCount = subSections?.count { it.isCompleted() } ?: 0 + val totalCount = subSections?.size ?: 0 + val progress = progress ?: if (totalCount > 0) completedCount.toFloat() / totalCount else 0f + + Column( + modifier = modifier + .clip(MaterialTheme.appShapes.sectionCardShape) + .noRippleClickable { onItemClick(section) } + .background(background) + .border( + 1.dp, + MaterialTheme.appColors.cardViewBorder, + MaterialTheme.appShapes.sectionCardShape + ) + ) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(6.dp), + progress = progress, + color = MaterialTheme.appColors.progressBarColor, + backgroundColor = MaterialTheme.appColors.progressBarBackgroundColor + ) + CourseExpandableChapterCard( + block = section, + arrowDegrees = arrowRotation, + isExpandable = isExpandable, + downloadedState = downloadedState, + onDownloadClick = { + onDownloadClick(section.descendants) + } + ) + subSections?.forEach { subSectionBlock -> + AnimatedVisibility( + visible = isSectionVisible == true + ) { + CourseSubSectionItem( + block = subSectionBlock, + onClick = onSubSectionClick, + showDueDate = showDueDate, + useRelativeDates = useRelativeDates + ) + } + } + } +} + +@Composable +fun CourseExpandableChapterCard( + modifier: Modifier = Modifier, + block: Block, + arrowDegrees: Float = 0f, + isExpandable: Boolean = true, + downloadedState: DownloadedState?, + onDownloadClick: () -> Unit, +) { + 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) + ) { + if (isExpandable) { CardArrow(degrees = arrowDegrees) } + if (block.isCompleted()) { + val completedIconPainter = painterResource(R.drawable.course_ic_task_alt) + val completedIconColor = MaterialTheme.appColors.successGreen + val completedIconDescription = + stringResource(id = R.string.course_accessibility_section_completed) + + Icon( + painter = completedIconPainter, + contentDescription = completedIconDescription, + tint = completedIconColor + ) + } + Text( + modifier = Modifier.weight(1f), + text = block.displayName, + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + DownloadIcon( + downloadedState = downloadedState, + onDownloadClick = onDownloadClick + ) } } @Composable fun CourseSubSectionItem( - modifier: Modifier, + modifier: Modifier = Modifier, block: Block, - downloadedState: DownloadedState?, - downloadsCount: Int, + useRelativeDates: Boolean, + showDueDate: Boolean, onClick: (Block) -> Unit, - onDownloadClick: (Block) -> Unit ) { - val icon = - if (block.isCompleted()) painterResource(R.drawable.course_ic_task_alt) else painterResource( - R.drawable.ic_course_chapter_icon + val context = LocalContext.current + val icon = if (block.isCompleted()) { + painterResource(R.drawable.course_ic_task_alt) + } else { + painterResource(coreR.drawable.core_ic_chapter_icon) + } + val iconColor = if (block.isCompleted()) { + MaterialTheme.appColors.successGreen + } else { + MaterialTheme.appColors.onSurface + } + val due by rememberSaveable { + mutableStateOf( + block.due?.let { TimeUtils.formatToString(context, it, useRelativeDates) } ) - 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) + } + 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 +1112,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 +1122,40 @@ 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 (due != null || showDueDate) { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + tint = MaterialTheme.appColors.onSurface, + contentDescription = null + ) } } - } -} + val strings = listOf( + block.assignmentProgress?.assignmentType, + due?.let { + stringResource( + id = coreR.string.core_date_format_assignment_due, + it + ) + }, + block.assignmentProgress?.numPointsPossible?.let { + if (it > 0) { + block.assignmentProgress?.toPointString(" ") + } else { + null + } + } + ) + val assignmentString = strings + .filter { !it.isNullOrEmpty() } + .joinToString(" - ") -@Composable -fun CourseToolbar( - title: String, - onBackClick: () -> Unit -) { - OpenEdXTheme { - Box( - modifier = Modifier - .fillMaxWidth() - .displayCutoutForLandscape() - .zIndex(1f) - .statusBarsPadding(), - contentAlignment = Alignment.CenterStart - ) { - BackBtn { onBackClick() } + if (assignmentString.isNotEmpty() && showDueDate) { + 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 +1164,7 @@ fun CourseToolbar( @Composable fun CourseUnitToolbar( title: String, - onBackClick: () -> Unit + onBackClick: () -> Unit, ) { OpenEdXTheme { Box( @@ -922,10 +1194,10 @@ fun SubSectionUnitsTitle( unitName: String, unitsCount: Int, unitsListShowed: Boolean, - onUnitsClick: () -> Unit + onUnitsClick: () -> Unit, ) { val textStyle = MaterialTheme.appTypography.titleMedium - val hasUnits = unitsCount > 0 + val hasMultipleUnits = unitsCount > 1 var rowModifier = Modifier .fillMaxWidth() .padding( @@ -933,7 +1205,7 @@ fun SubSectionUnitsTitle( vertical = 8.dp ) .displayCutoutForLandscape() - if (hasUnits) { + if (hasMultipleUnits) { rowModifier = rowModifier.noRippleClickable { onUnitsClick() } } @@ -953,10 +1225,10 @@ fun SubSectionUnitsTitle( textAlign = TextAlign.Start ) - if (hasUnits) { + if (hasMultipleUnits) { Icon( modifier = Modifier.rotate(if (unitsListShowed) 180f else 0f), - painter = painterResource(id = R.drawable.ic_course_arrow_down), + painter = painterResource(id = R.drawable.course_ic_arrow_down), contentDescription = null, tint = MaterialTheme.appColors.textPrimary ) @@ -968,7 +1240,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 @@ -982,8 +1254,11 @@ fun SubSectionUnitsList( Column( modifier = Modifier .background( - if (index == selectedUnitIndex) MaterialTheme.appColors.surface else + if (index == selectedUnitIndex) { + MaterialTheme.appColors.surface + } else { MaterialTheme.appColors.background + } ) .clickable { onUnitClick(index, unit) } ) { @@ -996,7 +1271,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.core_ic_check), contentDescription = "done" ) Text( @@ -1030,14 +1305,16 @@ fun SubSectionUnitsList( Image( modifier = Modifier .size(16.dp), - painter = painterResource(id = R.drawable.ic_course_gated), + painter = painterResource(id = R.drawable.course_ic_gated), contentDescription = "gated" ) Text( modifier = Modifier .padding(start = 8.dp, end = 8.dp) .weight(1f), - text = stringResource(id = R.string.course_gated_content_label), + text = stringResource( + id = R.string.course_gated_content_label + ), color = MaterialTheme.appColors.textPrimaryVariant, style = MaterialTheme.appTypography.labelSmall, maxLines = 2, @@ -1201,12 +1478,161 @@ fun DatesShiftedSnackBar( borderColor = MaterialTheme.appColors.primary, onClick = { onViewDates() - }) + } + ) + } + } + } +} + +@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 + ) + } +} + +@Composable +fun CourseProgress( + modifier: Modifier = Modifier, + progress: Progress, + description: String, + isCompletedShown: Boolean = false, + onVisibilityChanged: (() -> Unit)? = null +) { + val arrowRotation by animateFloatAsState( + targetValue = if (isCompletedShown) { + -90f + } else { + 90f + }, + label = "" + ) + val buttonText = if (isCompletedShown) { + stringResource(R.string.course_hide_completed) + } else { + stringResource(R.string.course_view_completed) + } + Column( + modifier = modifier, + ) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .clip(CircleShape), + progress = progress.value, + color = MaterialTheme.appColors.progressBarColor, + backgroundColor = MaterialTheme.appColors.progressBarBackgroundColor + ) + Row( + modifier = Modifier + .fillMaxWidth() + .height(24.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = description, + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.labelSmall + ) + if (onVisibilityChanged != null) { + Row( + modifier = Modifier.clickable { + onVisibilityChanged() + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = buttonText, + color = MaterialTheme.appColors.textAccent, + style = MaterialTheme.appTypography.labelMedium + ) + CardArrow( + degrees = arrowRotation, + tint = MaterialTheme.appColors.textAccent, + ) + } } } } } +@Composable +fun ResumeCourseButton( + modifier: Modifier = Modifier, + block: Block, + displayName: String, + onResumeClick: (String) -> Unit, +) { + OpenEdXButton( + modifier = modifier + .fillMaxWidth() + .defaultMinSize(minHeight = 54.dp), + onClick = { + onResumeClick(block.id) + }, + content = { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + modifier = Modifier.weight(1f), + text = displayName, + color = MaterialTheme.appColors.primaryButtonText, + style = MaterialTheme.appTypography.titleMedium, + fontWeight = FontWeight.W600 + ) + TextIcon( + text = stringResource(id = R.string.course_continue), + icon = Icons.AutoMirrored.Filled.ArrowForward, + color = MaterialTheme.appColors.primaryButtonText, + textStyle = MaterialTheme.appTypography.labelLarge + ) + } + } + ) +} + @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable @@ -1217,7 +1643,8 @@ private fun NavigationUnitsButtonsOnlyNextButtonPreview() { hasNextBlock = true, isVerticalNavigation = true, nextButtonText = "Next", - onPrevClick = {}) {} + onPrevClick = {} + ) {} } } @@ -1231,7 +1658,8 @@ private fun NavigationUnitsButtonsOnlyFinishButtonPreview() { hasNextBlock = false, isVerticalNavigation = true, nextButtonText = "Finish", - onPrevClick = {}) {} + onPrevClick = {} + ) {} } } @@ -1245,7 +1673,8 @@ private fun NavigationUnitsButtonsWithFinishPreview() { hasNextBlock = false, isVerticalNavigation = true, nextButtonText = "Finish", - onPrevClick = {}) {} + onPrevClick = {} + ) {} } } @@ -1259,25 +1688,15 @@ private fun NavigationUnitsButtonsWithNextPreview() { hasNextBlock = true, isVerticalNavigation = true, nextButtonText = "Next", - onPrevClick = {}) {} + onPrevClick = {} + ) {} } } @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 +1709,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 +1743,7 @@ private fun OfflineQueueCardPreview() { Surface(color = MaterialTheme.appColors.background) { OfflineQueueCard( downloadModel = DownloadModel( + courseId = "", id = "", title = "Problems of society", size = 4000, @@ -1351,7 +1751,6 @@ private fun OfflineQueueCardPreview() { url = "", type = FileType.VIDEO, downloadedState = DownloadedState.DOWNLOADING, - progress = 0f ), progressValue = 10, progressSize = 30, @@ -1361,42 +1760,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.course_ic_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 +1796,8 @@ private val mockChapterBlock = Block( descendants = emptyList(), descendantsType = BlockType.CHAPTER, completion = 0.0, - containsGatedContent = false + containsGatedContent = false, + assignmentProgress = AssignmentProgress("", 1f, 2f, "HM1"), + 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 deleted file mode 100644 index c69e26c0d..000000000 --- a/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt +++ /dev/null @@ -1,853 +0,0 @@ -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 -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.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.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.AlertDialog -import androidx.compose.material.CircularProgressIndicator -import androidx.compose.material.Divider -import androidx.compose.material.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.Switch -import androidx.compose.material.SwitchDefaults -import androidx.compose.material.Text -import androidx.compose.material.TextButton -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ChevronRight -import androidx.compose.material.icons.outlined.Settings -import androidx.compose.material.icons.outlined.Videocam -import androidx.compose.material.rememberScaffoldState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Devices -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.fragment.app.FragmentManager -import org.openedx.core.AppDataConstants -import org.openedx.core.BlockType -import org.openedx.core.UIMessage -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.VideoSettings -import org.openedx.core.extension.toFileSize -import org.openedx.core.module.download.DownloadModelsSize -import org.openedx.core.presentation.course.CourseViewMode -import org.openedx.core.presentation.settings.VideoQualityType -import org.openedx.core.ui.HandleUIMessage -import org.openedx.core.ui.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.videos.CourseVideoViewModel -import org.openedx.course.presentation.videos.CourseVideosUIState -import java.io.File -import java.util.Date - -@Composable -fun CourseVideosScreen( - windowSize: WindowSize, - courseVideoViewModel: CourseVideoViewModel, - fragmentManager: FragmentManager, - courseRouter: CourseRouter -) { - val uiState by courseVideoViewModel.uiState.collectAsState(CourseVideosUIState.Loading) - val uiMessage by courseVideoViewModel.uiMessage.collectAsState(null) - val videoSettings by courseVideoViewModel.videoSettings.collectAsState() - val context = LocalContext.current - - CourseVideosUI( - windowSize = windowSize, - uiState = uiState, - uiMessage = uiMessage, - courseTitle = courseVideoViewModel.courseTitle, - isCourseNestedListEnabled = courseVideoViewModel.isCourseNestedListEnabled, - videoSettings = videoSettings, - onItemClick = { block -> - courseRouter.navigateToCourseSubsections( - fm = fragmentManager, - courseId = courseVideoViewModel.courseId, - subSectionId = block.id, - mode = CourseViewMode.VIDEOS - ) - }, - onExpandClick = { block -> - courseVideoViewModel.switchCourseSections(block.id) - }, - onSubSectionClick = { subSectionBlock -> - courseVideoViewModel.courseSubSectionUnit[subSectionBlock.id]?.let { unit -> - courseVideoViewModel.sequentialClickedEvent( - unit.blockId, - unit.displayName - ) - courseRouter.navigateToCourseContainer( - fm = fragmentManager, - courseId = courseVideoViewModel.courseId, - unitId = unit.id, - mode = CourseViewMode.VIDEOS - ) - } - }, - onDownloadClick = { - if (courseVideoViewModel.isBlockDownloading(it.id)) { - courseRouter.navigateToDownloadQueue( - fm = fragmentManager, - courseVideoViewModel.getDownloadableChildren(it.id) - ?: arrayListOf() - ) - } else if (courseVideoViewModel.isBlockDownloaded(it.id)) { - courseVideoViewModel.removeDownloadModels(it.id) - } else { - courseVideoViewModel.saveDownloadModels( - context.externalCacheDir.toString() + - File.separator + - context - .getString(org.openedx.core.R.string.app_name) - .replace(Regex("\\s"), "_"), it.id - ) - } - }, - onDownloadAllClick = { isAllBlocksDownloadedOrDownloading -> - courseVideoViewModel.logBulkDownloadToggleEvent(!isAllBlocksDownloadedOrDownloading) - if (isAllBlocksDownloadedOrDownloading) { - courseVideoViewModel.removeAllDownloadModels() - } else { - courseVideoViewModel.saveAllDownloadModels( - context.externalCacheDir.toString() + - File.separator + - context - .getString(org.openedx.core.R.string.app_name) - .replace(Regex("\\s"), "_") - ) - } - }, - onDownloadQueueClick = { - if (courseVideoViewModel.hasDownloadModelsInQueue()) { - courseRouter.navigateToDownloadQueue(fm = fragmentManager) - } - }, - onVideoDownloadQualityClick = { - if (courseVideoViewModel.hasDownloadModelsInQueue()) { - courseVideoViewModel.onChangingVideoQualityWhileDownloading() - } else { - courseRouter.navigateToVideoQuality( - fragmentManager, - VideoQualityType.Download - ) - } - } - ) -} - -@Composable -private fun CourseVideosUI( - windowSize: WindowSize, - uiState: CourseVideosUIState, - uiMessage: UIMessage?, - courseTitle: String, - isCourseNestedListEnabled: Boolean, - videoSettings: VideoSettings, - onItemClick: (Block) -> Unit, - onExpandClick: (Block) -> Unit, - onSubSectionClick: (Block) -> Unit, - onDownloadClick: (Block) -> Unit, - onDownloadAllClick: (Boolean) -> Unit, - onDownloadQueueClick: () -> Unit, - onVideoDownloadQualityClick: () -> Unit -) { - val scaffoldState = rememberScaffoldState() - - Scaffold( - modifier = Modifier - .fillMaxSize(), - 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() - ) - ) - } - - val listBottomPadding by remember(key1 = windowSize) { - mutableStateOf( - windowSize.windowSizeValue( - expanded = PaddingValues(bottom = 24.dp), - compact = PaddingValues(bottom = 24.dp) - ) - ) - } - - val listPadding by remember(key1 = windowSize) { - mutableStateOf( - windowSize.windowSizeValue( - expanded = Modifier.padding(horizontal = 6.dp), - compact = Modifier.padding(horizontal = 24.dp) - ) - ) - } - - var isDownloadConfirmationShowed by rememberSaveable { - mutableStateOf(false) - } - - var isDeleteDownloadsConfirmationShowed by rememberSaveable { - mutableStateOf(false) - } - - var deleteDownloadBlock by rememberSaveable { - mutableStateOf(null) - } - - HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) - - Box( - modifier = Modifier - .fillMaxSize() - .padding(it) - .displayCutoutForLandscape(), - contentAlignment = Alignment.TopCenter - ) { - Surface( - modifier = screenWidth, - color = MaterialTheme.appColors.background - ) { - Box { - Column( - Modifier - .fillMaxSize() - ) { - 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) - ) - } - } - - is CourseVideosUIState.CourseData -> { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = listBottomPadding - ) { - if (uiState.downloadModelsSize.allCount > 0) { - item { - AllVideosDownloadItem( - downloadModelsSize = uiState.downloadModelsSize, - videoSettings = videoSettings, - onShowDownloadConfirmationDialog = { - isDownloadConfirmationShowed = true - }, - onDownloadAllClick = { isSwitched -> - if (isSwitched) { - isDeleteDownloadsConfirmationShowed = true - } else { - onDownloadAllClick(false) - } - }, - onDownloadQueueClick = onDownloadQueueClick, - onVideoDownloadQualityClick = onVideoDownloadQualityClick - ) - } - } - - 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 - } - - 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() - } - } - } - } - } - - CourseVideosUIState.Loading -> {} - } - } - } - } - } - - if (isDownloadConfirmationShowed) { - AlertDialog( - title = { - Text( - text = stringResource(id = R.string.course_download_big_files_confirmation_title) - ) - }, - text = { - Text( - text = stringResource(id = R.string.course_download_big_files_confirmation_text) - ) - }, - onDismissRequest = { - isDownloadConfirmationShowed = false - }, - confirmButton = { - TextButton( - onClick = { - isDownloadConfirmationShowed = false - onDownloadAllClick(false) - } - ) { - Text( - text = stringResource(id = org.openedx.core.R.string.core_confirm) - ) - } - }, - dismissButton = { - TextButton( - onClick = { - isDownloadConfirmationShowed = false - } - ) { - Text(text = stringResource(id = org.openedx.core.R.string.core_dismiss)) - } - } - ) - } - - if (isDeleteDownloadsConfirmationShowed) { - val downloadModelsSize = - (uiState as? CourseVideosUIState.CourseData)?.downloadModelsSize - val isDownloadedAllVideos = - downloadModelsSize?.isAllBlocksDownloadedOrDownloading == true && - downloadModelsSize.remainingCount == 0 - val dialogTextId = if (isDownloadedAllVideos) - R.string.course_delete_downloads_confirmation_text else - R.string.course_delete_while_downloading_confirmation_text - - AlertDialog( - title = { - Text( - text = stringResource(id = org.openedx.core.R.string.core_warning) - ) - }, - text = { - Text( - text = stringResource(id = dialogTextId, courseTitle) - ) - }, - onDismissRequest = { - isDeleteDownloadsConfirmationShowed = false - }, - confirmButton = { - TextButton( - onClick = { - isDeleteDownloadsConfirmationShowed = false - onDownloadAllClick(true) - } - ) { - Text( - text = stringResource(id = org.openedx.core.R.string.core_delete) - ) - } - }, - dismissButton = { - TextButton( - onClick = { - isDeleteDownloadsConfirmationShowed = false - } - ) { - Text(text = stringResource(id = org.openedx.core.R.string.core_cancel)) - } - } - ) - } - - if (deleteDownloadBlock != null) { - AlertDialog( - title = { - Text( - text = stringResource(id = org.openedx.core.R.string.core_warning) - ) - }, - text = { - Text( - text = stringResource( - id = R.string.course_delete_download_confirmation_text, - deleteDownloadBlock?.displayName ?: "" - ) - ) - }, - onDismissRequest = { - deleteDownloadBlock = null - }, - confirmButton = { - TextButton( - onClick = { - deleteDownloadBlock?.let { block -> - onDownloadClick(block) - } - deleteDownloadBlock = null - } - ) { - Text( - text = stringResource(id = org.openedx.core.R.string.core_delete) - ) - } - }, - dismissButton = { - TextButton( - onClick = { - deleteDownloadBlock = null - } - ) { - Text(text = stringResource(id = org.openedx.core.R.string.core_cancel)) - } - } - ) - } - } -} - -@Composable -private fun AllVideosDownloadItem( - downloadModelsSize: DownloadModelsSize, - videoSettings: VideoSettings, - onShowDownloadConfirmationDialog: () -> Unit, - onDownloadAllClick: (Boolean) -> Unit, - onDownloadQueueClick: () -> Unit, - onVideoDownloadQualityClick: () -> Unit -) { - val isDownloadingAllVideos = - downloadModelsSize.isAllBlocksDownloadedOrDownloading && - downloadModelsSize.remainingCount > 0 - val isDownloadedAllVideos = - downloadModelsSize.isAllBlocksDownloadedOrDownloading && - downloadModelsSize.remainingCount == 0 - - val downloadVideoTitleRes = when { - isDownloadingAllVideos -> org.openedx.core.R.string.core_video_downloading_to_device - isDownloadedAllVideos -> org.openedx.core.R.string.core_video_downloaded_to_device - else -> org.openedx.core.R.string.core_video_download_to_device - } - val downloadVideoSubTitle = - if (isDownloadedAllVideos) { - stringResource( - id = org.openedx.core.R.string.core_video_downloaded_subtitle, - downloadModelsSize.allCount, - downloadModelsSize.allSize.toFileSize() - ) - } else { - stringResource( - id = org.openedx.core.R.string.core_video_remaining_to_download, - downloadModelsSize.remainingCount, - downloadModelsSize.remainingSize.toFileSize() - ) - } - - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - onDownloadQueueClick() - }, - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - if (isDownloadingAllVideos) { - CircularProgressIndicator( - modifier = Modifier - .padding(start = 16.dp) - .size(24.dp), - color = MaterialTheme.appColors.primary, - strokeWidth = 2.dp - ) - } else { - Icon( - modifier = Modifier - .padding(start = 16.dp), - imageVector = Icons.Outlined.Videocam, - tint = MaterialTheme.appColors.onSurface, - contentDescription = null - ) - } - Column( - modifier = Modifier - .weight(1f) - .padding(8.dp) - ) { - Text( - text = stringResource(id = downloadVideoTitleRes), - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.titleMedium - ) - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = downloadVideoSubTitle, - color = MaterialTheme.appColors.textSecondary, - style = MaterialTheme.appTypography.labelMedium - ) - } - val isChecked = downloadModelsSize.isAllBlocksDownloadedOrDownloading - Switch( - modifier = Modifier - .padding(end = 16.dp), - checked = isChecked, - onCheckedChange = { - if (!isChecked) { - if ( - downloadModelsSize.remainingSize > AppDataConstants.DOWNLOADS_CONFIRMATION_SIZE - ) { - onShowDownloadConfirmationDialog() - } else { - onDownloadAllClick(false) - } - - } else { - onDownloadAllClick(true) - } - }, - colors = SwitchDefaults.colors( - checkedThumbColor = MaterialTheme.appColors.primary, - checkedTrackColor = MaterialTheme.appColors.primary - ) - ) - } - if (isDownloadingAllVideos) { - val progress = 1 - downloadModelsSize.remainingSize.toFloat() / downloadModelsSize.allSize - - val animatedProgress by animateFloatAsState( - targetValue = progress, - animationSpec = tween(durationMillis = 2000, easing = LinearEasing), - label = "ProgressAnimation" - ) - LinearProgressIndicator( - modifier = Modifier - .fillMaxWidth(), - progress = animatedProgress - ) - } - Divider() - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - onVideoDownloadQualityClick() - }, - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - modifier = Modifier - .padding(start = 16.dp), - imageVector = Icons.Outlined.Settings, - tint = MaterialTheme.appColors.onSurface, - contentDescription = null - ) - Column( - modifier = Modifier - .weight(1f) - .padding(8.dp) - ) { - Text( - text = stringResource(id = org.openedx.core.R.string.core_video_download_quality), - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.titleMedium - ) - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = stringResource(id = videoSettings.videoDownloadQuality.titleResId), - color = MaterialTheme.appColors.textSecondary, - style = MaterialTheme.appTypography.labelMedium - ) - } - Icon( - modifier = Modifier - .padding(end = 16.dp), - imageVector = Icons.Filled.ChevronRight, - tint = MaterialTheme.appColors.onSurface, - contentDescription = "Expandable Arrow" - ) - } - Divider() -} - -@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun CourseVideosScreenPreview() { - OpenEdXTheme { - CourseVideosUI( - windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - uiMessage = null, - uiState = CourseVideosUIState.CourseData( - mockCourseStructure, - emptyMap(), - mapOf(), - mapOf(), - mapOf(), - DownloadModelsSize( - isAllBlocksDownloadedOrDownloading = false, - remainingCount = 0, - remainingSize = 0, - allCount = 1, - allSize = 0 - ) - ), - courseTitle = "", - isCourseNestedListEnabled = false, - onItemClick = { }, - onExpandClick = { }, - onSubSectionClick = { }, - videoSettings = VideoSettings.default, - onDownloadClick = {}, - onDownloadAllClick = {}, - onDownloadQueueClick = {}, - onVideoDownloadQualityClick = {} - ) - } -} - -@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun CourseVideosScreenEmptyPreview() { - OpenEdXTheme { - CourseVideosUI( - windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - uiMessage = null, - uiState = CourseVideosUIState.Empty( - "This course does not include any videos." - ), - courseTitle = "", - isCourseNestedListEnabled = false, - onItemClick = { }, - onExpandClick = { }, - onSubSectionClick = { }, - videoSettings = VideoSettings.default, - onDownloadClick = {}, - onDownloadAllClick = {}, - onDownloadQueueClick = {}, - onVideoDownloadQualityClick = {} - ) - } -} - -@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 CourseVideosScreenTabletPreview() { - OpenEdXTheme { - CourseVideosUI( - windowSize = WindowSize(WindowType.Medium, WindowType.Medium), - uiMessage = null, - uiState = CourseVideosUIState.CourseData( - mockCourseStructure, - emptyMap(), - mapOf(), - mapOf(), - mapOf(), - DownloadModelsSize( - isAllBlocksDownloadedOrDownloading = false, - remainingCount = 0, - remainingSize = 0, - allCount = 0, - allSize = 0 - ) - ), - courseTitle = "", - isCourseNestedListEnabled = false, - onItemClick = { }, - onExpandClick = { }, - onSubSectionClick = { }, - videoSettings = VideoSettings.default, - onDownloadClick = {}, - onDownloadAllClick = {}, - onDownloadQueueClick = {}, - onVideoDownloadQualityClick = {} - ) - } -} - - -private val mockChapterBlock = Block( - id = "id", - blockId = "blockId", - lmsWebUrl = "lmsWebUrl", - legacyWebUrl = "legacyWebUrl", - studentViewUrl = "studentViewUrl", - type = BlockType.CHAPTER, - displayName = "Chapter", - graded = false, - studentViewData = null, - studentViewMultiDevice = false, - blockCounts = BlockCounts(1), - descendants = emptyList(), - descendantsType = BlockType.CHAPTER, - completion = 0.0, - containsGatedContent = false -) - -private val mockSequentialBlock = Block( - id = "id", - blockId = "blockId", - lmsWebUrl = "lmsWebUrl", - legacyWebUrl = "legacyWebUrl", - studentViewUrl = "studentViewUrl", - type = BlockType.SEQUENTIAL, - displayName = "Sequential", - graded = false, - studentViewData = null, - studentViewMultiDevice = false, - blockCounts = BlockCounts(1), - descendants = emptyList(), - descendantsType = BlockType.SEQUENTIAL, - completion = 0.0, - containsGatedContent = false -) - -private val mockCourseStructure = CourseStructure( - root = "", - blockData = listOf(mockSequentialBlock, mockChapterBlock), - id = "id", - name = "Course name", - number = "", - org = "Org", - start = Date(), - startDisplay = "", - startType = "", - end = Date(), - coursewareAccess = CoursewareAccess( - true, - "", - "", - "", - "", - "" - ), - media = null, - certificate = null, - isSelfPaced = false -) diff --git a/course/src/main/java/org/openedx/course/presentation/unit/NotAvailableUnitFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/NotAvailableUnitFragment.kt new file mode 100644 index 000000000..9e4dbde3e --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/unit/NotAvailableUnitFragment.kt @@ -0,0 +1,210 @@ +package org.openedx.course.presentation.unit + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +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.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 +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalUriHandler +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.unit.dp +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +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.presentation.WindowSize +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue +import org.openedx.core.R as coreR +import org.openedx.course.R as courseR + +class NotAvailableUnitFragment : Fragment() { + + private var blockId: String? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + blockId = requireArguments().getString(ARG_BLOCK_ID, "") + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val windowSize = rememberWindowSize() + 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 = coreR.string.core_not_available_offline) + description = + stringResource(id = coreR.string.core_explore_other_parts_when_reconnect) + } + + NotAvailableUnitType.NOT_DOWNLOADED -> { + title = stringResource(id = coreR.string.core_not_downloaded) + description = + stringResource(id = coreR.string.core_explore_other_parts_when_reconnect_or_download) + } + + else -> { + return@OpenEdXTheme + } + } + NotAvailableUnitScreen( + windowSize = windowSize, + title = title, + description = description, + buttonAction = buttonAction + ) + } + } + } + + 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, + unitType: NotAvailableUnitType, + ): NotAvailableUnitFragment { + val fragment = NotAvailableUnitFragment() + fragment.arguments = bundleOf( + ARG_BLOCK_ID to blockId, + ARG_BLOCK_URL to blockUrl, + ARG_UNIT_TYPE to unitType + ) + return fragment + } + } +} + +@Composable +private fun NotAvailableUnitScreen( + windowSize: WindowSize, + title: String, + description: String, + buttonAction: (() -> Unit)? = null, +) { + val scaffoldState = rememberScaffoldState() + val scrollState = rememberScrollState() + Scaffold( + modifier = Modifier.fillMaxSize(), + scaffoldState = scaffoldState + ) { + val contentWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.width(326.dp), + compact = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + ) + ) + } + + Box( + Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier + .verticalScroll(scrollState) + .padding(it) + .then(contentWidth), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + modifier = Modifier.size(100.dp), + painter = painterResource(id = courseR.drawable.course_ic_not_supported_block), + contentDescription = null, + tint = MaterialTheme.appColors.textPrimary + ) + Spacer(Modifier.height(36.dp)) + Text( + modifier = Modifier.fillMaxWidth(), + text = title, + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textPrimary, + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(12.dp)) + Text( + modifier = Modifier.fillMaxWidth(), + text = description, + style = MaterialTheme.appTypography.bodyLarge, + color = MaterialTheme.appColors.textPrimary, + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(40.dp)) + 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)) + } + } + } + } +} 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/NotSupportedUnitFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/NotSupportedUnitFragment.kt deleted file mode 100644 index bdf5dcd8b..000000000 --- a/course/src/main/java/org/openedx/course/presentation/unit/NotSupportedUnitFragment.kt +++ /dev/null @@ -1,157 +0,0 @@ -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.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.* -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.Modifier -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.LocalUriHandler -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.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.course.R as courseR - -class NotSupportedUnitFragment : Fragment() { - - private var blockId: String? = null - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - blockId = requireArguments().getString(ARG_BLOCK_ID, "") - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ) = ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - OpenEdXTheme { - val windowSize = rememberWindowSize() - NotSupportedUnitScreen( - windowSize = windowSize, - uri = requireArguments().getString(ARG_BLOCK_URL, "") - ) - } - } - } - - companion object { - private const val ARG_BLOCK_ID = "blockId" - private const val ARG_BLOCK_URL = "blockUrl" - fun newInstance( - blockId: String, - blockUrl: String - ): NotSupportedUnitFragment { - val fragment = NotSupportedUnitFragment() - fragment.arguments = bundleOf( - ARG_BLOCK_ID to blockId, - ARG_BLOCK_URL to blockUrl - ) - return fragment - } - } - -} - -@Composable -private fun NotSupportedUnitScreen( - windowSize: WindowSize, - uri: String -) { - val scaffoldState = rememberScaffoldState() - val uriHandler = LocalUriHandler.current - val scrollState = rememberScrollState() - Scaffold( - modifier = Modifier.fillMaxSize(), - scaffoldState = scaffoldState - ) { - - val contentWidth by remember(key1 = windowSize) { - mutableStateOf( - windowSize.windowSizeValue( - expanded = Modifier.width(326.dp), - compact = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) - ) - ) - } - - Box( - Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Column( - modifier = Modifier - .verticalScroll(scrollState) - .padding(it) - .then(contentWidth), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - modifier = Modifier.size(100.dp), - painter = painterResource(id = courseR.drawable.course_ic_not_supported_block), - contentDescription = null, - tint = MaterialTheme.appColors.textPrimary - ) - Spacer(Modifier.height(36.dp)) - Text( - modifier = Modifier.fillMaxWidth(), - text = stringResource(id = courseR.string.course_this_interactive_component), - style = MaterialTheme.appTypography.titleLarge, - color = MaterialTheme.appColors.textPrimary, - textAlign = TextAlign.Center - ) - Spacer(Modifier.height(12.dp)) - Text( - modifier = Modifier.fillMaxWidth(), - text = stringResource(id = courseR.string.course_explore_other_parts), - 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 - ) - } - Spacer(Modifier.height(20.dp)) - } - } - } -} \ No newline at end of file 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..2934fba13 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,15 @@ 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.core.module.db.DownloadModel +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 +25,125 @@ 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 - ) - } - } + isBlockNotDownloaded(block, noNetwork, offlineUrl) -> { + createNotAvailableUnitFragment(block, NotAvailableUnitType.NOT_DOWNLOADED) + } + + isBlockOfflineUnsupported(block, noNetwork) -> { + createNotAvailableUnitFragment(block, NotAvailableUnitType.OFFLINE_UNSUPPORTED) } - (block.isDiscussionBlock && block.studentViewData?.topicId.isNullOrEmpty().not()) -> { - DiscussionThreadsFragment.newInstance( - DiscussionTopicsViewModel.TOPIC, - viewModel.courseId, - block.studentViewData?.topicId ?: "", - block.displayName, - FragmentViewType.MAIN_CONTENT.name, - block.id - ) + isVideoBlockAvailable(block) -> { + createVideoFragment(block) } - block.studentViewMultiDevice.not() -> { - NotSupportedUnitFragment.newInstance( - block.id, - block.lmsWebUrl - ) + isDiscussionBlockAvailable(block) -> { + createDiscussionFragment(block) } - block.isHTMLBlock || - block.isProblemBlock || - block.isOpenAssessmentBlock || - block.isDragAndDropBlock || - block.isWordCloudBlock || - block.isLTIConsumerBlock || - block.isSurveyBlock -> { - HtmlUnitFragment.newInstance(block.id, block.studentViewUrl) + isSupportedHtmlBlock(block) -> { + createHtmlUnitFragment(block, downloadedModel, noNetwork, offlineUrl) } else -> { - NotSupportedUnitFragment.newInstance( - block.id, - block.lmsWebUrl - ) + createNotAvailableUnitFragment(block, NotAvailableUnitType.MOBILE_UNSUPPORTED) } } } + + private fun isBlockNotDownloaded(block: Block, noNetwork: Boolean, offlineUrl: String): Boolean { + return noNetwork && block.isDownloadable && offlineUrl.isEmpty() + } + + private fun isBlockOfflineUnsupported(block: Block, noNetwork: Boolean): Boolean { + return noNetwork && !block.isDownloadable + } + + private fun isVideoBlockAvailable(block: Block): Boolean { + val encodedVideos = block.studentViewData?.encodedVideos + val hasVideo = encodedVideos?.hasVideoUrl == true || encodedVideos?.hasYoutubeUrl == true + return block.isVideoBlock && hasVideo + } + + private fun isDiscussionBlockAvailable(block: Block): Boolean { + val topicId = block.studentViewData?.topicId + return block.isDiscussionBlock && !topicId.isNullOrEmpty() + } + + private fun isSupportedHtmlBlock(block: Block): Boolean { + return block.isHTMLBlock || + block.isProblemBlock || + block.isOpenAssessmentBlock || + block.isDragAndDropBlock || + block.isWordCloudBlock || + block.isLTIConsumerBlock || + block.isSurveyBlock + } + + private fun createHtmlUnitFragment( + block: Block, + downloadedModel: DownloadModel?, + noNetwork: Boolean, + offlineUrl: String + ): Fragment { + val lastModified = if (downloadedModel != null && noNetwork) { + downloadedModel.lastModified ?: "" + } else { + "" + } + return HtmlUnitFragment.newInstance( + block.id, + block.studentViewUrl, + viewModel.courseId, + offlineUrl, + lastModified + ) + } + + 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..b7131b993 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 @@ -6,9 +6,22 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.activity.OnBackPressedCallback +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.Divider import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text 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 @@ -16,6 +29,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.os.bundleOf @@ -31,23 +45,24 @@ 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.domain.model.Block import org.openedx.core.presentation.global.InsetHolder 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.databinding.FragmentCourseUnitContainerBinding import org.openedx.course.presentation.ChapterEndFragmentDialog import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.DialogListener import org.openedx.course.presentation.ui.CourseUnitToolbar +import org.openedx.course.presentation.ui.CourseVideoItem import org.openedx.course.presentation.ui.HorizontalPageIndicator 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) { @@ -58,7 +73,8 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta private val viewModel by viewModel { parametersOf( requireArguments().getString(ARG_COURSE_ID, ""), - requireArguments().getString(UNIT_ID, "") + requireArguments().getString(UNIT_ID, ""), + requireArguments().serializable(ARG_MODE) ) } @@ -76,9 +92,9 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta val blocks = viewModel.getUnitBlocks() blocks.getOrNull(position)?.let { currentBlock -> val encodedVideo = currentBlock.studentViewData?.encodedVideos - binding.mediaRouteButton.isVisible = currentBlock.type == BlockType.VIDEO - && encodedVideo?.hasNonYoutubeVideo == true - && !encodedVideo.videoUrl.endsWith(".m3u8") + binding.mediaRouteButton.isVisible = currentBlock.type == BlockType.VIDEO && + encodedVideo?.hasNonYoutubeVideo == true && + !encodedVideo.videoUrl.endsWith(".m3u8") } } } @@ -97,7 +113,7 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta fm = requireActivity().supportFragmentManager, courseId = viewModel.courseId, unitId = it.id, - mode = requireArguments().serializable(ARG_MODE)!! + mode = viewModel.mode ) } } @@ -134,8 +150,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(componentId) viewModel.courseUnitContainerShowedEvent() } @@ -151,15 +166,32 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + setupViewPagerInsets() + setupMediaRouteButton() + initViewPager() + handleSavedInstanceState(savedInstanceState) + setupNavigationBar() + setupProgressIndicators() + setupBackButton() + setupSubSectionUnits() + setupVideoList() + checkUnitsListShown() + setupChapterEndDialogListener() + } + + private fun setupViewPagerInsets() { val insetHolder = requireActivity() as InsetHolder val containerParams = binding.viewPager.layoutParams as ConstraintLayout.LayoutParams containerParams.bottomMargin = insetHolder.bottomInset binding.viewPager.layoutParams = containerParams + } - binding.mediaRouteButton.setAlwaysVisible(true) + private fun setupMediaRouteButton() { + binding.mediaRouteButton.visibility = View.VISIBLE CastButtonFactory.setUpMediaRouteButton(requireContext(), binding.mediaRouteButton) + } - initViewPager() + private fun handleSavedInstanceState(savedInstanceState: Bundle?) { if (savedInstanceState == null && componentId.isEmpty()) { val currentBlockIndex = viewModel.getUnitBlocks().indexOfFirst { viewModel.getCurrentBlock().id == it.id @@ -168,7 +200,7 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta binding.viewPager.currentItem = currentBlockIndex } } - if (componentId.isEmpty().not()) { + if (componentId.isNotEmpty()) { lifecycleScope.launch(Dispatchers.Main) { viewModel.indexInContainer.value?.let { index -> binding.viewPager.setCurrentItem(index, true) @@ -177,11 +209,15 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta requireArguments().putString(ARG_COMPONENT_ID, "") componentId = "" } + } + private fun setupNavigationBar() { binding.cvNavigationBar.setContent { NavigationBar() } + } + private fun setupProgressIndicators() { if (viewModel.isCourseUnitProgressEnabled) { binding.horizontalProgress.setContent { OpenEdXTheme { @@ -191,7 +227,8 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta HorizontalPageIndicator( blocks = descendantsBlocks, selectedPage = index, - completedAndSelectedColor = MaterialTheme.appColors.componentHorizontalProgressCompletedAndSelected, + completedAndSelectedColor = + MaterialTheme.appColors.componentHorizontalProgressCompletedAndSelected, completedColor = MaterialTheme.appColors.componentHorizontalProgressCompleted, selectedColor = MaterialTheme.appColors.componentHorizontalProgressSelected, defaultColor = MaterialTheme.appColors.componentHorizontalProgressDefault @@ -199,7 +236,6 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta } } binding.horizontalProgress.isVisible = true - } else { binding.cvCount.setContent { OpenEdXTheme { @@ -213,35 +249,35 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta selectedPage = index, defaultRadius = 3.dp, selectedLength = 5.dp, - modifier = Modifier - .width(24.dp) + modifier = Modifier.width(24.dp) ) } } binding.cvCount.isVisible = true } + } + private fun setupBackButton() { binding.btnBack.setContent { val title = if (viewModel.isCourseExpandableSectionsEnabled) { val unitBlocks by viewModel.subSectionUnitBlocks.collectAsState() unitBlocks.firstOrNull()?.let { viewModel.getSubSectionBlock(it.id).displayName } ?: "" - } else { val index by viewModel.indexInContainer.observeAsState(0) val descendantsBlocks by viewModel.descendantsBlocks.collectAsState() - descendantsBlocks[index].displayName + descendantsBlocks.getOrNull(index)?.displayName ?: "" } CourseUnitToolbar( title = title, - onBackClick = { - navigateToParentFragment() - } + onBackClick = { navigateToParentFragment() } ) } + } + private fun setupSubSectionUnits() { if (viewModel.isCourseExpandableSectionsEnabled) { binding.subSectionUnitsTitle.setContent { val unitBlocks by viewModel.subSectionUnitBlocks.collectAsState() @@ -263,40 +299,82 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta binding.subSectionUnitsList.setContent { val unitBlocks by viewModel.subSectionUnitBlocks.collectAsState() - val selectedUnitIndex = unitBlocks.indexOfFirst { it.id == viewModel.unitId } - OpenEdXTheme { - SubSectionUnitsList( - unitBlocks = unitBlocks, - selectedUnitIndex = selectedUnitIndex - ) { index, unit -> - if (index != selectedUnitIndex) { - router.navigateToCourseContainer( - fm = requireActivity().supportFragmentManager, - courseId = viewModel.courseId, - unitId = unit.id, - mode = requireArguments().serializable(ARG_MODE)!! - ) - - } else { - handleUnitsClick() + // If there is more than one unit in the section, show the list + if (unitBlocks.size > 1) { + val selectedUnitIndex = unitBlocks.indexOfFirst { it.id == viewModel.unitId } + OpenEdXTheme { + SubSectionUnitsList( + unitBlocks = unitBlocks, + selectedUnitIndex = selectedUnitIndex + ) { index, unit -> + if (index != selectedUnitIndex) { + router.navigateToCourseContainer( + fm = requireActivity().supportFragmentManager, + courseId = viewModel.courseId, + unitId = unit.id, + mode = viewModel.mode + ) + } else { + handleUnitsClick() + } } } } } - } else { binding.subSectionUnitsTitle.isGone = true } + } - if (viewModel.unitsListShowed.value == true) handleUnitsClick() + private fun setupVideoList() { + binding.videoList?.setContent { + OpenEdXTheme { + Column { + VideoList( + onVideoClick = { block -> + val currentBlock = viewModel.currentBlock.value + if (currentBlock?.id != block.id) { + viewModel.setSelectedVideoBlock(block) + updateViewPagerAdapter() + val blockIndex = + viewModel.getUnitBlocks().indexOfFirst { it.id == block.id } + if (blockIndex != -1) { + binding.viewPager.currentItem = blockIndex + } + } + } + ) + Spacer(modifier = Modifier.height(8.dp)) + Divider() + if (viewModel.mode == CourseViewMode.VIDEOS) { + Spacer(modifier = Modifier.height(16.dp)) + HierarchyPathText() + } + Spacer(modifier = Modifier.height(8.dp)) + } + } + } + } - val chapterEndDialogTag = ChapterEndFragmentDialog::class.simpleName - (requireActivity().supportFragmentManager - .findFragmentByTag(chapterEndDialogTag) as? ChapterEndFragmentDialog)?.let { fragment -> - fragment.listener = dialogListener + private fun updateViewPagerAdapter() { + adapter = CourseUnitContainerAdapter(this, viewModel.getUnitBlocks(), viewModel) + binding.viewPager.adapter = adapter + } + + private fun checkUnitsListShown() { + if (viewModel.unitsListShowed.value == true) { + handleUnitsClick() } } + private fun setupChapterEndDialogListener() { + val chapterEndDialogTag = ChapterEndFragmentDialog::class.simpleName + (requireActivity().supportFragmentManager.findFragmentByTag(chapterEndDialogTag) as? ChapterEndFragmentDialog) + ?.let { fragment -> + fragment.listener = dialogListener + } + } + override fun onResume() { super.onResume() activity?.onBackPressedDispatcher?.addCallback(onBackPressedCallback) @@ -401,7 +479,6 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta binding.subSectionUnitsList.visibility = View.GONE binding.subSectionUnitsBg.visibility = View.GONE viewModel.setUnitsListVisibility(false) - } else { binding.subSectionUnitsList.visibility = View.VISIBLE binding.subSectionUnitsBg.visibility = View.VISIBLE @@ -411,42 +488,173 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta @Composable private fun NavigationBar() { - OpenEdXTheme { - var nextButtonText by rememberSaveable { - mutableStateOf(viewModel.nextButtonText) - } - var hasNextBlock by rememberSaveable { - mutableStateOf(viewModel.hasNextBlock) - } - var hasPrevBlock by rememberSaveable { - mutableStateOf(viewModel.hasNextBlock) + if (viewModel.mode == CourseViewMode.VIDEOS) { + OpenEdXTheme { + val videoBlocks by viewModel.videoList.collectAsState() + val currentBlock by viewModel.currentBlock.collectAsState() + val hasNextBlock = videoBlocks.lastOrNull()?.id != currentBlock?.id + val nextButtonText = if (hasNextBlock) { + getString(R.string.course_navigation_next) + } else { + getString(R.string.course_navigation_finish) + } + NavigationUnitsButtons( + hasPrevBlock = videoBlocks.firstOrNull()?.id != currentBlock?.id, + nextButtonText = nextButtonText, + hasNextBlock = hasNextBlock, + isVerticalNavigation = false, + showFinishButton = false, + onPrevClick = { + if (!restrictDoubleClick()) { + val currentIndex = + videoBlocks.indexOfFirst { it.id == currentBlock?.id } + if (currentIndex > 0) { + val target = videoBlocks[currentIndex - 1] + viewModel.setSelectedVideoBlock(target) + updateViewPagerAdapter() + val blockIndex = + viewModel.getUnitBlocks().indexOfFirst { it.id == target.id } + if (blockIndex != -1) { + binding.viewPager.setCurrentItem(blockIndex, true) + } + } + } + }, + onNextClick = { + if (!restrictDoubleClick()) { + val currentIndex = + videoBlocks.indexOfFirst { it.id == currentBlock?.id } + if (currentIndex != -1 && currentIndex < videoBlocks.lastIndex) { + val target = videoBlocks[currentIndex + 1] + viewModel.setSelectedVideoBlock(target) + updateViewPagerAdapter() + val blockIndex = + viewModel.getUnitBlocks().indexOfFirst { it.id == target.id } + if (blockIndex != -1) { + binding.viewPager.setCurrentItem(blockIndex, true) + } + } + } + } + ) } + } else { + OpenEdXTheme { + var nextButtonText by rememberSaveable { + mutableStateOf(viewModel.nextButtonText) + } + var hasNextBlock by rememberSaveable { + mutableStateOf(viewModel.hasNextBlock) + } + var hasPrevBlock by rememberSaveable { + mutableStateOf(viewModel.hasNextBlock) + } + + updateNavigationButtons { next, hasPrev, hasNext -> + nextButtonText = next + hasPrevBlock = hasPrev + hasNextBlock = hasNext + } - updateNavigationButtons { next, hasPrev, hasNext -> - nextButtonText = next - hasPrevBlock = hasPrev - hasNextBlock = hasNext + NavigationUnitsButtons( + hasPrevBlock = hasPrevBlock, + nextButtonText = nextButtonText, + hasNextBlock = hasNextBlock, + isVerticalNavigation = !viewModel.isCourseUnitProgressEnabled, + onPrevClick = { + handlePrevClick { next, hasPrev, hasNext -> + nextButtonText = next + hasPrevBlock = hasPrev + hasNextBlock = hasNext + } + }, + onNextClick = { + handleNextClick { next, hasPrev, hasNext -> + nextButtonText = next + hasPrevBlock = hasPrev + hasNextBlock = hasNext + } + } + ) } + } + } - NavigationUnitsButtons( - hasPrevBlock = hasPrevBlock, - nextButtonText = nextButtonText, - hasNextBlock = hasNextBlock, - isVerticalNavigation = !viewModel.isCourseUnitProgressEnabled, - onPrevClick = { - handlePrevClick { next, hasPrev, hasNext -> - nextButtonText = next - hasPrevBlock = hasPrev - hasNextBlock = hasNext + @Composable + private fun VideoList( + onVideoClick: (Block) -> Unit + ) { + val videoBlocks by viewModel.videoList.collectAsState() + val videoPreview by viewModel.videoPreview.collectAsState() + val videoProgress by viewModel.videoProgress.collectAsState() + val currentBlock by viewModel.currentBlock.collectAsState() + val rowState = rememberLazyListState() + + LaunchedEffect(currentBlock) { + rowState.animateScrollToItem(videoBlocks.indexOf(currentBlock)) + } + + if (videoBlocks.isNotEmpty()) { + LazyRow( + state = rowState, + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp) + ) { + items(videoBlocks) { block -> + val isSelectedBlock = block.id == currentBlock?.id + val playButtonSize = if (isSelectedBlock) { + 0.dp + } else { + 14.dp + } + val borderColor = if (isSelectedBlock) { + MaterialTheme.appColors.primary + } else { + null } - }, - onNextClick = { - handleNextClick { next, hasPrev, hasNext -> - nextButtonText = next - hasPrevBlock = hasPrev - hasNextBlock = hasNext + val borderWidth = if (isSelectedBlock) { + 3.dp + } else { + 1.dp } + CourseVideoItem( + modifier = Modifier + .width(112.dp) + .height(63.dp), + videoBlock = block, + preview = videoPreview[block.id], + progress = if (isSelectedBlock) { + 0f + } else { + videoProgress[block.id] ?: 0f + }, + onClick = { + onVideoClick(block) + }, + titleStyle = MaterialTheme.appTypography.labelSmall, + playButtonSize = playButtonSize, + borderColor = borderColor, + borderWidth = borderWidth + ) } + } + } + } + + @Composable + private fun HierarchyPathText() { + val hierarchyPath by viewModel.hierarchyPath.collectAsState() + + if (hierarchyPath.isNotEmpty()) { + Text( + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + text = hierarchyPath, + style = MaterialTheme.appTypography.bodySmall, + color = MaterialTheme.appColors.textDark, + maxLines = 2, ) } } 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..81382f9f3 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,37 +9,44 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import org.openedx.core.BaseViewModel +import kotlinx.coroutines.withContext import org.openedx.core.BlockType import org.openedx.core.config.Config +import org.openedx.core.domain.helper.VideoPreviewHelper import org.openedx.core.domain.model.Block -import org.openedx.core.extension.clearAndAddAll -import org.openedx.core.extension.indexOfFirstFromIndex +import org.openedx.core.extension.safeDivBy 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 +import org.openedx.core.utils.VideoPreview 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, val unitId: String, + val mode: CourseViewMode, private val config: Config, private val interactor: CourseInteractor, private val notifier: CourseNotifier, private val analytics: CourseAnalytics, + private val networkConnection: NetworkConnection, + private val videoPreviewHelper: VideoPreviewHelper, ) : 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 @@ -47,14 +54,16 @@ class CourseUnitContainerViewModel( val isFirstIndexInContainer: Boolean get() { - return _descendantsBlocks.value.firstOrNull() == - _descendantsBlocks.value.getOrNull(currentIndex) + return _descendantsBlocks.value.firstOrNull() == _descendantsBlocks.value.getOrNull( + currentIndex + ) } val isLastIndexInContainer: Boolean get() { - return _descendantsBlocks.value.lastOrNull() == - _descendantsBlocks.value.getOrNull(currentIndex) + return _descendantsBlocks.value.lastOrNull() == _descendantsBlocks.value.getOrNull( + currentIndex + ) } private val _verticalBlockCounts = MutableLiveData() @@ -72,27 +81,50 @@ class CourseUnitContainerViewModel( private val _subSectionUnitBlocks = MutableStateFlow>(listOf()) val subSectionUnitBlocks = _subSectionUnitBlocks.asStateFlow() + private val _videoList = MutableStateFlow>(listOf()) + val videoList = _videoList.asStateFlow() + + private val _videoPreview = MutableStateFlow>(emptyMap()) + val videoPreview = _videoPreview.asStateFlow() + + private val _videoProgress = MutableStateFlow>(emptyMap()) + val videoProgress = _videoProgress.asStateFlow() + + private val _currentBlock = MutableStateFlow(null) + val currentBlock = _currentBlock.asStateFlow() + + private val _hierarchyPath = MutableStateFlow("") + val hierarchyPath = _hierarchyPath.asStateFlow() + var nextButtonText = "" 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) { - currentMode = mode - try { - val courseStructure = when (mode) { - CourseViewMode.FULL -> interactor.getCourseStructureFromCache() - CourseViewMode.VIDEOS -> interactor.getCourseStructureForVideos() + val hasNetworkConnection: Boolean + get() = networkConnection.isOnline() + + fun loadBlocks(componentId: String = "") { + 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) + if (mode == CourseViewMode.VIDEOS) { + _videoList.value = getAllVideoBlocks() + loadVideoData() + } + setupCurrentIndex(componentId) + } catch (e: Exception) { + e.printStackTrace() } - val blocks = courseStructure.blockData - courseName = courseStructure.name - this.blocks.clearAndAddAll(blocks) - } catch (e: Exception) { - //ignore e.printStackTrace() } } @@ -103,8 +135,7 @@ class CourseUnitContainerViewModel( notifier.notifier.collect { event -> if (event is CourseStructureUpdated) { if (event.courseId != courseId) return@collect - - currentMode?.let { loadBlocks(it) } + loadBlocks(currentComponentId) val blockId = blocks[currentVerticalIndex].id _subSectionUnitBlocks.value = getSubSectionUnitBlocks(blocks, getSubSectionId(blockId)) @@ -113,10 +144,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 @@ -131,6 +162,9 @@ class CourseUnitContainerViewModel( _subSectionUnitBlocks.value = getSubSectionUnitBlocks(blocks, getSubSectionId(unitId)) + if (_descendantsBlocks.value.isEmpty()) { + _descendantsBlocks.value = listOf(block) + } } else { setNextVerticalIndex() } @@ -141,6 +175,8 @@ class CourseUnitContainerViewModel( currentIndex = _descendantsBlocks.value.indexOfFirst { it.id == componentId } _indexInContainer.value = currentIndex } + // Initialize current block + _currentBlock.value = getCurrentBlock() return } } @@ -163,7 +199,9 @@ class CourseUnitContainerViewModel( if (blockDescendant.type == BlockType.VERTICAL) { resultList.add(blockDescendant.copy(type = getUnitType(blockDescendant.descendants))) } - } else continue + } else { + continue + } } return resultList } @@ -208,7 +246,10 @@ class CourseUnitContainerViewModel( } fun getCurrentBlock(): Block { - return blocks[currentIndex] + val block = _descendantsBlocks.value.getOrNull(currentIndex) ?: blocks[currentVerticalIndex] + _currentBlock.value = block + _hierarchyPath.value = buildHierarchyPath(block) + return block } fun moveToNextBlock(): Block? { @@ -225,6 +266,8 @@ class CourseUnitContainerViewModel( if (currentVerticalIndex != -1) { _indexInContainer.value = currentIndex } + _currentBlock.value = block + _hierarchyPath.value = buildHierarchyPath(block) return block } return null @@ -286,4 +329,110 @@ class CourseUnitContainerViewModel( fun setUnitsListVisibility(isVisible: Boolean) { _unitsListShowed.value = isVisible } + + fun getAllVideoBlocks(): List = blocks.filter { it.type == BlockType.VIDEO } + + fun setSelectedVideoBlock(videoBlock: Block) { + // Find the parent vertical block for this video + val verticalBlock = findParentBlock(videoBlock.id) ?: return + val verticalIndex = blocks.indexOfFirst { it.id == verticalBlock.id } + if (verticalIndex == -1) return + + // Update vertical index + currentVerticalIndex = verticalIndex + + // Find and update section index + val sectionIndex = blocks.indexOfFirst { + it.descendants.contains(blocks[currentVerticalIndex].id) + } + if (sectionIndex != currentSectionIndex) { + currentSectionIndex = sectionIndex + blocks.getOrNull(currentSectionIndex)?.id?.let { + sendCourseSectionChanged(it) + } + } + + // Update descendants blocks for the new vertical + val verticalBlockData = blocks[currentVerticalIndex] + if (verticalBlockData.descendants.isNotEmpty() || verticalBlockData.isGated()) { + _descendantsBlocks.value = + verticalBlockData.descendants.mapNotNull { descendant -> + blocks.firstOrNull { descendant == it.id } + } + _subSectionUnitBlocks.value = + getSubSectionUnitBlocks(blocks, getSubSectionId(verticalBlockData.id)) + + if (_descendantsBlocks.value.isEmpty()) { + _descendantsBlocks.value = listOf(verticalBlockData) + } + } + + // Update vertical block counts + _verticalBlockCounts.value = verticalBlockData.descendants.size + + // Find the video block index in the new descendants and set it as current + val blockIndex = _descendantsBlocks.value.indexOfFirst { it.id == videoBlock.id } + if (blockIndex != -1) { + currentIndex = blockIndex + _indexInContainer.value = currentIndex + _currentBlock.value = videoBlock + _hierarchyPath.value = buildHierarchyPath(videoBlock) + } + viewModelScope.launch { + loadVideoProgress() + } + } + + private fun findParentBlock(childId: String): Block? { + return blocks.firstOrNull { it.descendants.contains(childId) } + } + + private fun loadVideoData() { + viewModelScope.launch { + loadVideoPreview() + loadVideoProgress() + } + } + + private suspend fun loadVideoProgress() { + val videoBlocks = getAllVideoBlocks() + val videoProgress = videoBlocks.associate { block -> + val videoProgressEntity = interactor.getVideoProgress(block.id) + val progress = videoProgressEntity.videoTime?.toFloat() + ?.safeDivBy(videoProgressEntity.duration?.toFloat() ?: 0f) ?: 0f + block.id to progress + } + _videoProgress.value = videoProgress + } + + private suspend fun loadVideoPreview() { + val videoBlocks = getAllVideoBlocks() + val videoPreview = withContext(Dispatchers.IO) { + videoPreviewHelper.getVideoPreviews(videoBlocks) + } + _videoPreview.value = videoPreview + } + + private fun buildHierarchyPath(block: Block): String { + val pathComponents = mutableListOf() + + val verticalBlock = findParentBlock(block.id) + verticalBlock?.let { vertical -> + // Vertical name + pathComponents.add(0, vertical.displayName) + // Find the parent Sequential block (Subsection) + val sequentialBlock = findParentBlock(vertical.id) + sequentialBlock?.let { sequential -> + pathComponents.add(0, sequential.displayName) + + // Find the parent Chapter block (Section) + val chapterBlock = findParentBlock(sequential.id) + chapterBlock?.let { chapter -> + pathComponents.add(0, chapter.displayName) + } + } + } + + return pathComponents.joinToString(" > ") + } } diff --git a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseViewMode.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseViewMode.kt new file mode 100644 index 000000000..1fcace78b --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseViewMode.kt @@ -0,0 +1,6 @@ +package org.openedx.course.presentation.unit.container + +enum class CourseViewMode { + FULL, + VIDEOS +} 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..471918622 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,17 +9,35 @@ 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 -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext @@ -32,23 +50,39 @@ 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.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( @@ -58,116 +92,160 @@ class HtmlUnitFragment : Fragment() { ) = ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { - OpenEdXTheme { - val windowSize = rememberWindowSize() + HtmlUnitView( + viewModel = viewModel, + blockUrl = blockUrl, + offlineUrl = offlineUrl, + fromDownloadedContent = fromDownloadedContent, + isFragmentAdded = isAdded + ) + } + } - var isLoading by remember { - mutableStateOf(true) - } + 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_OFFLINE_URL to offlineUrl, + ARG_LAST_MODIFIED to lastModified, + ARG_COURSE_ID to courseId + ) + return fragment + } + } +} + +@Composable +fun HtmlUnitView( + viewModel: HtmlUnitViewModel, + blockUrl: String, + offlineUrl: String, + fromDownloadedContent: Boolean, + isFragmentAdded: Boolean, +) { + OpenEdXTheme { + val context = LocalContext.current + val windowSize = rememberWindowSize() + + var hasInternetConnection by remember { + mutableStateOf(viewModel.isOnline) + } - 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 injectJSList by viewModel.injectJSList.collectAsState() + val uiState by viewModel.uiState.collectAsState() - val configuration = LocalConfiguration.current + val configuration = LocalConfiguration.current - val bottomPadding = - if (configuration.orientation == Configuration.ORIENTATION_PORTRAIT) { - 72.dp + val bottomPadding = + if (configuration.orientation == Configuration.ORIENTATION_PORTRAIT) { + 72.dp + } else { + 0.dp + } + + val border = if (!isSystemInDarkTheme() && !viewModel.isCourseUnitProgressEnabled) { + Modifier.roundBorderWithoutBottom( + borderWidth = 2.dp, + cornerRadius = 30.dp + ) + } else { + Modifier + } + + Surface( + modifier = Modifier + .clip(RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp)), + color = MaterialTheme.colors.background + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(bottom = bottomPadding) + .then(border), + contentAlignment = Alignment.TopCenter + ) { + 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 (isFragmentAdded) viewModel.setWebPageLoaded(context.assets) + }, + onWebPageLoadError = { + if (!fromDownloadedContent) viewModel.onWebPageLoadError() + }, + saveXBlockProgress = { jsonProgress -> + viewModel.saveXBlockProgress(jsonProgress) + }, + ) } else { - 0.dp + viewModel.onWebPageLoadError() } - - val border = if (!isSystemInDarkTheme() && !viewModel.isCourseUnitProgressEnabled) { - Modifier.roundBorderWithoutBottom( - borderWidth = 2.dp, - cornerRadius = 30.dp - ) } else { - Modifier + val errorType = (uiState as HtmlUnitUIState.Error).errorType + FullScreenErrorView(errorType = errorType) { + hasInternetConnection = viewModel.isOnline + viewModel.onWebPageLoading() + } } - - Surface( - modifier = Modifier - .clip(RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp)), - color = Color.White - ) { + if (uiState is HtmlUnitUIState.Loading && hasInternetConnection) { Box( modifier = Modifier .fillMaxSize() - .padding(bottom = bottomPadding) - .background(Color.White) - .then(border), - contentAlignment = Alignment.TopCenter + .zIndex(1f), + contentAlignment = Alignment.Center ) { - 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) - } - ) - } else { - ConnectionErrorView( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight() - .background(MaterialTheme.appColors.background) - ) { - hasInternetConnection = viewModel.isOnline - } - } - if (isLoading && hasInternetConnection) { - Box( - modifier = Modifier - .fillMaxSize() - .zIndex(1f), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator(color = MaterialTheme.appColors.primary) - } - } + CircularProgressIndicator(color = MaterialTheme.appColors.primary) } } } } } - - - companion object { - private const val ARG_BLOCK_ID = "blockId" - private const val ARG_BLOCK_URL = "blockUrl" - fun newInstance( - blockId: String, - blockUrl: String, - ): HtmlUnitFragment { - val fragment = HtmlUnitFragment() - fragment.arguments = bundleOf( - ARG_BLOCK_ID to blockId, - ARG_BLOCK_URL to blockUrl - ) - return fragment - } - } } @Composable @SuppressLint("SetJavaScriptEnabled") private fun HTMLContentView( + uiState: HtmlUnitUIState, windowSize: WindowSize, url: String, cookieManager: AppCookieManager, @@ -177,6 +255,8 @@ private fun HTMLContentView( onCompletionSet: () -> Unit, onWebPageLoading: () -> Unit, onWebPageLoaded: () -> Unit, + onWebPageLoadError: () -> Unit, + saveXBlockProgress: (String) -> Unit, ) { val coroutineScope = rememberCoroutineScope() val context = LocalContext.current @@ -190,19 +270,35 @@ private fun HTMLContentView( ) } + val isDarkTheme = isSystemInDarkTheme() + AndroidView( modifier = Modifier .then(screenWidth) .background(MaterialTheme.appColors.background), factory = { WebView(context).apply { - addJavascriptInterface(object { - @Suppress("unused") - @JavascriptInterface - fun completionSet() { - onCompletionSet() - } - }, "callback") + addJavascriptInterface( + object { + @Suppress("unused") + @JavascriptInterface + fun completionSet() { + onCompletionSet() + } + }, + "callback" + ) + addJavascriptInterface( + JSBridge( + postMessageCallback = { + coroutineScope.launch { + saveXBlockProgress(it) + setupOfflineProgress(it) + } + } + ), + "AndroidBridge" + ) webViewClient = object : WebViewClient() { override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { @@ -221,10 +317,7 @@ private fun HTMLContentView( request: WebResourceRequest? ): Boolean { val clickUrl = request?.url?.toString() ?: "" - return if (clickUrl.isNotEmpty() && - (clickUrl.startsWith("http://") || - clickUrl.startsWith("https://")) - ) { + return if (clickUrl.isNotEmpty() && clickUrl.startsWith("http")) { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(clickUrl))) true } else if (clickUrl.startsWith("mailto:")) { @@ -257,6 +350,17 @@ private fun HTMLContentView( } super.onReceivedHttpError(view, request, errorResponse) } + + override fun onReceivedError( + view: WebView, + request: WebResourceRequest, + error: WebResourceError + ) { + if (request.url.toString() == view.url) { + onWebPageLoadError() + } + super.onReceivedError(view, request, error) + } } with(settings) { javaScriptEnabled = true @@ -265,16 +369,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..702082746 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,43 +2,77 @@ 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 val jsList = mutableListOf() - //Injection to intercept completion state for xBlocks + // Injection to intercept completion state for xBlocks assets.readAsText("js_injection/completions.js")?.let { jsList.add(it) } - //Injection to fix CSS issues for Survey xBlock + // Injection to fix CSS issues for Survey xBlock assets.readAsText("js_injection/survey_css.js")?.let { jsList.add(it) } _injectJSList.value = jsList + getXBlockProgress() } fun notifyCompletionSet() { @@ -46,4 +80,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/EncodedVideoUnitViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt index dec6f70e9..2c2816bc9 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt @@ -5,6 +5,7 @@ import android.content.Context import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope import androidx.media3.cast.CastPlayer import androidx.media3.common.PlaybackParameters import androidx.media3.common.Player @@ -19,6 +20,8 @@ import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.exoplayer.upstream.DefaultBandwidthMeter import androidx.media3.extractor.DefaultExtractorsFactory import com.google.android.gms.cast.framework.CastContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.VideoQuality import org.openedx.core.module.TranscriptManager @@ -32,7 +35,8 @@ import java.util.concurrent.Executors @SuppressLint("StaticFieldLeak") class EncodedVideoUnitViewModel( courseId: String, - val blockId: String, + videoUrl: String, + blockId: String, private val context: Context, private val preferencesManager: CorePreferences, courseRepository: CourseRepository, @@ -42,6 +46,8 @@ class EncodedVideoUnitViewModel( courseAnalytics: CourseAnalytics, ) : VideoUnitViewModel( courseId, + videoUrl, + blockId, courseRepository, notifier, networkConnection, @@ -65,6 +71,16 @@ class EncodedVideoUnitViewModel( var isPlayerSetUp = false private val exoPlayerListener = object : Player.Listener { + override fun onRenderedFirstFrame() { + super.onRenderedFirstFrame() + viewModelScope.launch { + while (exoPlayer?.duration == null || exoPlayer?.duration!! < 0f) { + delay(500) + } + duration = exoPlayer?.duration ?: 0L + } + } + override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { super.onPlayWhenReadyChanged(playWhenReady, reason) isPlaying = playWhenReady @@ -76,7 +92,6 @@ class EncodedVideoUnitViewModel( _isVideoEnded.value = true markBlockCompleted(blockId, CourseAnalyticsKey.NATIVE.key) } - } override fun onIsPlayingChanged(isPlaying: Boolean) { @@ -107,6 +122,7 @@ class EncodedVideoUnitViewModel( CastContext.getSharedInstance(context, executor).addOnCompleteListener { it.result?.let { castContext -> castPlayer = CastPlayer(castContext) + isUpdatedMutable.value = true } } } 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..a0439d2ed 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 @@ -4,6 +4,7 @@ import android.annotation.SuppressLint import android.os.Bundle import android.view.View import android.widget.FrameLayout +import androidx.annotation.OptIn import androidx.core.os.bundleOf import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.Fragment @@ -12,6 +13,7 @@ import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackParameters import androidx.media3.common.Player import androidx.media3.common.util.Clock +import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.DefaultDataSource import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.DefaultRenderersFactory @@ -27,12 +29,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) { @@ -91,78 +93,89 @@ class VideoFullScreenFragment : Fragment(R.layout.fragment_video_full_screen) { initPlayer() } - @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) + @OptIn(UnstableApi::class) private fun initPlayer() { - with(binding) { - if (exoPlayer == null) { - val videoQuality = viewModel.getVideoQuality() - val params = DefaultTrackSelector.Parameters.Builder(requireContext()) - .apply { - if (videoQuality != VideoQuality.AUTO) { - setMaxVideoSize(videoQuality.width, videoQuality.height) - setViewportSize(videoQuality.width, videoQuality.height, false) - } - } - .build() - - val factory = AdaptiveTrackSelection.Factory() - val selector = DefaultTrackSelector(requireContext(), factory) - selector.parameters = params - - exoPlayer = ExoPlayer.Builder( - requireContext(), - DefaultRenderersFactory(requireContext()), - DefaultMediaSourceFactory(requireContext(), DefaultExtractorsFactory()), - selector, - DefaultLoadControl(), - DefaultBandwidthMeter.getSingletonInstance(requireContext()), - DefaultAnalyticsCollector(Clock.DEFAULT) - ).build() + if (exoPlayer == null) { + exoPlayer = buildExoPlayer() + } + setupPlayerView() + setupMediaItem() + setupPlayerListeners() + } + + @OptIn(UnstableApi::class) + private fun buildExoPlayer(): ExoPlayer { + val videoQuality = viewModel.getVideoQuality() + val trackSelector = DefaultTrackSelector(requireContext(), AdaptiveTrackSelection.Factory()) + trackSelector.parameters = DefaultTrackSelector.Parameters.Builder(requireContext()).apply { + if (videoQuality != VideoQuality.AUTO) { + setMaxVideoSize(videoQuality.width, videoQuality.height) + setViewportSize(videoQuality.width, videoQuality.height, false) } - playerView.player = exoPlayer - playerView.setShowNextButton(false) - playerView.setShowPreviousButton(false) - val mediaItem = MediaItem.fromUri(viewModel.videoUrl) - setPlayerMedia(mediaItem) - exoPlayer?.prepare() - exoPlayer?.playWhenReady = viewModel.isPlaying ?: false - - playerView.setFullscreenButtonClickListener { _ -> + }.build() + + return ExoPlayer.Builder( + requireContext(), + DefaultRenderersFactory(requireContext()), + DefaultMediaSourceFactory(requireContext(), DefaultExtractorsFactory()), + trackSelector, + DefaultLoadControl(), + DefaultBandwidthMeter.getSingletonInstance(requireContext()), + DefaultAnalyticsCollector(Clock.DEFAULT) + ).build() + } + + @OptIn(UnstableApi::class) + private fun setupPlayerView() { + with(binding.playerView) { + player = exoPlayer + setShowNextButton(false) + setShowPreviousButton(false) + setFullscreenButtonClickListener { requireActivity().supportFragmentManager.popBackStackImmediate() } + } + } - exoPlayer?.addListener(object : Player.Listener { - override fun onIsPlayingChanged(isPlaying: Boolean) { - super.onIsPlayingChanged(isPlaying) - viewModel.logPlayPauseEvent( - viewModel.videoUrl, - isPlaying, - viewModel.currentVideoTime, - CourseAnalyticsKey.NATIVE.key - ) - } + private fun setupMediaItem() { + val mediaItem = MediaItem.fromUri(viewModel.videoUrl) + setPlayerMedia(mediaItem) + exoPlayer?.prepare() + exoPlayer?.playWhenReady = viewModel.isPlaying ?: false + } - override fun onPlaybackStateChanged(playbackState: Int) { - super.onPlaybackStateChanged(playbackState) - if (playbackState == Player.STATE_ENDED) { - viewModel.markBlockCompleted(blockId, CourseAnalyticsKey.NATIVE.key) - } - } + private fun setupPlayerListeners() { + exoPlayer?.addListener(object : Player.Listener { + override fun onIsPlayingChanged(isPlaying: Boolean) { + super.onIsPlayingChanged(isPlaying) + viewModel.logPlayPauseEvent( + viewModel.videoUrl, + isPlaying, + viewModel.currentVideoTime, + CourseAnalyticsKey.NATIVE.key + ) + } - override fun onPlaybackParametersChanged(playbackParameters: PlaybackParameters) { - super.onPlaybackParametersChanged(playbackParameters) - viewModel.logVideoSpeedEvent( - viewModel.videoUrl, - playbackParameters.speed, - viewModel.currentVideoTime, - CourseAnalyticsKey.NATIVE.key - ) + override fun onPlaybackStateChanged(playbackState: Int) { + super.onPlaybackStateChanged(playbackState) + if (playbackState == Player.STATE_ENDED) { + viewModel.markBlockCompleted(blockId, CourseAnalyticsKey.NATIVE.key) } - }) - } + } + + override fun onPlaybackParametersChanged(playbackParameters: PlaybackParameters) { + super.onPlaybackParametersChanged(playbackParameters) + viewModel.logVideoSpeedEvent( + viewModel.videoUrl, + playbackParameters.speed, + viewModel.currentVideoTime, + CourseAnalyticsKey.NATIVE.key + ) + } + }) } - @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) + @OptIn(UnstableApi::class) private fun setPlayerMedia(mediaItem: MediaItem) { if (viewModel.videoUrl.endsWith(".m3u8")) { val factory = DefaultDataSource.Factory(requireContext()) @@ -191,11 +204,11 @@ class VideoFullScreenFragment : Fragment(R.layout.fragment_video_full_screen) { override fun onDestroyView() { viewModel.currentVideoTime = exoPlayer?.currentPosition ?: C.TIME_UNSET + viewModel.duration = exoPlayer?.duration ?: 0L viewModel.sendTime() super.onDestroyView() } - @SuppressLint("SourceLockedOrientationActivity") override fun onDestroy() { releasePlayer() 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..15725c19e 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) { @@ -54,6 +49,7 @@ class VideoUnitFragment : Fragment(R.layout.fragment_video_unit) { private val viewModel by viewModel { parametersOf( requireArguments().getString(ARG_COURSE_ID, ""), + requireArguments().getString(ARG_VIDEO_URL, ""), requireArguments().getString(ARG_BLOCK_ID, ""), ) } @@ -84,14 +80,12 @@ class VideoUnitFragment : Fragment(R.layout.fragment_video_unit) { lifecycle.addObserver(viewModel) handler.post(videoTimeRunnable) requireArguments().apply { - viewModel.videoUrl = getString(ARG_VIDEO_URL, "") viewModel.transcripts = stringToObject>( getString(ARG_TRANSCRIPT_URL, "") ) ?: emptyMap() 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 } @@ -150,25 +140,7 @@ class VideoUnitFragment : Fragment(R.layout.fragment_video_unit) { binding.connectionError.isVisible = !viewModel.hasInternetConnection && !viewModel.isDownloaded - val orientation = resources.configuration.orientation - val windowMetrics = - WindowMetricsCalculator.getOrCreate().computeCurrentWindowMetrics(requireActivity()) - val currentBounds = windowMetrics.bounds - val layoutParams = binding.playerView.layoutParams as FrameLayout.LayoutParams - if (orientation == Configuration.ORIENTATION_PORTRAIT || windowSize?.isTablet == true) { - val width = currentBounds.width() - requireContext().dpToPixel(32) - val minHeight = requireContext().dpToPixel(194).roundToInt() - val height = (width / 16f * 9f).roundToInt() - layoutParams.height = if (windowSize?.isTablet == true) { - requireContext().dpToPixel(320).roundToInt() - } else if (height < minHeight) { - minHeight - } else { - height - } - } - - binding.playerView.layoutParams = layoutParams + setupPlayerHeight() viewModel.isUpdated.observe(viewLifecycleOwner) { isUpdated -> if (isUpdated) { @@ -183,6 +155,38 @@ class VideoUnitFragment : Fragment(R.layout.fragment_video_unit) { } } + private fun setupPlayerHeight() { + val orientation = resources.configuration.orientation + val windowMetrics = WindowMetricsCalculator.getOrCreate() + .computeCurrentWindowMetrics(requireActivity()) + val currentBounds = windowMetrics.bounds + val layoutParams = binding.playerView.layoutParams as FrameLayout.LayoutParams + + if (orientation == Configuration.ORIENTATION_PORTRAIT || windowSize?.isTablet == true) { + val padding = requireContext().dpToPixel(PLAYER_VIEW_PADDING_DP) + val width = currentBounds.width() - padding + val minHeight = requireContext().dpToPixel(MIN_PLAYER_HEIGHT_DP).roundToInt() + val aspectRatio = VIDEO_ASPECT_RATIO_WIDTH / VIDEO_ASPECT_RATIO_HEIGHT + val calculatedHeight = (width / aspectRatio).roundToInt() + + layoutParams.height = when { + windowSize?.isTablet == true -> { + requireContext().dpToPixel(TABLET_PLAYER_HEIGHT_DP).roundToInt() + } + + calculatedHeight < minHeight -> { + minHeight + } + + else -> { + calculatedHeight + } + } + } + + binding.playerView.layoutParams = layoutParams + } + @androidx.annotation.OptIn(UnstableApi::class) private fun initPlayer() { with(binding) { @@ -202,9 +206,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 { @@ -217,7 +222,7 @@ class VideoUnitFragment : Fragment(R.layout.fragment_video_unit) { mediaItem, viewModel.exoPlayer?.currentPosition ?: 0L ) - viewModel.castPlayer?.playWhenReady = false + viewModel.castPlayer?.playWhenReady = true showVideoControllerIndefinitely(true) } @@ -234,8 +239,9 @@ class VideoUnitFragment : Fragment(R.layout.fragment_video_unit) { ) playerView.setFullscreenButtonClickListener { - if (viewModel.isCastActive) + if (viewModel.isCastActive) { return@setFullscreenButtonClickListener + } router.navigateToFullScreenVideo( requireActivity().supportFragmentManager, @@ -267,11 +273,11 @@ class VideoUnitFragment : Fragment(R.layout.fragment_video_unit) { binding.playerView.showController() } else { binding.playerView.controllerAutoShow = true - binding.playerView.controllerShowTimeoutMs = 2000 + binding.playerView.controllerShowTimeoutMs = CONTROLLER_SHOW_TIMEOUT } } - @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) + @androidx.annotation.OptIn(UnstableApi::class) private fun setPlayerMedia(mediaItem: MediaItem) { if (viewModel.videoUrl.endsWith(".m3u8")) { val factory = DefaultDataSource.Factory(requireContext()) @@ -294,6 +300,13 @@ class VideoUnitFragment : Fragment(R.layout.fragment_video_unit) { private const val ARG_TITLE = "title" private const val ARG_DOWNLOADED = "isDownloaded" + private const val PLAYER_VIEW_PADDING_DP = 32 + private const val MIN_PLAYER_HEIGHT_DP = 194 + private const val TABLET_PLAYER_HEIGHT_DP = 320 + private const val VIDEO_ASPECT_RATIO_WIDTH = 16f + private const val VIDEO_ASPECT_RATIO_HEIGHT = 9f + private const val CONTROLLER_SHOW_TIMEOUT = 2000 + fun newInstance( blockId: String, courseId: String, 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..bd9199942 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 @@ -21,6 +21,8 @@ import subtitleFile.TimedTextObject open class VideoUnitViewModel( val courseId: String, + val videoUrl: String, + val blockId: String, private val courseRepository: CourseRepository, private val notifier: CourseNotifier, private val networkConnection: NetworkConnection, @@ -28,9 +30,8 @@ open class VideoUnitViewModel( courseAnalytics: CourseAnalytics, ) : BaseVideoViewModel(courseId, courseAnalytics) { - var videoUrl = "" var transcripts = emptyMap() - var isPlaying = false + var isPlaying = true var transcriptLanguage = AppDataConstants.defaultLocale.language ?: "en" private set @@ -40,9 +41,11 @@ open class VideoUnitViewModel( val currentVideoTime: LiveData get() = _currentVideoTime - private val _isUpdated = MutableLiveData(true) + var duration = 0L + + protected val isUpdatedMutable = MutableLiveData(true) val isUpdated: LiveData - get() = _isUpdated + get() = isUpdatedMutable private val _currentIndex = MutableStateFlow(0) val currentIndex = _currentIndex.asStateFlow() @@ -58,14 +61,19 @@ open class VideoUnitViewModel( private var isBlockAlreadyCompleted = false + init { + initVideoProgress() + } + override fun onCreate(owner: LifecycleOwner) { super.onCreate(owner) viewModelScope.launch { notifier.notifier.collect { if (it is CourseVideoPositionChanged && videoUrl == it.videoUrl) { - _isUpdated.value = false + isUpdatedMutable.value = false _currentVideoTime.value = it.videoTime - _isUpdated.value = true + saveVideoProgress() + isUpdatedMutable.value = true isPlaying = it.isPlaying } else if (it is CourseSubtitleLanguageChanged) { transcriptLanguage = it.value @@ -76,6 +84,22 @@ open class VideoUnitViewModel( } } + override fun onPause(owner: LifecycleOwner) { + saveVideoProgress() + super.onPause(owner) + } + + private fun saveVideoProgress() { + viewModelScope.launch { + courseRepository.saveVideoProgress( + blockId, + videoUrl, + _currentVideoTime.value ?: 0L, + duration + ) + } + } + fun downloadSubtitles() { viewModelScope.launch(Dispatchers.IO) { transcriptManager.downloadTranscriptsForVideo(getTranscriptUrl())?.let { result -> @@ -88,20 +112,20 @@ open class VideoUnitViewModel( private fun getTranscriptUrl(): String { val defaultTranscripts = transcripts[transcriptLanguage] - if (!defaultTranscripts.isNullOrEmpty()) { - return defaultTranscripts - } - if (transcripts.values.isNotEmpty()) { - transcriptLanguage = transcripts.keys.toList().first() - return transcripts[transcriptLanguage] ?: "" + return when { + !defaultTranscripts.isNullOrEmpty() -> defaultTranscripts + transcripts.values.isNotEmpty() -> { + transcriptLanguage = transcripts.keys.first() + transcripts[transcriptLanguage] ?: "" + } + + else -> "" } - return "" } - open fun markBlockCompleted(blockId: String, medium: String) { - logLoadedCompletedEvent(videoUrl, false, getCurrentVideoTime(), medium) if (!isBlockAlreadyCompleted) { + logLoadedCompletedEvent(videoUrl, false, getCurrentVideoTime(), medium) viewModelScope.launch { try { isBlockAlreadyCompleted = true @@ -111,6 +135,7 @@ open class VideoUnitViewModel( ) notifier.send(CourseCompletionSet()) } catch (e: Exception) { + e.printStackTrace() isBlockAlreadyCompleted = false } } @@ -130,4 +155,15 @@ open class VideoUnitViewModel( } fun getCurrentVideoTime() = currentVideoTime.value ?: 0 + + private fun initVideoProgress() { + viewModelScope.launch { + try { + val videoProgress = courseRepository.getVideoProgress(blockId) + _currentVideoTime.value = videoProgress.videoTime + } catch (e: Exception) { + e.printStackTrace() + } + } + } } 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..c9da7aaec 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 @@ -20,11 +20,11 @@ class VideoViewModel( var videoUrl = "" var currentVideoTime = 0L + var duration = 0L var isPlaying: Boolean? = null private var isBlockAlreadyCompleted = false - fun sendTime() { if (currentVideoTime != C.TIME_UNSET) { viewModelScope.launch { @@ -32,7 +32,8 @@ class VideoViewModel( CourseVideoPositionChanged( videoUrl, currentVideoTime, - isPlaying ?: false + duration, + isPlaying == true ) ) } @@ -40,8 +41,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 @@ -51,6 +52,7 @@ class VideoViewModel( ) notifier.send(CourseCompletionSet()) } catch (e: Exception) { + e.printStackTrace() isBlockAlreadyCompleted = false } } 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..0dda9e50b 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 @@ -7,21 +7,23 @@ import androidx.core.os.bundleOf import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible import androidx.fragment.app.Fragment +import com.pierfrancescosoffritti.androidyoutubeplayer.core.customui.DefaultPlayerUiController import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.PlayerConstants import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.YouTubePlayer import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.listeners.AbstractYouTubePlayerListener import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.options.IFramePlayerOptions import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.utils.YouTubePlayerTracker -import com.pierfrancescosoffritti.androidyoutubeplayer.core.ui.DefaultPlayerUiController 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.course.presentation.unit.video.YoutubeVideoUnitFragment.Companion.RATE_DIALOG_THRESHOLD +import org.openedx.course.presentation.unit.video.YoutubeVideoUnitFragment.Companion.VIDEO_COMPLETION_THRESHOLD +import org.openedx.foundation.extension.requestApplyInsetsWhenAttached class YoutubeVideoFullScreenFragment : Fragment(R.layout.fragment_youtube_video_full_screen) { @@ -64,67 +66,72 @@ class YoutubeVideoFullScreenFragment : Fragment(R.layout.fragment_youtube_video_ binding.root.requestApplyInsetsWhenAttached() lifecycle.addObserver(binding.youtubePlayerView) - val options = IFramePlayerOptions.Builder() + val options = IFramePlayerOptions.Builder(requireContext()) .controls(0) .rel(0) .build() - - binding.youtubePlayerView.initialize(object : AbstractYouTubePlayerListener() { - var isMarkBlockCompletedCalled = false - - override fun onStateChange( - youTubePlayer: YouTubePlayer, - state: PlayerConstants.PlayerState, - ) { - super.onStateChange(youTubePlayer, state) - if (state == PlayerConstants.PlayerState.ENDED) { - viewModel.markBlockCompleted(blockId, CourseAnalyticsKey.YOUTUBE.key) - } - viewModel.isPlaying = when (state) { - PlayerConstants.PlayerState.PLAYING -> true - PlayerConstants.PlayerState.PAUSED -> false - else -> return - } - } - - override fun onCurrentSecond(youTubePlayer: YouTubePlayer, second: Float) { - super.onCurrentSecond(youTubePlayer, second) - viewModel.currentVideoTime = (second * 1000f).toLong() - val completePercentage = second / youtubeTrackerListener.videoDuration - if (completePercentage >= 0.8f && !isMarkBlockCompletedCalled) { - viewModel.markBlockCompleted(blockId, CourseAnalyticsKey.YOUTUBE.key) - isMarkBlockCompletedCalled = true - } - if (completePercentage >= 0.99f && !appReviewManager.isDialogShowed) { - if (!appReviewManager.isDialogShowed) { - appReviewManager.tryToOpenRateDialog() + binding.youtubePlayerView.initialize( + object : AbstractYouTubePlayerListener() { + var isMarkBlockCompletedCalled = false + + override fun onStateChange( + youTubePlayer: YouTubePlayer, + state: PlayerConstants.PlayerState, + ) { + super.onStateChange(youTubePlayer, state) + if (state == PlayerConstants.PlayerState.ENDED) { + viewModel.markBlockCompleted(blockId, CourseAnalyticsKey.YOUTUBE.key) + } + viewModel.isPlaying = when (state) { + PlayerConstants.PlayerState.PLAYING -> true + PlayerConstants.PlayerState.PAUSED -> false + else -> return } } - } - - override fun onReady(youTubePlayer: YouTubePlayer) { - super.onReady(youTubePlayer) - binding.youtubePlayerView.isVisible = true - val defPlayerUiController = - DefaultPlayerUiController(binding.youtubePlayerView, youTubePlayer) - defPlayerUiController.setFullScreenButtonClickListener { - parentFragmentManager.popBackStack() + + override fun onCurrentSecond(youTubePlayer: YouTubePlayer, second: Float) { + super.onCurrentSecond(youTubePlayer, second) + viewModel.currentVideoTime = (second * 1000f).toLong() + val completePercentage = second / youtubeTrackerListener.videoDuration + if (completePercentage >= VIDEO_COMPLETION_THRESHOLD && !isMarkBlockCompletedCalled) { + viewModel.markBlockCompleted(blockId, CourseAnalyticsKey.YOUTUBE.key) + isMarkBlockCompletedCalled = true + } + if (completePercentage >= RATE_DIALOG_THRESHOLD && !appReviewManager.isDialogShowed) { + if (!appReviewManager.isDialogShowed) { + appReviewManager.tryToOpenRateDialog() + } + } } - binding.youtubePlayerView.setCustomPlayerUi(defPlayerUiController.rootView) + override fun onReady(youTubePlayer: YouTubePlayer) { + super.onReady(youTubePlayer) + binding.youtubePlayerView.isVisible = true + val defPlayerUiController = + DefaultPlayerUiController(binding.youtubePlayerView, youTubePlayer) + defPlayerUiController.setFullscreenButtonClickListener { + parentFragmentManager.popBackStack() + } - val videoId = viewModel.videoUrl.split("watch?v=")[1] - if (viewModel.isPlaying == true) { - youTubePlayer.loadVideo(videoId, viewModel.currentVideoTime.toFloat() / 1000) - } else { - youTubePlayer.cueVideo(videoId, viewModel.currentVideoTime.toFloat() / 1000) - } - youTubePlayer.addListener(youtubeTrackerListener) + binding.youtubePlayerView.setCustomPlayerUi(defPlayerUiController.rootView) - } + val videoId = viewModel.videoUrl.split("watch?v=")[1] + if (viewModel.isPlaying == true) { + youTubePlayer.loadVideo(videoId, viewModel.currentVideoTime.toFloat() / 1000) + } else { + youTubePlayer.cueVideo(videoId, viewModel.currentVideoTime.toFloat() / 1000) + } + youTubePlayer.addListener(youtubeTrackerListener) + } - }, options) + override fun onVideoDuration(youTubePlayer: YouTubePlayer, duration: Float) { + viewModel.duration = (duration * 1000).toLong() + super.onVideoDuration(youTubePlayer, duration) + } + }, + options + ) } override fun onDestroyView() { @@ -157,5 +164,4 @@ class YoutubeVideoFullScreenFragment : Fragment(R.layout.fragment_youtube_video_ return fragment } } - } 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..352858cbe 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,35 +4,26 @@ 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 +import com.pierfrancescosoffritti.androidyoutubeplayer.core.customui.DefaultPlayerUiController import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.PlayerConstants import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.YouTubePlayer import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.listeners.AbstractYouTubePlayerListener import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.options.IFramePlayerOptions import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.utils.YouTubePlayerTracker -import com.pierfrancescosoffritti.androidyoutubeplayer.core.ui.DefaultPlayerUiController 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,11 +31,19 @@ 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) { private val viewModel by viewModel { - parametersOf(requireArguments().getString(ARG_COURSE_ID, "")) + parametersOf( + requireArguments().getString(ARG_COURSE_ID, ""), + requireArguments().getString(ARG_VIDEO_URL, ""), + requireArguments().getString(ARG_BLOCK_ID, ""), + ) } private val router by inject() private val appReviewManager by inject { parametersOf(requireActivity()) } @@ -66,7 +65,6 @@ class YoutubeVideoUnitFragment : Fragment(R.layout.fragment_youtube_video_unit) windowSize = computeWindowSizeClasses() lifecycle.addObserver(viewModel) requireArguments().apply { - viewModel.videoUrl = getString(ARG_VIDEO_URL, "") viewModel.transcripts = stringToObject>( getString(ARG_TRANSCRIPT_URL, "") ) ?: emptyMap() @@ -84,6 +82,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 +100,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 } } @@ -141,7 +142,7 @@ class YoutubeVideoUnitFragment : Fragment(R.layout.fragment_youtube_video_unit) lifecycle.addObserver(binding.youtubePlayerView) - val options = IFramePlayerOptions.Builder() + val options = IFramePlayerOptions.Builder(requireContext()) .controls(0) .rel(0) .build() @@ -153,11 +154,11 @@ class YoutubeVideoUnitFragment : Fragment(R.layout.fragment_youtube_video_unit) super.onCurrentSecond(youTubePlayer, second) viewModel.setCurrentVideoTime((second * 1000f).toLong()) val completePercentage = second / youtubeTrackerListener.videoDuration - if (completePercentage >= 0.8f && !isMarkBlockCompletedCalled) { + if (completePercentage >= VIDEO_COMPLETION_THRESHOLD && !isMarkBlockCompletedCalled) { viewModel.markBlockCompleted(blockId, CourseAnalyticsKey.YOUTUBE.key) isMarkBlockCompletedCalled = true } - if (completePercentage >= 0.99f && !appReviewManager.isDialogShowed) { + if (completePercentage >= RATE_DIALOG_THRESHOLD && !appReviewManager.isDialogShowed) { appReviewManager.tryToOpenRateDialog() } } @@ -188,7 +189,7 @@ class YoutubeVideoUnitFragment : Fragment(R.layout.fragment_youtube_video_unit) binding.youtubePlayerView, youTubePlayer ) - defPlayerUiController.setFullScreenButtonClickListener { + defPlayerUiController.setFullscreenButtonClickListener { router.navigateToFullScreenYoutubeVideo( requireActivity().supportFragmentManager, viewModel.videoUrl, @@ -202,13 +203,15 @@ 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 + videoId, + viewModel.getCurrentVideoTime().toFloat() / 1000 ) } else { youTubePlayer.cueVideo( - videoId, viewModel.getCurrentVideoTime().toFloat() / 1000 + videoId, + viewModel.getCurrentVideoTime().toFloat() / 1000 ) } } @@ -220,6 +223,11 @@ class YoutubeVideoUnitFragment : Fragment(R.layout.fragment_youtube_video_unit) CourseAnalyticsKey.YOUTUBE.key ) } + + override fun onVideoDuration(youTubePlayer: YouTubePlayer, duration: Float) { + viewModel.duration = (duration * 1000).toLong() + super.onVideoDuration(youTubePlayer, duration) + } } if (!isPlayerInitialized) { @@ -248,6 +256,9 @@ class YoutubeVideoUnitFragment : Fragment(R.layout.fragment_youtube_video_unit) private const val ARG_COURSE_ID = "courseId" private const val ARG_TITLE = "blockTitle" + const val VIDEO_COMPLETION_THRESHOLD = 0.8f + const val RATE_DIALOG_THRESHOLD = 0.99f + fun newInstance( blockId: String, courseId: String, diff --git a/course/src/main/java/org/openedx/course/presentation/videos/CourseContentVideoScreen.kt b/course/src/main/java/org/openedx/course/presentation/videos/CourseContentVideoScreen.kt new file mode 100644 index 000000000..f482596ec --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/videos/CourseContentVideoScreen.kt @@ -0,0 +1,393 @@ +package org.openedx.course.presentation.videos + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Box +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.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Divider +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.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentManager +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.CourseStructure +import org.openedx.core.domain.model.CoursewareAccess +import org.openedx.core.domain.model.Progress +import org.openedx.core.module.download.DownloadModelsSize +import org.openedx.core.ui.CircularProgress +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.course.R +import org.openedx.course.presentation.contenttab.CourseContentVideoEmptyState +import org.openedx.course.presentation.ui.CourseProgress +import org.openedx.course.presentation.ui.CourseVideoSection +import org.openedx.course.presentation.unit.container.CourseViewMode +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 + +@Composable +fun CourseContentVideoScreen( + windowSize: WindowSize, + viewModel: CourseVideoViewModel, + fragmentManager: FragmentManager, + onNavigateToHome: () -> Unit = {}, +) { + val uiState by viewModel.uiState.collectAsState(CourseVideoUIState.Loading) + val uiMessage by viewModel.uiMessage.collectAsState(null) + + CourseVideosUI( + windowSize = windowSize, + uiState = uiState, + uiMessage = uiMessage, + onNavigateToHome = onNavigateToHome, + onVideoClick = { videoBlock -> + viewModel.courseRouter.navigateToCourseContainer( + fragmentManager, + courseId = viewModel.courseId, + unitId = viewModel.getBlockParent(videoBlock.id)?.id ?: return@CourseVideosUI, + mode = CourseViewMode.VIDEOS + ) + viewModel.logVideoClick(videoBlock.id) + }, + onDownloadClick = { blocksIds -> + viewModel.downloadBlocks( + blocksIds = blocksIds, + fragmentManager = fragmentManager, + ) + }, + onCompletedSectionVisibilityChange = { + viewModel.onCompletedSectionVisibilityChange() + }, + ) +} + +@Composable +private fun CourseVideosUI( + windowSize: WindowSize, + uiState: CourseVideoUIState, + uiMessage: UIMessage?, + onNavigateToHome: () -> Unit, + onVideoClick: (Block) -> Unit, + onDownloadClick: (blocksIds: List) -> Unit, + onCompletedSectionVisibilityChange: () -> Unit, +) { + val scaffoldState = rememberScaffoldState() + + Scaffold( + modifier = Modifier.fillMaxSize(), + 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() + ) + ) + } + + val listBottomPadding by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = PaddingValues(bottom = 24.dp), + compact = PaddingValues(bottom = 24.dp) + ) + ) + } + + HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) + + Box( + modifier = Modifier + .fillMaxSize() + .padding(it) + .displayCutoutForLandscape(), + contentAlignment = Alignment.TopCenter + ) { + Surface( + modifier = screenWidth, + color = MaterialTheme.appColors.background + ) { + Box { + Column( + modifier = Modifier.fillMaxSize() + ) { + when (uiState) { + is CourseVideoUIState.Empty -> { + CourseContentVideoEmptyState( + modifier = Modifier.verticalScroll(rememberScrollState()), + onReturnToCourseClick = onNavigateToHome + ) + } + + is CourseVideoUIState.CourseData -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = listBottomPadding + ) { + val allVideos = uiState.courseVideos.values.flatten() + val hasCompletedSection = + uiState.courseVideos.values.any { sectionVideos -> + sectionVideos.all { video -> + video.isCompleted() + } + } + val progress = Progress( + completed = allVideos.filter { it.isCompleted() }.size, + total = allVideos.size, + ) + item { + CourseProgress( + modifier = Modifier + .fillMaxWidth() + .padding( + bottom = 8.dp, + start = 24.dp, + end = 24.dp, + ), + progress = progress, + isCompletedShown = uiState.isCompletedSectionsShown, + onVisibilityChanged = if (hasCompletedSection) { + { onCompletedSectionVisibilityChange() } + } else { + null + }, + description = stringResource( + R.string.course_completed_of, + progress.completed, + progress.total + ) + ) + } + item { + Divider(modifier = Modifier.fillMaxWidth()) + } + + uiState.courseStructure.blockData + .let { list -> + if (uiState.isCompletedSectionsShown) { + list.sortedBy { section -> + uiState.courseVideos[section.id]?.any { !it.isCompleted() } + } + } else { + list + } + } + .forEach { section -> + val sectionVideos = + uiState.courseVideos[section.id] ?: emptyList() + + val shouldShowSection = + sectionVideos.any { !it.isCompleted() } || + uiState.isCompletedSectionsShown + if (shouldShowSection) { + item { + CourseVideoSection( + block = section, + videoBlocks = sectionVideos, + downloadedStateMap = uiState.downloadedState, + onVideoClick = onVideoClick, + onDownloadClick = onDownloadClick, + preview = uiState.videoPreview, + progress = uiState.videoProgress, + ) + } + } + } + } + } + + CourseVideoUIState.Loading -> { + CircularProgress() + } + } + } + } + } + } + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun CourseVideosScreenPreview() { + OpenEdXTheme { + CourseVideosUI( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + uiMessage = null, + uiState = CourseVideoUIState.CourseData( + mockCourseStructure, + emptyMap(), + mapOf(), + mapOf(), + DownloadModelsSize( + isAllBlocksDownloadedOrDownloading = false, + remainingCount = 0, + remainingSize = 0, + allCount = 1, + allSize = 0 + ), + isCompletedSectionsShown = false, + videoPreview = mapOf(), + videoProgress = mapOf(), + ), + onVideoClick = { }, + onDownloadClick = {}, + onCompletedSectionVisibilityChange = {}, + onNavigateToHome = {}, + ) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun CourseVideosScreenEmptyPreview() { + OpenEdXTheme { + CourseVideosUI( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + uiMessage = null, + uiState = CourseVideoUIState.Empty, + onVideoClick = { }, + onDownloadClick = {}, + onCompletedSectionVisibilityChange = {}, + onNavigateToHome = {}, + ) + } +} + +@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 CourseVideosScreenTabletPreview() { + OpenEdXTheme { + CourseVideosUI( + windowSize = WindowSize(WindowType.Medium, WindowType.Medium), + uiMessage = null, + uiState = CourseVideoUIState.CourseData( + mockCourseStructure, + emptyMap(), + mapOf(), + mapOf(), + DownloadModelsSize( + isAllBlocksDownloadedOrDownloading = false, + remainingCount = 0, + remainingSize = 0, + allCount = 0, + allSize = 0 + ), + isCompletedSectionsShown = true, + videoPreview = mapOf(), + videoProgress = mapOf(), + ), + onVideoClick = { }, + onDownloadClick = {}, + onCompletedSectionVisibilityChange = {}, + onNavigateToHome = {}, + ) + } +} + +private val mockAssignmentProgress = AssignmentProgress( + assignmentType = "Home", + numPointsEarned = 1f, + numPointsPossible = 3f, + shortLabel = "HM1" +) + +private val mockChapterBlock = Block( + id = "id", + blockId = "blockId", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.CHAPTER, + displayName = "Chapter", + graded = false, + studentViewData = null, + studentViewMultiDevice = false, + blockCounts = BlockCounts(1), + descendants = emptyList(), + descendantsType = BlockType.CHAPTER, + completion = 0.0, + containsGatedContent = false, + assignmentProgress = mockAssignmentProgress, + due = Date(), + offlineDownload = null +) + +private val mockSequentialBlock = Block( + id = "id", + blockId = "blockId", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.SEQUENTIAL, + displayName = "Sequential", + graded = false, + studentViewData = null, + studentViewMultiDevice = false, + blockCounts = BlockCounts(1), + descendants = emptyList(), + descendantsType = BlockType.SEQUENTIAL, + completion = 0.0, + containsGatedContent = false, + assignmentProgress = mockAssignmentProgress, + due = Date(), + offlineDownload = null +) + +private val mockCourseStructure = CourseStructure( + root = "", + blockData = listOf(mockSequentialBlock, mockChapterBlock), + id = "id", + name = "Course name", + number = "", + org = "Org", + start = Date(), + startDisplay = "", + startType = "", + end = Date(), + coursewareAccess = CoursewareAccess( + true, + "", + "", + "", + "", + "" + ), + media = null, + certificate = null, + isSelfPaced = false, + progress = Progress(1, 3), +) diff --git a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoUIState.kt b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoUIState.kt new file mode 100644 index 000000000..584814c15 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoUIState.kt @@ -0,0 +1,23 @@ +package org.openedx.course.presentation.videos + +import org.openedx.core.domain.model.Block +import org.openedx.core.domain.model.CourseStructure +import org.openedx.core.module.db.DownloadedState +import org.openedx.core.module.download.DownloadModelsSize +import org.openedx.core.utils.VideoPreview + +sealed class CourseVideoUIState { + data class CourseData( + val courseStructure: CourseStructure, + val downloadedState: Map, + val courseVideos: Map>, + val subSectionsDownloadsCount: Map, + val downloadModelsSize: DownloadModelsSize, + val isCompletedSectionsShown: Boolean, + val videoPreview: Map, + val videoProgress: Map, + ) : CourseVideoUIState() + + data object Empty : CourseVideoUIState() + data object Loading : CourseVideoUIState() +} 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..456669cc0 100644 --- a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt @@ -1,6 +1,8 @@ package org.openedx.course.presentation.videos +import androidx.fragment.app.FragmentManager import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -9,62 +11,64 @@ 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.helper.VideoPreviewHelper import org.openedx.core.domain.model.Block -import org.openedx.core.domain.model.VideoSettings +import org.openedx.core.extension.safeDivBy 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.dialog.downloaddialog.DownloadDialogManager 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.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.CourseAnalyticsEvent +import org.openedx.course.presentation.CourseAnalyticsKey +import org.openedx.course.presentation.CourseRouter +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager +import org.openedx.foundation.utils.FileUtil class CourseVideoViewModel( val courseId: String, - val courseTitle: String, private val config: Config, private val interactor: CourseInteractor, private val resourceManager: ResourceManager, private val networkConnection: NetworkConnection, private val preferencesManager: CorePreferences, private val courseNotifier: CourseNotifier, - private val videoNotifier: VideoNotifier, + private val downloadDialogManager: DownloadDialogManager, + private val fileUtil: FileUtil, + val courseRouter: CourseRouter, private val analytics: CourseAnalytics, + private val videoPreviewHelper: VideoPreviewHelper, coreAnalytics: CoreAnalytics, downloadDao: DownloadDao, - workerController: DownloadWorkerController + workerController: DownloadWorkerController, + downloadHelper: DownloadHelper, ) : BaseDownloadViewModel( - courseId, downloadDao, preferencesManager, workerController, - coreAnalytics + coreAnalytics, + downloadHelper, ) { - - val isCourseNestedListEnabled get() = config.isCourseNestedListEnabled() - - private val _uiState = MutableStateFlow(CourseVideosUIState.Loading) - val uiState: StateFlow + private val _uiState = MutableStateFlow(CourseVideoUIState.Loading) + val uiState: StateFlow get() = _uiState.asStateFlow() private val _uiMessage = MutableSharedFlow() val uiMessage: SharedFlow get() = _uiMessage.asSharedFlow() - private val _videoSettings = MutableStateFlow(VideoSettings.default) - val videoSettings = _videoSettings.asStateFlow() - + private val courseVideos = mutableMapOf>() private val courseSubSections = mutableMapOf>() private val subSectionsDownloadsCount = mutableMapOf() val courseSubSectionUnit = mutableMapOf() @@ -75,21 +79,17 @@ class CourseVideoViewModel( when (event) { is CourseStructureUpdated -> { if (event.courseId == courseId) { - updateVideos() + getVideos() } } - - is CourseDataReady -> { - getVideos() - } } } } viewModelScope.launch { downloadModelsStatusFlow.collect { - if (_uiState.value is CourseVideosUIState.CourseData) { - val state = _uiState.value as CourseVideosUIState.CourseData + if (_uiState.value is CourseVideoUIState.CourseData) { + val state = _uiState.value as CourseVideoUIState.CourseData _uiState.value = state.copy( downloadedState = it.toMap(), downloadModelsSize = getDownloadModelsSize() @@ -98,126 +98,248 @@ class CourseVideoViewModel( } } - viewModelScope.launch { - videoNotifier.notifier.collect { event -> - if (event is VideoQualityChanged) { - _videoSettings.value = preferencesManager.videoSettings - - if (_uiState.value is CourseVideosUIState.CourseData) { - val state = _uiState.value as CourseVideosUIState.CourseData - _uiState.value = state.copy( - downloadModelsSize = getDownloadModelsSize() - ) - } - } - } - } - - _videoSettings.value = preferencesManager.videoSettings + getVideos() } - override fun saveDownloadModels(folder: String, id: String) { + override fun saveDownloadModels(folder: String, courseId: String, id: String) { if (preferencesManager.videoSettings.wifiDownloadOnly) { if (networkConnection.isWifiConnected()) { - super.saveDownloadModels(folder, id) + super.saveDownloadModels(folder, courseId, id) } else { viewModelScope.launch { - _uiMessage.emit(UIMessage.ToastMessage(resourceManager.getString(R.string.course_can_download_only_with_wifi))) + _uiMessage.emit( + UIMessage.ToastMessage( + resourceManager.getString(R.string.course_can_download_only_with_wifi) + ) + ) } } } else { - super.saveDownloadModels(folder, id) + super.saveDownloadModels(folder, courseId, id) } } - override fun saveAllDownloadModels(folder: String) { + override fun saveAllDownloadModels(folder: String, courseId: String) { if (preferencesManager.videoSettings.wifiDownloadOnly && !networkConnection.isWifiConnected()) { viewModelScope.launch { - _uiMessage.emit(UIMessage.ToastMessage(resourceManager.getString(R.string.course_can_download_only_with_wifi))) + _uiMessage.emit( + UIMessage.ToastMessage(resourceManager.getString(R.string.course_can_download_only_with_wifi)) + ) } return } - super.saveAllDownloadModels(folder) - } - - private fun updateVideos() { - getVideos() + super.saveAllDownloadModels(folder, courseId) } 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) + try { + var courseStructure = interactor.getCourseStructureForVideos(courseId) + val blocks = courseStructure.blockData + if (blocks.isEmpty()) { + _uiState.value = CourseVideoUIState.Empty + } else { + setBlocks(courseStructure.blockData) + courseVideos.clear() + courseSubSectionUnit.clear() + courseStructure = courseStructure.copy(blockData = sortBlocks(blocks)) + initDownloadModelsStatus() + val videoProgress = courseVideos.values.flatten().associate { block -> + val videoProgressEntity = interactor.getVideoProgress(block.id) + val videoTime = videoProgressEntity.videoTime?.toFloat() + val videoDuration = videoProgressEntity.duration?.toFloat() + val progress = if (videoTime != null && videoDuration != null) { + videoTime.safeDivBy(videoDuration) + } else { + null + } + block.id to progress + } + val isCompletedSectionsShown = + (_uiState.value as? CourseVideoUIState.CourseData)?.isCompletedSectionsShown + ?: false + + _uiState.value = + CourseVideoUIState.CourseData( + courseStructure = courseStructure, + downloadedState = getDownloadModelsStatus(), + courseVideos = courseVideos, + subSectionsDownloadsCount = subSectionsDownloadsCount, + downloadModelsSize = getDownloadModelsSize(), + isCompletedSectionsShown = isCompletedSectionsShown, + videoPreview = (_uiState.value as? CourseVideoUIState.CourseData)?.videoPreview + ?: emptyMap(), + videoProgress = videoProgress, + ) + } + courseNotifier.send(CourseLoading(false)) + getVideoPreviews() + } catch (e: Exception) { + e.printStackTrace() + _uiState.value = CourseVideoUIState.Empty + } + } + } + + private fun getVideoPreviews() { + viewModelScope.launch(Dispatchers.IO) { + val downloadingModels = getDownloadModelList() + courseVideos.values.flatten().forEach { block -> + val offlineUrl = downloadingModels.find { block.id == it.id }?.path + val previewMap = videoPreviewHelper.getVideoPreviewWithId( + blockId = block.id, + block = block, + offlineUrl = offlineUrl + ) + val currentUiState = + (_uiState.value as? CourseVideoUIState.CourseData) ?: return@forEach + _uiState.value = currentUiState.copy( + videoPreview = currentUiState.videoPreview + previewMap ) - } 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() - ) } - courseNotifier.send(CourseLoading(false)) } } - fun switchCourseSections(blockId: String) { - if (_uiState.value is CourseVideosUIState.CourseData) { - val state = _uiState.value as CourseVideosUIState.CourseData - val courseSectionsState = state.courseSectionsState.toMutableMap() - courseSectionsState[blockId] = !(state.courseSectionsState[blockId] ?: false) + private fun sortBlocks(blocks: List): List { + if (blocks.isEmpty()) return emptyList() - _uiState.value = state.copy(courseSectionsState = courseSectionsState) + val resultBlocks = mutableListOf() + blocks.forEach { block -> + if (block.type == BlockType.CHAPTER) { + resultBlocks.add(block) + processDescendants(block, blocks) + } } + return resultBlocks } - fun sequentialClickedEvent(blockId: String, blockName: String) { - val currentState = uiState.value - if (currentState is CourseVideosUIState.CourseData) { - analytics.sequentialClickedEvent(courseId, courseTitle, blockId, blockName) + private fun processDescendants(chapterBlock: Block, blocks: List) { + chapterBlock.descendants.forEach { descendantId -> + val sequentialBlock = blocks.find { it.id == descendantId } ?: return@forEach + val verticalBlocks = blocks.filter { block -> + block.id in sequentialBlock.descendants + } + val videoBlocks = blocks.filter { block -> + verticalBlocks.any { vertical -> block.id in vertical.descendants } && block.type == BlockType.VIDEO + } + addToSubSections(chapterBlock, sequentialBlock) + addToVideo(chapterBlock, videoBlocks) + updateSubSectionUnit(sequentialBlock, blocks) + updateDownloadsCount(sequentialBlock, blocks) + addDownloadableChildrenForSequentialBlock(sequentialBlock) } } - fun onChangingVideoQualityWhileDownloading() { - viewModelScope.launch { - _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.course_change_quality_when_downloading))) - } + private fun addToSubSections(chapterBlock: Block, sequentialBlock: Block) { + courseSubSections.getOrPut(chapterBlock.id) { mutableListOf() }.add(sequentialBlock) } - private fun sortBlocks(blocks: List): List { - val resultBlocks = mutableListOf() - if (blocks.isEmpty()) return emptyList() - blocks.forEach { block -> - if (block.type == BlockType.CHAPTER) { - 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) + private fun addToVideo(chapterBlock: Block, videoBlocks: List) { + courseVideos.getOrPut(chapterBlock.id) { mutableListOf() }.addAll(videoBlocks) + } - } else { - resultBlocks.add(it) + private fun updateSubSectionUnit(sequentialBlock: Block, blocks: List) { + courseSubSectionUnit[sequentialBlock.id] = sequentialBlock.getFirstDescendantBlock(blocks) + } + + private fun updateDownloadsCount(sequentialBlock: Block, blocks: List) { + subSectionsDownloadsCount[sequentialBlock.id] = sequentialBlock.getDownloadsCount(blocks) + } + + fun downloadBlocks(blocksIds: List, fragmentManager: FragmentManager) { + viewModelScope.launch { + val subSectionsBlocks = + 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) } - addDownloadableChildrenForSequentialBlock(it) } } + } else { + downloadDialogManager.showPopup( + subSectionsBlocks = requiredSubSections, + courseId = courseId, + isBlocksDownloaded = isAllBlocksDownloaded, + onlyVideoBlocks = true, + fragmentManager = fragmentManager, + removeDownloadModels = ::removeDownloadModels, + saveDownloadModels = { blockId -> + saveDownloadModels(fileUtil.getExternalAppDir().path, courseId, blockId) + } + ) } } - return resultBlocks.toList() + } + + fun onCompletedSectionVisibilityChange() { + if (_uiState.value is CourseVideoUIState.CourseData) { + val state = _uiState.value as CourseVideoUIState.CourseData + _uiState.value = state.copy(isCompletedSectionsShown = !state.isCompletedSectionsShown) + + analytics.logEvent( + CourseAnalyticsEvent.VIDEO_SHOW_COMPLETED.eventName, + buildMap { + put( + CourseAnalyticsKey.NAME.key, + CourseAnalyticsEvent.VIDEO_SHOW_COMPLETED.biValue + ) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + } + ) + } + } + + fun logVideoClick(blockId: String) { + if (_uiState.value is CourseVideoUIState.CourseData) { + analytics.logEvent( + CourseAnalyticsEvent.COURSE_CONTENT_VIDEO_CLICK.eventName, + buildMap { + put( + CourseAnalyticsKey.NAME.key, + CourseAnalyticsEvent.COURSE_CONTENT_VIDEO_CLICK.biValue + ) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + put(CourseAnalyticsKey.BLOCK_ID.key, blockId) + } + ) + } + } + + fun getBlockParent(blockId: String): Block? { + return allBlocks.values.find { blockId in it.descendants } } } 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 deleted file mode 100644 index ce05913d6..000000000 --- a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideosUIState.kt +++ /dev/null @@ -1,20 +0,0 @@ -package org.openedx.course.presentation.videos - -import org.openedx.core.domain.model.Block -import org.openedx.core.domain.model.CourseStructure -import org.openedx.core.module.db.DownloadedState -import org.openedx.core.module.download.DownloadModelsSize - -sealed class CourseVideosUIState { - data class CourseData( - val courseStructure: CourseStructure, - val downloadedState: Map, - val courseSubSections: Map>, - val courseSectionsState: Map, - val subSectionsDownloadsCount: Map, - val downloadModelsSize: DownloadModelsSize - ) : CourseVideosUIState() - - data class Empty(val message: String) : CourseVideosUIState() - 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..612056392 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 @@ -34,31 +33,31 @@ import androidx.compose.ui.platform.ViewCompositionStrategy 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.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 +import org.openedx.core.R as coreR class DownloadQueueFragment : Fragment() { @@ -80,7 +79,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, @@ -89,7 +88,7 @@ class DownloadQueueFragment : Fragment() { requireActivity().supportFragmentManager.popBackStack() }, onDownloadClick = { - viewModel.removeDownloadModels(it.id) + viewModel.removeDownloadModels(it.id, "") } ) } @@ -156,7 +155,7 @@ private fun DownloadQueueScreen( modifier = Modifier .fillMaxWidth() .padding(horizontal = 56.dp), - text = stringResource(id = R.string.course_download_queue_title), + text = stringResource(id = coreR.string.core_download_queue_title), color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.titleMedium, maxLines = 1, @@ -184,11 +183,17 @@ private fun DownloadQueueScreen( LazyColumn { items(uiState.downloadingModels) { model -> val progressValue = - if (model.id == uiState.currentProgressId) - uiState.currentProgressValue else 0 + if (model.id == uiState.currentProgressId) { + uiState.currentProgressValue + } else { + 0 + } val progressSize = - if (model.id == uiState.currentProgressId) - uiState.currentProgressSize else 0 + if (model.id == uiState.currentProgressId) { + uiState.currentProgressSize + } else { + 0 + } OfflineQueueCard( downloadModel = model, @@ -212,8 +217,7 @@ private fun DownloadQueueScreen( } } - -@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, device = Devices.TABLET) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun DownloadQueueScreenPreview() { @@ -223,6 +227,7 @@ private fun DownloadQueueScreenPreview() { uiState = DownloadQueueUIState.Models( listOf( DownloadModel( + courseId = "", id = "", title = "1", size = 0, @@ -230,9 +235,9 @@ private fun DownloadQueueScreenPreview() { url = "", type = FileType.VIDEO, downloadedState = DownloadedState.DOWNLOADING, - progress = 0f ), DownloadModel( + courseId = "", id = "", title = "2", size = 0, @@ -240,7 +245,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..67e161378 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,14 @@ 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() @@ -31,7 +39,6 @@ class DownloadQueueViewModel( if (descendants.isEmpty()) models else models.filter { descendants.contains(it.id) } if (filteredModels.isEmpty()) { _uiState.value = DownloadQueueUIState.Empty - } else { if (_uiState.value is DownloadQueueUIState.Models) { val state = _uiState.value as DownloadQueueUIState.Models @@ -66,7 +73,7 @@ class DownloadQueueViewModel( } } - override fun removeDownloadModels(blockId: String) { + override fun removeDownloadModels(blockId: String, courseId: String) { viewModelScope.launch { workerController.removeModel(blockId) } 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 96% 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..c909f97cf 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 @@ -41,7 +41,7 @@ class ImageProcessor(private val context: Context) { ScriptIntrinsicBlur.create(renderScript, bitmapAlloc.element).apply { setRadius(blurRadio) setInput(bitmapAlloc) - repeat(3) { + repeat(times = 3) { forEach(bitmapAlloc) } } 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..83b56a997 --- /dev/null +++ b/course/src/main/java/org/openedx/course/worker/OfflineProgressSyncWorker.kt @@ -0,0 +1,85 @@ +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/anim/course_slide_in_down.xml b/course/src/main/res/anim/course_slide_in_down.xml deleted file mode 100644 index 6201b1915..000000000 --- a/course/src/main/res/anim/course_slide_in_down.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - \ No newline at end of file diff --git a/course/src/main/res/anim/course_slide_in_up.xml b/course/src/main/res/anim/course_slide_in_up.xml deleted file mode 100644 index 8aa389749..000000000 --- a/course/src/main/res/anim/course_slide_in_up.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - \ No newline at end of file diff --git a/course/src/main/res/anim/course_slide_out_down.xml b/course/src/main/res/anim/course_slide_out_down.xml deleted file mode 100644 index 5a97ec71e..000000000 --- a/course/src/main/res/anim/course_slide_out_down.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - \ No newline at end of file diff --git a/course/src/main/res/anim/course_slide_out_up.xml b/course/src/main/res/anim/course_slide_out_up.xml deleted file mode 100644 index 2db3f4d94..000000000 --- a/course/src/main/res/anim/course_slide_out_up.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - \ No newline at end of file diff --git a/course/src/main/res/drawable/core_ic_error.xml b/course/src/main/res/drawable/core_ic_error.xml deleted file mode 100644 index 391e8b4c5..000000000 --- a/course/src/main/res/drawable/core_ic_error.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/course/src/main/res/drawable/ic_course_arrow_down.xml b/course/src/main/res/drawable/course_ic_arrow_down.xml similarity index 86% rename from course/src/main/res/drawable/ic_course_arrow_down.xml rename to course/src/main/res/drawable/course_ic_arrow_down.xml index 8265e6957..0f502bde0 100644 --- a/course/src/main/res/drawable/ic_course_arrow_down.xml +++ b/course/src/main/res/drawable/course_ic_arrow_down.xml @@ -5,5 +5,5 @@ android:viewportHeight="24"> + android:fillColor="#ffffff" /> diff --git a/course/src/main/res/drawable/course_ic_block.xml b/course/src/main/res/drawable/course_ic_block.xml new file mode 100644 index 000000000..0e8cf67d6 --- /dev/null +++ b/course/src/main/res/drawable/course_ic_block.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + 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_calenday_sync.xml b/course/src/main/res/drawable/course_ic_calenday_sync.xml deleted file mode 100644 index 32a1bf361..000000000 --- a/course/src/main/res/drawable/course_ic_calenday_sync.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/course/src/main/res/drawable/course_ic_certificate.xml b/course/src/main/res/drawable/course_ic_certificate.xml new file mode 100644 index 000000000..a2a3ae2f9 --- /dev/null +++ b/course/src/main/res/drawable/course_ic_certificate.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_discussion.xml b/course/src/main/res/drawable/course_ic_discussion.xml new file mode 100644 index 000000000..0cd49c885 --- /dev/null +++ b/course/src/main/res/drawable/course_ic_discussion.xml @@ -0,0 +1,16 @@ + + + + + + diff --git a/course/src/main/res/drawable/ic_course_gated.xml b/course/src/main/res/drawable/course_ic_gated.xml similarity index 89% rename from course/src/main/res/drawable/ic_course_gated.xml rename to course/src/main/res/drawable/course_ic_gated.xml index bda1fb76a..4a0e638c6 100644 --- a/course/src/main/res/drawable/ic_course_gated.xml +++ b/course/src/main/res/drawable/course_ic_gated.xml @@ -5,8 +5,8 @@ android:viewportHeight="16"> + android:fillColor="#D23228" /> + android:fillColor="#ffffff" /> diff --git a/course/src/main/res/drawable/course_ic_pen.xml b/course/src/main/res/drawable/course_ic_pen.xml new file mode 100644 index 000000000..b384fe453 --- /dev/null +++ b/course/src/main/res/drawable/course_ic_pen.xml @@ -0,0 +1,30 @@ + + + + + + + + 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_screen_rotation.xml b/course/src/main/res/drawable/course_ic_screen_rotation.xml deleted file mode 100644 index 550684665..000000000 --- a/course/src/main/res/drawable/course_ic_screen_rotation.xml +++ /dev/null @@ -1,36 +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/course_ic_video.xml b/course/src/main/res/drawable/course_ic_video.xml new file mode 100644 index 000000000..a5cbd960b --- /dev/null +++ b/course/src/main/res/drawable/course_ic_video.xml @@ -0,0 +1,23 @@ + + + + + + + diff --git a/course/src/main/res/drawable/course_ic_warning.xml b/course/src/main/res/drawable/course_ic_warning.xml new file mode 100644 index 000000000..635c5ca80 --- /dev/null +++ b/course/src/main/res/drawable/course_ic_warning.xml @@ -0,0 +1,9 @@ + + + diff --git a/course/src/main/res/drawable/course_video_play_button.xml b/course/src/main/res/drawable/course_video_play_button.xml new file mode 100644 index 000000000..ce96d5ec1 --- /dev/null +++ b/course/src/main/res/drawable/course_video_play_button.xml @@ -0,0 +1,12 @@ + + + + diff --git a/course/src/main/res/drawable/ic_calendar_month.xml b/course/src/main/res/drawable/ic_calendar_month.xml deleted file mode 100644 index 434cf9907..000000000 --- a/course/src/main/res/drawable/ic_calendar_month.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/course/src/main/res/drawable/ic_course_block.xml b/course/src/main/res/drawable/ic_course_block.xml deleted file mode 100644 index 9445f48f0..000000000 --- a/course/src/main/res/drawable/ic_course_block.xml +++ /dev/null @@ -1,52 +0,0 @@ - - - - - - - - - - - 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_check.xml b/course/src/main/res/drawable/ic_course_check.xml deleted file mode 100644 index 10551dea9..000000000 --- a/course/src/main/res/drawable/ic_course_check.xml +++ /dev/null @@ -1,9 +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/drawable/ic_course_discussion.xml b/course/src/main/res/drawable/ic_course_discussion.xml deleted file mode 100644 index b25683821..000000000 --- a/course/src/main/res/drawable/ic_course_discussion.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - diff --git a/course/src/main/res/drawable/ic_course_marker.xml b/course/src/main/res/drawable/ic_course_marker.xml new file mode 100644 index 000000000..007f3425b --- /dev/null +++ b/course/src/main/res/drawable/ic_course_marker.xml @@ -0,0 +1,12 @@ + + + + diff --git a/course/src/main/res/drawable/ic_course_navigation_discussions.xml b/course/src/main/res/drawable/ic_course_navigation_discussions.xml deleted file mode 100644 index 3f875475e..000000000 --- a/course/src/main/res/drawable/ic_course_navigation_discussions.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - diff --git a/course/src/main/res/drawable/ic_course_navigation_more.xml b/course/src/main/res/drawable/ic_course_navigation_more.xml deleted file mode 100644 index acc5a2c56..000000000 --- a/course/src/main/res/drawable/ic_course_navigation_more.xml +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - - - - diff --git a/course/src/main/res/drawable/ic_course_navigation_outline.xml b/course/src/main/res/drawable/ic_course_navigation_outline.xml deleted file mode 100644 index 984fc91ac..000000000 --- a/course/src/main/res/drawable/ic_course_navigation_outline.xml +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - - - - diff --git a/course/src/main/res/drawable/ic_course_navigation_video.xml b/course/src/main/res/drawable/ic_course_navigation_video.xml deleted file mode 100644 index 095992bf5..000000000 --- a/course/src/main/res/drawable/ic_course_navigation_video.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - diff --git a/course/src/main/res/drawable/ic_course_pen.xml b/course/src/main/res/drawable/ic_course_pen.xml deleted file mode 100644 index bf05828b6..000000000 --- a/course/src/main/res/drawable/ic_course_pen.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - diff --git a/course/src/main/res/drawable/ic_course_video.xml b/course/src/main/res/drawable/ic_course_video.xml deleted file mode 100644 index 544550562..000000000 --- a/course/src/main/res/drawable/ic_course_video.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - diff --git a/course/src/main/res/drawable/ic_lock.xml b/course/src/main/res/drawable/ic_lock.xml deleted file mode 100644 index 68cb9c1f5..000000000 --- a/course/src/main/res/drawable/ic_lock.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/course/src/main/res/drawable/rounded_top.xml b/course/src/main/res/drawable/rounded_top.xml deleted file mode 100644 index fb871fb60..000000000 --- a/course/src/main/res/drawable/rounded_top.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/course/src/main/res/layout-w600dp-h480dp/fragment_course_unit_container.xml b/course/src/main/res/layout-w600dp-h480dp/fragment_course_unit_container.xml index f06c77405..697e0840a 100644 --- a/course/src/main/res/layout-w600dp-h480dp/fragment_course_unit_container.xml +++ b/course/src/main/res/layout-w600dp-h480dp/fragment_course_unit_container.xml @@ -39,6 +39,14 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/frameBack" /> + + + app:layout_constraintTop_toBottomOf="@+id/videoList" /> + + + app:layout_constraintTop_toBottomOf="@+id/videoList" /> - - Огляд курсу - Зміст курсу - Одиниці курсу - Підрозділи курсу - Відео - Ви успішно пройшли курс! Тепер ви можете отримати сертифікат - Ви успішно пройшли курс - Вітаємо! - Переглянути сертифікат - Ви можете отримати сертифікат після проходження курсу (заробіть необхідну оцінку) - Отримати сертифікат - Назад - Попередня одиниця - Далі - Наступна одиниця - Завершити - Цей курс не містить відео. - Остання одиниця: - Продовжити - Обговорення - Роздаткові матеріали - Оголошення - Знайдіть важливу інформацію про курс - Будьте в курсі останніх новин - Гарна робота! - Секція \"%s\" завершена. - Наступний розділ - Повернутись до модуля - Цей курс ще не розпочався. - Ви не підключені до Інтернету. Будь ласка, перевірте ваше підключення до Інтернету. - Курс - Відео - Обговорення - Матеріали - Ви можете завантажувати контент тільки через Wi-Fi - Ця інтерактивна компонента ще не доступна - Досліджуйте інші частини цього курсу або перегляньте це на веб-сайті. - Відкрити в браузері - Субтитри - Остання активність: - Продовжити - Щоб перейти до \"%s\", натисніть \"Наступний розділ\". - - - Відеоплеєр - Видалити секцію курсу - Завантажити секцію курсу - Зупинити завантаження секції курсу - Секція завершена - Секція не завершена - diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index c6b370267..dd59fcf8f 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -1,25 +1,10 @@ - Course Outline - Course content - 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 Handouts Announcements Find important course information @@ -29,56 +14,44 @@ Back to outline Next section 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: - Resume To proceed with \"%s\" press \"Next section\". Some content in this part of the course is locked for upgraded users only. You cannot change the download video quality when all videos are downloading Dates Shifted + Course Completion + This represents how much of the course content you have completed. Note that some content may not yet be released. + Overall Grade + This represents your weighted grade against the grade needed to pass this course. + Current Overall Weighted Grade: + A weighted grade of %1$s%% is required to pass this course + Grade Details + Assignment Type + Current / Max % - - Course dates are not currently available. + Home + Content + Discussions + More + Dates + Downloads + Progress - - Sync to calendar - Automatically sync all deadlines and due dates for this course to your calendar. + All + Videos + Assignments - \“%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 - - + + Course Completion + Progress + You have completed %1$s%% of the course content + Completed + Next Sequence + View All Content Video player @@ -87,13 +60,58 @@ Stop downloading course section Section completed Section uncompleted + Video watched + Assignment completed + Assignment due date past + + + %1$s/%2$s Section Completed + %1$s/%2$s Sections Completed + + + Back + Your free audit access to this course expired on %s. + This course will begin on %s. Come back then to start learning! + An error occurred while loading your course + Continue + View Completed + Hide Completed + Completed + %1$s / %2$s Complete + of Grade + %1$s / %2$s%% + This course does not contain graded assignments. + %1$s/%2$s Watched + %1$s/%2$s Completed + Complete - %1$s points + Past Due - %1$s points + - %1$s points + %1$s %% of Grade + Review Course Grading Policy + Return to Course Home + + + Continue Watching + Next Video + View All Videos + %1$s left + Videos\ncompleted + + + Assignments\ncompleted + View All Assignments + Next Assignment + Due Today: %1$s + %1$d Days Past Due: %2$s + Due in %1$d Days: %2$s + Section: %1$s - Downloads - (Untitled) - Download - The videos you\'ve selected are larger than 1 GB. Do you want to download these videos? - 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\"? + + Grades + This represents your weighted grade against the grade needed to pass this course. + View Progress + + You\'re all caught up on videos! + You\'re all caught up on assignments! diff --git a/course/src/main/res/values/values.xml b/course/src/main/res/values/values.xml index 917f92663..a6b3daec9 100644 --- a/course/src/main/res/values/values.xml +++ b/course/src/main/res/values/values.xml @@ -1,7 +1,2 @@ - - - \ No newline at end of file + \ No newline at end of file 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..f9b17792c 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 @@ -11,6 +11,8 @@ import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain @@ -22,26 +24,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 java.net.UnknownHostException +import org.openedx.course.utils.ImageProcessor +import org.openedx.foundation.system.ResourceManager import java.util.Date @OptIn(ExperimentalCoroutinesApi::class) @@ -55,18 +62,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 +90,47 @@ 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 +152,49 @@ 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 +205,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 } @@ -128,167 +217,167 @@ class CourseContainerViewModelTest { Dispatchers.resetMain() } + @Suppress("TooGenericExceptionThrown") @Test - fun `preloadCourseStructure internet connection 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 UnknownHostException() - every { analytics.logEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } returns Unit - viewModel.preloadCourseStructure() + coEvery { + interactor.getCourseStructureFlow(any(), any()) + } returns flowOf(null) + coEvery { + interactor.getEnrollmentDetailsFlow(any()) + } returns flow { throw Exception() } + every { + analytics.logScreenEvent( + CourseAnalyticsEvent.DASHBOARD.eventName, + any() + ) + } returns Unit + every { + analytics.logScreenEvent( + CourseAnalyticsEvent.HOME_TAB.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(noInternet, message) + coVerify(exactly = 1) { interactor.getEnrollmentDetailsFlow(any()) } + verify(exactly = 1) { + analytics.logScreenEvent( + CourseAnalyticsEvent.DASHBOARD.eventName, + any() + ) + } + verify(exactly = 1) { + analytics.logScreenEvent( + CourseAnalyticsEvent.HOME_TAB.eventName, + any() + ) + } assert(!viewModel.refreshing.value) - assert(viewModel.dataReady.value == null) + assert(viewModel.courseAccessStatus.value == CourseAccessError.UNKNOWN) } @Test - fun `preloadCourseStructure unknown exception`() = 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()) } throws Exception() - every { analytics.logEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } returns Unit - viewModel.preloadCourseStructure() + coEvery { interactor.getCourseStructureFlow(any(), any()) } returns flowOf(courseStructure) + coEvery { interactor.getEnrollmentDetailsFlow(any()) } returns flowOf(enrollmentDetails) + every { + analytics.logScreenEvent( + CourseAnalyticsEvent.DASHBOARD.eventName, + any() + ) + } returns Unit + every { + analytics.logScreenEvent( + CourseAnalyticsEvent.HOME_TAB.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) - assert(!viewModel.refreshing.value) - assert(viewModel.dataReady.value == null) - } - - @Test - fun `preloadCourseStructure success with internet`() = runTest { - val viewModel = CourseContainerViewModel( - "", - "", - "", - config, - interactor, - calendarManager, - resourceManager, - notifier, - networkConnection, - corePreferences, - coursePreferences, - analytics, - imageProcessor, - 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() - advanceUntilIdle() - - coVerify(exactly = 1) { interactor.preloadCourseStructure(any()) } - verify(exactly = 1) { analytics.logEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } - + coVerify(exactly = 1) { interactor.getEnrollmentDetailsFlow(any()) } + verify(exactly = 1) { + analytics.logScreenEvent( + CourseAnalyticsEvent.DASHBOARD.eventName, + any() + ) + } + verify(exactly = 1) { + analytics.logScreenEvent( + CourseAnalyticsEvent.HOME_TAB.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.getCourseStructureFlow(any(), any()) } returns flowOf(courseStructure) + coEvery { interactor.getEnrollmentDetailsFlow(any()) } returns flowOf(enrollmentDetails) + every { + analytics.logScreenEvent( + CourseAnalyticsEvent.DASHBOARD.eventName, + any() + ) + } returns Unit + every { + analytics.logScreenEvent( + CourseAnalyticsEvent.HOME_TAB.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() + ) + } + verify(exactly = 1) { + analytics.logScreenEvent( + CourseAnalyticsEvent.HOME_TAB.eventName, + any() + ) + } assert(viewModel.errorMessage.value == null) assert(!viewModel.refreshing.value) - assert(viewModel.dataReady.value != null) - } - - @Test - fun `updateData no internet connection exception`() = runTest { - val viewModel = CourseContainerViewModel( - "", - "", - "", - config, - interactor, - calendarManager, - resourceManager, - notifier, - networkConnection, - corePreferences, - coursePreferences, - analytics, - imageProcessor, - courseRouter - ) - coEvery { interactor.preloadCourseStructure(any()) } throws UnknownHostException() - coEvery { notifier.send(CourseStructureUpdated("")) } returns Unit - viewModel.updateData() - advanceUntilIdle() - - coVerify(exactly = 1) { interactor.preloadCourseStructure(any()) } - - val message = viewModel.errorMessage.value - assertEquals(noInternet, message) - assert(!viewModel.refreshing.value) + assert(viewModel.courseAccessStatus.value != null) } @Test @@ -299,22 +388,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 +417,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..981c88783 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 { @@ -34,12 +36,6 @@ class HandoutsViewModelTest { private val interactor = mockk() private val analytics = mockk() - //region mockHandoutsModel - - private val handoutsModel = HandoutsModel("") - - //endregion - @Before fun setUp() { Dispatchers.setMain(dispatcher) @@ -57,7 +53,7 @@ class HandoutsViewModelTest { coEvery { interactor.getHandouts(any()) } throws UnknownHostException() advanceUntilIdle() - assert(viewModel.htmlContent.value == null) + assert(viewModel.uiState.value == HandoutsUIState.Error) } @Test @@ -66,7 +62,7 @@ class HandoutsViewModelTest { coEvery { interactor.getHandouts(any()) } throws Exception() advanceUntilIdle() - assert(viewModel.htmlContent.value == null) + assert(viewModel.uiState.value == HandoutsUIState.Error) } @Test @@ -79,7 +75,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 +93,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 +107,7 @@ class HandoutsViewModelTest { ) ) viewModel.injectDarkMode( - viewModel.htmlContent.value.toString(), + viewModel.uiState.value.toString(), ULong.MAX_VALUE, ULong.MAX_VALUE ) @@ -119,6 +115,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/home/CourseHomeViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/home/CourseHomeViewModelTest.kt new file mode 100644 index 000000000..7196d7df3 --- /dev/null +++ b/course/src/test/java/org/openedx/course/presentation/home/CourseHomeViewModelTest.kt @@ -0,0 +1,865 @@ +package org.openedx.course.presentation.home + +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.flow +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.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.openedx.core.Mock +import org.openedx.core.R +import org.openedx.core.config.Config +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.helper.VideoPreviewHelper +import org.openedx.core.module.DownloadWorkerController +import org.openedx.core.module.db.DownloadDao +import org.openedx.core.module.download.DownloadHelper +import org.openedx.core.presentation.CoreAnalytics +import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.system.notifier.CourseDatesShifted +import org.openedx.core.system.notifier.CourseNotifier +import org.openedx.core.system.notifier.CourseOpenBlock +import org.openedx.core.system.notifier.CourseProgressLoaded +import org.openedx.core.system.notifier.CourseStructureUpdated +import org.openedx.course.domain.interactor.CourseInteractor +import org.openedx.course.presentation.CourseAnalytics +import org.openedx.course.presentation.CourseAnalyticsEvent +import org.openedx.course.presentation.CourseAnalyticsKey +import org.openedx.course.presentation.CourseRouter +import org.openedx.foundation.system.ResourceManager +import org.openedx.foundation.utils.FileUtil +import java.net.UnknownHostException +import org.openedx.course.R as courseR + +@Suppress("LargeClass") +@OptIn(ExperimentalCoroutinesApi::class) +class CourseHomeViewModelTest { + + @get:Rule + val testInstantTaskExecutorRule: TestRule = InstantTaskExecutorRule() + private val dispatcher = StandardTestDispatcher() + private val courseId = "test-course-id" + private val courseTitle = "Test Course" + private val config = mockk() + private val interactor = mockk() + private val resourceManager = mockk() + private val courseNotifier = mockk() + private val networkConnection = mockk() + private val preferencesManager = mockk() + private val analytics = mockk() + private val downloadDialogManager = mockk() + private val fileUtil = mockk() + private val courseRouter = mockk() + private val coreAnalytics = mockk() + private val downloadDao = mockk() + private val workerController = mockk() + private val downloadHelper = mockk() + private val videoPreviewHelper = 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 courseStructure = Mock.mockCourseStructure.copy( + id = courseId, + name = courseTitle + ) + private val courseComponentStatus = Mock.mockCourseComponentStatus + private val courseDatesResult = Mock.mockCourseDatesResult + private val courseProgress = Mock.mockCourseProgress + private val videoProgress = Mock.mockVideoProgress + private val resetCourseDates = Mock.mockResetCourseDates + + @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 { + resourceManager.getString(courseR.string.course_can_download_only_with_wifi) + } returns cantDownload + every { + resourceManager.getString(R.string.core_dates_shift_dates_unsuccessful_msg) + } returns "Failed to shift dates" + + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns true + every { config.getCourseUIConfig().isCourseDownloadQueueEnabled } returns true + + every { preferencesManager.isRelativeDatesEnabled } returns true + every { preferencesManager.videoSettings.wifiDownloadOnly } returns false + + every { networkConnection.isWifiConnected() } returns true + every { networkConnection.isOnline() } returns true + + every { fileUtil.getExternalAppDir().path } returns "/test/path" + + every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } + + every { courseNotifier.notifier } returns flow { } + coEvery { courseNotifier.send(any()) } returns Unit + + every { analytics.logEvent(any(), any()) } returns Unit + every { coreAnalytics.logEvent(any(), any()) } returns Unit + + every { + downloadDialogManager.showPopup( + any(), + any(), + any(), + any(), + any(), + any() + ) + } returns Unit + + coEvery { workerController.saveModels(any()) } returns Unit + + every { videoPreviewHelper.getVideoPreview(any(), any()) } returns null + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `getCourseData success`() = runTest { + coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { + emit( + courseStructure + ) + } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false, + true + ) + } returns flow { emit(courseProgress) } + coEvery { interactor.getVideoProgress("video1") } returns videoProgress + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + + coVerify { interactor.getCourseStructureFlow(courseId, false) } + coVerify { interactor.getCourseStatusFlow(courseId) } + coVerify { interactor.getCourseDatesFlow(courseId) } + coVerify { interactor.getCourseProgress(courseId, false, true) } + + assertTrue(viewModel.uiState.value is CourseHomeUIState.CourseData) + val courseData = viewModel.uiState.value as CourseHomeUIState.CourseData + assertEquals(courseId, courseData.courseStructure.id) + assertEquals(courseTitle, courseData.courseStructure.name) + assertEquals(courseProgress, courseData.courseProgress) + } + + @Test + fun `getCourseData no internet connection error`() = runTest { + coEvery { + interactor.getCourseStructureFlow( + courseId, + false + ) + } returns flow { throw UnknownHostException() } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false, + true + ) + } returns flow { emit(courseProgress) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + + assertTrue(viewModel.uiState.value !is CourseHomeUIState.CourseData) + } + + @Suppress("TooGenericExceptionThrown") + @Test + fun `getCourseData unknown error`() = runTest { + coEvery { + interactor.getCourseStructureFlow( + courseId, + false + ) + } returns flow { throw Exception() } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false, + true + ) + } returns flow { emit(courseProgress) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + + assertTrue(viewModel.uiState.value !is CourseHomeUIState.CourseData) + } + + @Test + fun `saveDownloadModels with wifi only enabled but no wifi connection`() = runTest { + every { preferencesManager.videoSettings.wifiDownloadOnly } returns true + every { networkConnection.isWifiConnected() } returns false + + coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { + emit( + courseStructure + ) + } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false, + true + ) + } returns flow { emit(courseProgress) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + + viewModel.saveDownloadModels("/test/path", courseId, "test-block-id") + + coVerify(exactly = 0) { workerController.saveModels(any()) } + } + + @Test + fun `resetCourseDatesBanner success`() = runTest { + coEvery { interactor.resetCourseDates(courseId) } returns resetCourseDates + coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { + emit( + courseStructure + ) + } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false, + true + ) + } returns flow { emit(courseProgress) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + + var resetResult: Boolean? = null + + viewModel.resetCourseDatesBanner { success -> + resetResult = success + } + + advanceUntilIdle() + + coVerify { interactor.resetCourseDates(courseId) } + coVerify { courseNotifier.send(CourseDatesShifted) } + assertEquals(true, resetResult) + } + + @Test + fun `resetCourseDatesBanner with internet error`() = runTest { + coEvery { interactor.resetCourseDates(courseId) } throws UnknownHostException() + coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { + emit( + courseStructure + ) + } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false, + true + ) + } returns flow { emit(courseProgress) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + + var resetResult: Boolean? = null + + viewModel.resetCourseDatesBanner { success -> + resetResult = success + } + + advanceUntilIdle() + + coVerify { interactor.resetCourseDates(courseId) } + coVerify(exactly = 0) { courseNotifier.send(CourseDatesShifted) } + assertEquals(false, resetResult) + } + + @Test + fun `logVideoClick analytics event`() = runTest { + coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { + emit( + courseStructure + ) + } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false, + true + ) + } returns flow { emit(courseProgress) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + + viewModel.logVideoClick("video1") + + verify { + analytics.logEvent( + CourseAnalyticsEvent.COURSE_HOME_VIDEO_CLICK.eventName, + match { + it[CourseAnalyticsKey.NAME.key] == CourseAnalyticsEvent.COURSE_HOME_VIDEO_CLICK.biValue && + it[CourseAnalyticsKey.COURSE_ID.key] == courseId && + it[CourseAnalyticsKey.COURSE_NAME.key] == courseTitle && + it[CourseAnalyticsKey.BLOCK_ID.key] == "video1" + } + ) + } + } + + @Test + fun `logAssignmentClick analytics event`() = runTest { + coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { + emit( + courseStructure + ) + } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false, + true + ) + } returns flow { emit(courseProgress) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + + viewModel.logAssignmentClick("assignment1") + + verify { + analytics.logEvent( + CourseAnalyticsEvent.COURSE_HOME_ASSIGNMENT_CLICK.eventName, + match { + it[CourseAnalyticsKey.NAME.key] == CourseAnalyticsEvent.COURSE_HOME_ASSIGNMENT_CLICK.biValue && + it[CourseAnalyticsKey.COURSE_ID.key] == courseId && + it[CourseAnalyticsKey.COURSE_NAME.key] == courseTitle && + it[CourseAnalyticsKey.BLOCK_ID.key] == "assignment1" + } + ) + } + } + + @Test + fun `viewCertificateTappedEvent analytics event`() = runTest { + coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { + emit( + courseStructure + ) + } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false, + true + ) + } returns flow { emit(courseProgress) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + + viewModel.viewCertificateTappedEvent() + + verify { + analytics.logEvent( + CourseAnalyticsEvent.VIEW_CERTIFICATE.eventName, + match { + it[CourseAnalyticsKey.NAME.key] == CourseAnalyticsEvent.VIEW_CERTIFICATE.biValue && + it[CourseAnalyticsKey.COURSE_ID.key] == courseId + } + ) + } + } + + @Test + fun `getCourseProgress success`() = runTest { + coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { + emit( + courseStructure + ) + } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false, + true + ) + } returns flow { emit(courseProgress) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + + viewModel.getCourseProgress() + + coVerify { interactor.getCourseProgress(courseId, false, true) } + } + + @Test + fun `CourseStructureUpdated notifier event`() = runTest { + coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { + emit( + courseStructure + ) + } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false, + true + ) + } returns flow { emit(courseProgress) } + + every { courseNotifier.notifier } returns flow { emit(CourseStructureUpdated(courseId)) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + + coVerify(atLeast = 2) { interactor.getCourseStructureFlow(courseId, false) } + } + + @Test + fun `CourseOpenBlock notifier event`() = runTest { + coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { + emit( + courseStructure + ) + } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false, + true + ) + } returns flow { emit(courseProgress) } + + every { courseNotifier.notifier } returns flow { emit(CourseOpenBlock("test-block-id")) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + } + + @Test + fun `CourseProgressLoaded notifier event`() = runTest { + coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { + emit( + courseStructure + ) + } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false, + true + ) + } returns flow { emit(courseProgress) } + + every { courseNotifier.notifier } returns flow { emit(CourseProgressLoaded) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + + coVerify(atLeast = 2) { interactor.getCourseProgress(courseId, false, true) } + } + + @Test + fun `isCourseDropdownNavigationEnabled property`() = runTest { + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns true + + coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { + emit( + courseStructure + ) + } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false, + true + ) + } returns flow { emit(courseProgress) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + assertTrue(viewModel.isCourseDropdownNavigationEnabled) + } +} 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..62fc097b7 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 @@ -15,6 +15,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain @@ -29,10 +30,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 +49,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.presentation.dialog.downloaddialog.DownloadDialogManager 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.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 +83,22 @@ 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, + shortLabel = "HW1", + ) + private val blocks = listOf( Block( id = "id", @@ -97,7 +114,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 +133,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 +152,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 +180,8 @@ class CourseOutlineViewModelTest { ), media = null, certificate = null, - isSelfPaced = false + isSelfPaced = false, + progress = null ) private val dateBlock = CourseDateBlock( @@ -192,6 +219,7 @@ class CourseOutlineViewModelTest { private val downloadModel = DownloadModel( "id", "title", + "", 0, "", "url", @@ -205,10 +233,15 @@ class CourseOutlineViewModelTest { 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 { resourceManager.getString(org.openedx.course.R.string.course_can_download_only_with_wifi) } returns cantDownload + 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 + coEvery { interactor.getCourseDatesFlow(any()) } returns flowOf(mockedCourseDatesResult) } @After @@ -217,47 +250,68 @@ class CourseOutlineViewModelTest { } @Test - fun `getCourseDataInternal no internet connection exception`() = runTest(UnconfinedTestDispatcher()) { - every { interactor.getCourseStructureFromCache() } returns courseStructure - every { networkConnection.isOnline() } returns true - every { downloadDao.readAllData() } returns flow { emit(emptyList()) } - coEvery { interactor.getCourseStatus(any()) } throws UnknownHostException() - - val viewModel = CourseOutlineViewModel( - "", - "", - config, - interactor, - resourceManager, - notifier, - networkConnection, - preferencesManager, - analytics, - coreAnalytics, - downloadDao, - workerController, - ) + fun `getCourseDataInternal no internet connection exception`() = + runTest(UnconfinedTestDispatcher()) { + coEvery { interactor.getCourseStructureFlow(any(), any()) } returns flowOf( + courseStructure + ) + every { networkConnection.isOnline() } returns true + every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } + every { + downloadDialogManager.showPopup( + any(), + any(), + any(), + any(), + any(), + any(), + any(), + any(), + any(), + ) + } returns Unit + coEvery { interactor.getCourseStatusFlow(any()) } returns flow { throw UnknownHostException() } + + val viewModel = CourseContentAllViewModel( + "", + "", + config, + interactor, + resourceManager, + notifier, + networkConnection, + preferencesManager, + analytics, + downloadDialogManager, + fileUtil, + courseRouter, + coreAnalytics, + downloadDao, + workerController, + downloadHelper, + ) - val message = async { - viewModel.uiMessage.first() as? UIMessage.SnackBarMessage - } - viewModel.getCourseData() - advanceUntilIdle() + val message = async { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + viewModel.getCourseData() + advanceUntilIdle() - verify(exactly = 1) { interactor.getCourseStructureFromCache() } - coVerify(exactly = 1) { interactor.getCourseStatus(any()) } + coVerify(exactly = 2) { interactor.getCourseStructureFlow(any(), any()) } + coVerify(exactly = 2) { interactor.getCourseStatusFlow(any()) } - assertEquals(noInternet, message.await()?.message) - assert(viewModel.uiState.value is CourseOutlineUIState.Loading) - } + assertEquals(noInternet, message.await()?.message) + assert(viewModel.uiState.value is CourseContentAllUIState.Error) + } + @Suppress("TooGenericExceptionThrown") @Test fun `getCourseDataInternal unknown exception`() = runTest(UnconfinedTestDispatcher()) { - every { interactor.getCourseStructureFromCache() } returns courseStructure + coEvery { interactor.getCourseStructureFlow(any(), any()) } returns flowOf(courseStructure) every { networkConnection.isOnline() } returns true - every { downloadDao.readAllData() } returns flow { emit(emptyList()) } - coEvery { interactor.getCourseStatus(any()) } throws Exception() - val viewModel = CourseOutlineViewModel( + every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } + coEvery { interactor.getCourseStatusFlow(any()) } returns flow { throw Exception() } + val viewModel = CourseContentAllViewModel( "", "", config, @@ -267,9 +321,13 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + downloadDialogManager, + fileUtil, + courseRouter, coreAnalytics, downloadDao, - workerController + workerController, + downloadHelper, ) val message = async { @@ -278,156 +336,182 @@ class CourseOutlineViewModelTest { viewModel.getCourseData() advanceUntilIdle() - verify(exactly = 1) { interactor.getCourseStructureFromCache() } - coVerify(exactly = 1) { interactor.getCourseStatus(any()) } + coVerify(exactly = 2) { interactor.getCourseStructureFlow(any(), any()) } + coVerify(exactly = 2) { interactor.getCourseStatusFlow(any()) } assertEquals(somethingWrong, message.await()?.message) - assert(viewModel.uiState.value is CourseOutlineUIState.Loading) + assert(viewModel.uiState.value is CourseContentAllUIState.Error) } @Test - fun `getCourseDataInternal success with internet connection`() = runTest(UnconfinedTestDispatcher()) { - every { interactor.getCourseStructureFromCache() } returns courseStructure - every { networkConnection.isOnline() } returns true - coEvery { downloadDao.readAllData() } returns flow { - emit( - listOf( - DownloadModelEntity.createFrom( - downloadModel + fun `getCourseDataInternal success with internet connection`() = + runTest(UnconfinedTestDispatcher()) { + coEvery { interactor.getCourseStructureFlow(any(), any()) } returns flowOf( + courseStructure + ) + every { networkConnection.isOnline() } returns true + coEvery { downloadDao.getAllDataFlow() } returns flow { + emit( + listOf( + DownloadModelEntity.createFrom( + downloadModel + ) ) ) + } + coEvery { interactor.getCourseStatusFlow(any()) } returns flowOf(CourseComponentStatus("id")) + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false + + val viewModel = CourseContentAllViewModel( + "", + "", + config, + interactor, + resourceManager, + notifier, + networkConnection, + preferencesManager, + analytics, + downloadDialogManager, + fileUtil, + courseRouter, + coreAnalytics, + downloadDao, + workerController, + downloadHelper, ) - } - coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") - every { config.isCourseNestedListEnabled() } returns false - val viewModel = CourseOutlineViewModel( - "", - "", - config, - interactor, - resourceManager, - notifier, - networkConnection, - preferencesManager, - analytics, - coreAnalytics, - downloadDao, - workerController - ) - - val message = async { - withTimeoutOrNull(5000) { - viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } } - } - viewModel.getCourseData() - advanceUntilIdle() - verify(exactly = 1) { interactor.getCourseStructureFromCache() } - coVerify(exactly = 1) { interactor.getCourseStatus(any()) } + viewModel.getCourseData() + advanceUntilIdle() - assert(message.await() == null) - assert(viewModel.uiState.value is CourseOutlineUIState.CourseData) - } + coVerify(exactly = 2) { interactor.getCourseStructureFlow(any(), any()) } + coVerify(exactly = 2) { interactor.getCourseStatusFlow(any()) } + + assert(message.await() == null) + assert(viewModel.uiState.value is CourseContentAllUIState.CourseData) + } @Test - fun `getCourseDataInternal success without internet connection`() = runTest(UnconfinedTestDispatcher()) { - every { interactor.getCourseStructureFromCache() } returns courseStructure - every { networkConnection.isOnline() } returns false - coEvery { downloadDao.readAllData() } returns flow { - emit( - listOf( - DownloadModelEntity.createFrom( - downloadModel + fun `getCourseDataInternal success without internet connection`() = + runTest(UnconfinedTestDispatcher()) { + coEvery { interactor.getCourseStructureFlow(any(), any()) } returns flowOf( + courseStructure + ) + every { networkConnection.isOnline() } returns false + coEvery { downloadDao.getAllDataFlow() } returns flow { + emit( + listOf( + DownloadModelEntity.createFrom( + downloadModel + ) ) ) + } + coEvery { interactor.getCourseStatusFlow(any()) } returns flowOf(CourseComponentStatus("id")) + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false + + val viewModel = CourseContentAllViewModel( + "", + "", + config, + interactor, + resourceManager, + notifier, + networkConnection, + preferencesManager, + analytics, + downloadDialogManager, + fileUtil, + courseRouter, + coreAnalytics, + downloadDao, + workerController, + downloadHelper, ) - } - coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") - every { config.isCourseNestedListEnabled() } returns false - - val viewModel = CourseOutlineViewModel( - "", - "", - config, - interactor, - resourceManager, - notifier, - networkConnection, - preferencesManager, - analytics, - coreAnalytics, - downloadDao, - workerController - ) - val message = async { - withTimeoutOrNull(5000) { - viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } } - } - viewModel.getCourseData() - advanceUntilIdle() + viewModel.getCourseData() + advanceUntilIdle() - verify(exactly = 1) { interactor.getCourseStructureFromCache() } - coVerify(exactly = 0) { interactor.getCourseStatus(any()) } + coVerify(exactly = 2) { interactor.getCourseStructureFlow(any(), any()) } + coVerify(exactly = 2) { interactor.getCourseStatusFlow(any()) } - assert(message.await() == null) - assert(viewModel.uiState.value is CourseOutlineUIState.CourseData) - } + assert(message.await() == null) + assert(viewModel.uiState.value is CourseContentAllUIState.CourseData) + } @Test - fun `updateCourseData success with internet connection`() = runTest(UnconfinedTestDispatcher()) { - every { interactor.getCourseStructureFromCache() } returns courseStructure - every { networkConnection.isOnline() } returns true - coEvery { downloadDao.readAllData() } returns flow { - emit( - listOf( - DownloadModelEntity.createFrom( - downloadModel + fun `updateCourseData success with internet connection`() = + runTest(UnconfinedTestDispatcher()) { + coEvery { interactor.getCourseStructureFlow(any(), any()) } returns flowOf( + courseStructure + ) + every { networkConnection.isOnline() } returns true + coEvery { downloadDao.getAllDataFlow() } returns flow { + emit( + listOf( + DownloadModelEntity.createFrom( + downloadModel + ) ) ) + } + coEvery { interactor.getCourseStatusFlow(any()) } returns flowOf(CourseComponentStatus("id")) + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false + + val viewModel = CourseContentAllViewModel( + "", + "", + config, + interactor, + resourceManager, + notifier, + networkConnection, + preferencesManager, + analytics, + downloadDialogManager, + fileUtil, + courseRouter, + coreAnalytics, + downloadDao, + workerController, + downloadHelper, ) - } - coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") - every { config.isCourseNestedListEnabled() } returns false - val viewModel = CourseOutlineViewModel( - "", - "", - config, - interactor, - resourceManager, - notifier, - networkConnection, - preferencesManager, - analytics, - coreAnalytics, - downloadDao, - workerController - ) - - val message = async { - withTimeoutOrNull(5000) { - viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } } - } - viewModel.getCourseData() - viewModel.updateCourseData() - advanceUntilIdle() + viewModel.getCourseData() + advanceUntilIdle() - coVerify(exactly = 2) { interactor.getCourseStructureFromCache() } - coVerify(exactly = 2) { interactor.getCourseStatus(any()) } + coVerify(exactly = 2) { interactor.getCourseStructureFlow(any(), any()) } + coVerify(exactly = 2) { interactor.getCourseStatusFlow(any()) } - assert(message.await() == null) - assert(viewModel.uiState.value is CourseOutlineUIState.CourseData) - } + assert(message.await() == null) + assert(viewModel.uiState.value is CourseContentAllUIState.CourseData) + } @Test fun `CourseStructureUpdated notifier test`() = runTest(UnconfinedTestDispatcher()) { - coEvery { downloadDao.readAllData() } returns flow { emit(emptyList()) } - val viewModel = CourseOutlineViewModel( + coEvery { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } + coEvery { interactor.getCourseStructureFlow(any(), any()) } returns flowOf(courseStructure) + coEvery { notifier.notifier } returns flow { emit(CourseStructureUpdated("")) } + every { networkConnection.isOnline() } returns true + coEvery { interactor.getCourseStatusFlow(any()) } returns flowOf(CourseComponentStatus("id")) + + val viewModel = CourseContentAllViewModel( "", "", config, @@ -437,14 +521,14 @@ 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 - every { networkConnection.isOnline() } returns true - coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") val mockLifeCycleOwner: LifecycleOwner = mockk() val lifecycleRegistry = LifecycleRegistry(mockLifeCycleOwner) @@ -454,14 +538,15 @@ class CourseOutlineViewModelTest { viewModel.getCourseData() advanceUntilIdle() - coVerify(exactly = 1) { interactor.getCourseStructureFromCache() } - coVerify(exactly = 1) { interactor.getCourseStatus(any()) } + coVerify(exactly = 3) { interactor.getCourseStructureFlow(any(), any()) } + coVerify(exactly = 3) { interactor.getCourseStatusFlow(any()) } } @Test fun `saveDownloadModels test`() = runTest(UnconfinedTestDispatcher()) { every { preferencesManager.videoSettings.wifiDownloadOnly } returns false - every { interactor.getCourseStructureFromCache() } returns courseStructure + coEvery { interactor.getCourseStructure(any()) } returns courseStructure + coEvery { interactor.getCourseStructureFlow(any(), any()) } returns flowOf(courseStructure) every { networkConnection.isWifiConnected() } returns true every { networkConnection.isOnline() } returns true every { @@ -472,10 +557,11 @@ 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 { interactor.getCourseStatusFlow(any()) } returns flowOf(CourseComponentStatus("id")) + coEvery { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false - val viewModel = CourseOutlineViewModel( + val viewModel = CourseContentAllViewModel( "", "", config, @@ -485,16 +571,20 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + downloadDialogManager, + fileUtil, + courseRouter, coreAnalytics, downloadDao, - workerController + workerController, + downloadHelper, ) val message = async { withTimeoutOrNull(5000) { viewModel.uiMessage.first() as? UIMessage.SnackBarMessage } } - viewModel.saveDownloadModels("", "") + viewModel.saveDownloadModels("", "", "") advanceUntilIdle() verify(exactly = 1) { coreAnalytics.logEvent( @@ -507,75 +597,48 @@ class CourseOutlineViewModelTest { } @Test - fun `saveDownloadModels only wifi download, with connection`() = runTest(UnconfinedTestDispatcher()) { - every { interactor.getCourseStructureFromCache() } returns courseStructure - coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") - every { preferencesManager.videoSettings.wifiDownloadOnly } returns true - every { networkConnection.isWifiConnected() } returns true - every { networkConnection.isOnline() } returns true - coEvery { workerController.saveModels(any()) } returns Unit - coEvery { downloadDao.readAllData() } returns flow { emit(emptyList()) } - every { config.isCourseNestedListEnabled() } returns false - every { coreAnalytics.logEvent(any(), any()) } returns Unit - - val viewModel = CourseOutlineViewModel( - "", - "", - config, - interactor, - resourceManager, - notifier, - networkConnection, - preferencesManager, - analytics, - coreAnalytics, - downloadDao, - workerController - ) - val message = async { - withTimeoutOrNull(5000) { - viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + fun `saveDownloadModels only wifi download, with connection`() = + runTest(UnconfinedTestDispatcher()) { + coEvery { interactor.getCourseStructure(any()) } returns courseStructure + coEvery { interactor.getCourseStructureFlow(any(), any()) } returns flowOf( + courseStructure + ) + coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") + coEvery { interactor.getCourseStatusFlow(any()) } returns flowOf(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.getAllDataFlow() } returns flow { emit(emptyList()) } + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false + every { coreAnalytics.logEvent(any(), any()) } returns Unit + + val viewModel = CourseContentAllViewModel( + "", + "", + config, + interactor, + resourceManager, + notifier, + networkConnection, + preferencesManager, + analytics, + downloadDialogManager, + fileUtil, + courseRouter, + coreAnalytics, + downloadDao, + workerController, + downloadHelper, + ) + 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 + viewModel.saveDownloadModels("", "", "") + advanceUntilIdle() - val viewModel = CourseOutlineViewModel( - "", - "", - config, - interactor, - resourceManager, - notifier, - networkConnection, - preferencesManager, - analytics, - coreAnalytics, - downloadDao, - workerController - ) - val message = async { - withTimeoutOrNull(5000) { - viewModel.uiMessage.first() as? UIMessage.SnackBarMessage - } + assert(message.await()?.message.isNullOrEmpty()) } - 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..685311e9e 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 @@ -38,12 +38,13 @@ 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.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.course.presentation.unit.container.CourseViewMode +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException import java.util.Date @@ -69,6 +70,12 @@ class CourseSectionViewModelTest { private val somethingWrong = "Something went wrong" private val cantDownload = "You can download content only from Wi-fi" + private val assignmentProgress = AssignmentProgress( + assignmentType = "Homework", + numPointsEarned = 1f, + numPointsPossible = 3f, + shortLabel = "HW1", + ) private val blocks = listOf( Block( @@ -85,7 +92,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 +111,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 +130,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 +158,14 @@ class CourseSectionViewModelTest { ), media = null, certificate = null, - isSelfPaced = false + isSelfPaced = false, + progress = null ) private val downloadModel = DownloadModel( "id", "title", + "", 0, "", "url", @@ -161,7 +179,9 @@ class CourseSectionViewModelTest { 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 { resourceManager.getString(org.openedx.course.R.string.course_can_download_only_with_wifi) } returns cantDownload + every { + resourceManager.getString(org.openedx.course.R.string.course_can_download_only_with_wifi) + } returns cantDownload } @After @@ -171,28 +191,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 +216,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 +241,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 +270,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 +292,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 +324,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 +341,5 @@ 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..fb8ac2920 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.helper.VideoPreviewHelper +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,15 @@ class CourseUnitContainerViewModelTest { private val interactor = mockk() private val notifier = mockk() private val analytics = mockk() + private val networkConnection = mockk() + private val videoPreviewHelper = mockk() + + private val assignmentProgress = AssignmentProgress( + assignmentType = "Homework", + numPointsEarned = 1f, + numPointsPossible = 3f, + shortLabel = "HW1", + ) private val blocks = listOf( Block( @@ -54,7 +70,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 +89,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 +108,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 +127,10 @@ class CourseUnitContainerViewModelTest { blockCounts = BlockCounts(0), descendants = emptyList(), descendantsType = BlockType.HTML, - completion = 0.0 + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date(), + offlineDownload = null, ) ) @@ -128,12 +156,14 @@ class CourseUnitContainerViewModelTest { ), media = null, certificate = null, - isSelfPaced = false + isSelfPaced = false, + progress = null ) @Before fun setUp() { Dispatchers.setMain(dispatcher) + every { videoPreviewHelper.getVideoPreviews(any(), any()) } returns emptyMap() } @After @@ -144,162 +174,253 @@ class CourseUnitContainerViewModelTest { @Test fun `getBlocks no internet connection exception`() = runTest { every { notifier.notifier } returns MutableSharedFlow() - val viewModel = - CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics) + val viewModel = CourseUnitContainerViewModel( + "", + "", + CourseViewMode.FULL, + config, + interactor, + notifier, + analytics, + networkConnection, + videoPreviewHelper + ) - 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) + viewModel.loadBlocks() 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( + "", + "", + CourseViewMode.FULL, + config, + interactor, + notifier, + analytics, + networkConnection, + videoPreviewHelper + ) - 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) + viewModel.loadBlocks() 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( + "", + "", + CourseViewMode.VIDEOS, + config, + interactor, + notifier, + analytics, + networkConnection, + videoPreviewHelper + ) - 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) + viewModel.loadBlocks() 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( + "", + "", + CourseViewMode.VIDEOS, + config, + interactor, + notifier, + analytics, + networkConnection, + videoPreviewHelper + ) + coEvery { interactor.getCourseStructure(any()) } returns courseStructure + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - viewModel.loadBlocks(CourseViewMode.VIDEOS) - viewModel.setupCurrentIndex("id") + viewModel.loadBlocks("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( + "", + "", + CourseViewMode.VIDEOS, + config, + interactor, + notifier, + analytics, + networkConnection, + videoPreviewHelper + ) + coEvery { interactor.getCourseStructure(any()) } returns courseStructure + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - viewModel.loadBlocks(CourseViewMode.VIDEOS) - viewModel.setupCurrentIndex("id") + viewModel.loadBlocks("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( + "", + "", + CourseViewMode.VIDEOS, + config, + interactor, + notifier, + analytics, + networkConnection, + videoPreviewHelper + ) + coEvery { interactor.getCourseStructure(any()) } returns courseStructure + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - viewModel.loadBlocks(CourseViewMode.VIDEOS) - viewModel.setupCurrentIndex("id") + viewModel.loadBlocks("id3") 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", + CourseViewMode.VIDEOS, + config, + interactor, + notifier, + analytics, + networkConnection, + videoPreviewHelper + ) + coEvery { interactor.getCourseStructure(any()) } returns courseStructure + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - viewModel.loadBlocks(CourseViewMode.VIDEOS) - viewModel.setupCurrentIndex("id1") + viewModel.loadBlocks("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( + "", + "", + CourseViewMode.VIDEOS, + config, + interactor, + notifier, + analytics, + networkConnection, + videoPreviewHelper + ) + coEvery { interactor.getCourseStructure(any()) } returns courseStructure + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - viewModel.loadBlocks(CourseViewMode.VIDEOS) - viewModel.setupCurrentIndex("id3") + viewModel.loadBlocks("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", + CourseViewMode.VIDEOS, + config, + interactor, + notifier, + analytics, + networkConnection, + videoPreviewHelper + ) + coEvery { interactor.getCourseStructure("") } returns courseStructure + coEvery { interactor.getCourseStructureForVideos("") } returns courseStructure - viewModel.loadBlocks(CourseViewMode.VIDEOS) - viewModel.setupCurrentIndex("id") + viewModel.loadBlocks("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( + "", + "", + CourseViewMode.VIDEOS, + config, + interactor, + notifier, + analytics, + networkConnection, + videoPreviewHelper + ) + coEvery { interactor.getCourseStructure(any()) } returns courseStructure + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - viewModel.loadBlocks(CourseViewMode.VIDEOS) - viewModel.setupCurrentIndex("id3") + viewModel.loadBlocks("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/unit/video/VideoUnitViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/unit/video/VideoUnitViewModelTest.kt index 4270dba82..1d8524a7b 100644 --- a/course/src/test/java/org/openedx/course/presentation/unit/video/VideoUnitViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/unit/video/VideoUnitViewModelTest.kt @@ -44,7 +44,6 @@ class VideoUnitViewModelTest { private val transcriptManager = mockk() private val courseAnalytics = mockk() - @Before fun setUp() { Dispatchers.setMain(dispatcher) @@ -58,6 +57,8 @@ class VideoUnitViewModelTest { @Test fun `markBlockCompleted exception`() = runTest { val viewModel = VideoUnitViewModel( + "", + "", "", courseRepository, notifier, @@ -97,6 +98,8 @@ class VideoUnitViewModelTest { @Test fun `markBlockCompleted success`() = runTest { val viewModel = VideoUnitViewModel( + "", + "", "", courseRepository, notifier, @@ -136,6 +139,8 @@ class VideoUnitViewModelTest { @Test fun `CourseVideoPositionChanged notifier test`() = runTest { val viewModel = VideoUnitViewModel( + "", + "", "", courseRepository, notifier, @@ -148,10 +153,12 @@ class VideoUnitViewModelTest { CourseVideoPositionChanged( "", 10, - false + 10000L, + false, ) ) } + coEvery { courseRepository.saveVideoProgress(any(), any(), any(), any()) } returns Unit val mockLifeCycleOwner: LifecycleOwner = mockk() val lifecycleRegistry = LifecycleRegistry(mockLifeCycleOwner) lifecycleRegistry.addObserver(viewModel) @@ -162,5 +169,4 @@ class VideoUnitViewModelTest { assert(viewModel.currentVideoTime.value == 10L) assert(viewModel.isUpdated.value == true) } - -} \ No newline at end of file +} diff --git a/course/src/test/java/org/openedx/course/presentation/unit/video/VideoViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/unit/video/VideoViewModelTest.kt index 3f476fe29..ae954c5f7 100644 --- a/course/src/test/java/org/openedx/course/presentation/unit/video/VideoViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/unit/video/VideoViewModelTest.kt @@ -52,11 +52,11 @@ class VideoViewModelTest { fun `sendTime test`() = runTest { val viewModel = VideoViewModel("", courseRepository, notifier, preferenceManager, courseAnalytics) - coEvery { notifier.send(CourseVideoPositionChanged("", 0, false)) } returns Unit + coEvery { notifier.send(CourseVideoPositionChanged("", 0, 0L, false)) } returns Unit viewModel.sendTime() advanceUntilIdle() - coVerify(exactly = 1) { notifier.send(CourseVideoPositionChanged("", 0, false)) } + coVerify(exactly = 1) { notifier.send(CourseVideoPositionChanged("", 0, 0L, false)) } } @Test @@ -90,7 +90,6 @@ class VideoViewModelTest { any() ) } - } @Test 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..e8a16c151 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 @@ -16,7 +16,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain @@ -29,9 +28,11 @@ 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.model.room.VideoProgressEntity import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.helper.VideoPreviewHelper +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 +44,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.system.ResourceManager +import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager import org.openedx.core.system.connection.NetworkConnection -import org.openedx.core.system.notifier.CourseDataReady import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseStructureUpdated -import org.openedx.core.system.notifier.VideoNotifier 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.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager +import org.openedx.foundation.utils.FileUtil import java.util.Date @OptIn(ExperimentalCoroutinesApi::class) @@ -60,22 +64,33 @@ class CourseVideoViewModelTest { @get:Rule val testInstantTaskExecutorRule: TestRule = InstantTaskExecutorRule() - private val dispatcher = StandardTestDispatcher() + private val dispatcher = UnconfinedTestDispatcher() private val config = mockk() private val resourceManager = mockk() private val interactor = mockk() private val courseNotifier = spyk() - private val videoNotifier = spyk() - private val analytics = mockk() private val coreAnalytics = mockk() + private val courseAnalytics = mockk() private val preferencesManager = mockk() 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 videoPreviewHelper = mockk() private val cantDownload = "You can download content only from Wi-fi" + private val assignmentProgress = AssignmentProgress( + assignmentType = "Homework", + numPointsEarned = 1f, + numPointsPossible = 3f, + shortLabel = "HW1", + ) + 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,29 @@ 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() + every { preferencesManager.isRelativeDatesEnabled } returns true + every { + downloadDialogManager.showPopup( + any(), + any(), + any(), + any(), + any(), + any(), + any(), + any(), + any(), + ) + } returns Unit + + every { videoPreviewHelper.getVideoPreviewWithId(any(), any(), any()) } returns Pair( + "test", + null + ) } @After @@ -180,13 +224,14 @@ 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()) } + fun `getVideos empty list`() = runTest(UnconfinedTestDispatcher()) { + 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( - "", "", config, interactor, @@ -194,30 +239,37 @@ class CourseVideoViewModelTest { networkConnection, preferencesManager, courseNotifier, - videoNotifier, - analytics, + downloadDialogManager, + fileUtil, + courseRouter, + courseAnalytics, + videoPreviewHelper, 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) + assert(viewModel.uiState.value is CourseVideoUIState.Empty) } @Test - fun `getVideos success`() = runTest { - every { config.isCourseNestedListEnabled() } returns false - every { interactor.getCourseStructureForVideos() } returns courseStructure - every { downloadDao.readAllData() } returns flow { emit(emptyList()) } + fun `getVideos success`() = runTest(UnconfinedTestDispatcher()) { + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure + every { downloadDao.getAllDataFlow() } returns flow { + repeat(5) { + delay(10000) + emit(emptyList()) + } + } every { preferencesManager.videoSettings } returns VideoSettings.default - val viewModel = CourseVideoViewModel( - "", "", config, interactor, @@ -225,39 +277,43 @@ class CourseVideoViewModelTest { networkConnection, preferencesManager, courseNotifier, - videoNotifier, - analytics, + downloadDialogManager, + fileUtil, + courseRouter, + courseAnalytics, + videoPreviewHelper, coreAnalytics, downloadDao, - workerController + workerController, + downloadHelper, ) + val mockLifeCycleOwner: LifecycleOwner = mockk() + val lifecycleRegistry = LifecycleRegistry(mockLifeCycleOwner) + lifecycleRegistry.addObserver(viewModel) + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START) - viewModel.getVideos() advanceUntilIdle() - coVerify(exactly = 2) { interactor.getCourseStructureForVideos() } + coVerify(exactly = 1) { interactor.getCourseStructureForVideos(any()) } - assert(viewModel.uiState.value is CourseVideosUIState.CourseData) + assert(viewModel.uiState.value is CourseVideoUIState.CourseData) } @Test - fun `updateVideos success`() = runTest { - every { config.isCourseNestedListEnabled() } returns false - every { interactor.getCourseStructureForVideos() } returns courseStructure + fun `updateVideos success`() = runTest(UnconfinedTestDispatcher()) { + 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 { - repeat(5) { - delay(10000) - emit(emptyList()) - } + every { downloadDao.getAllDataFlow() } returns flow { + emit(emptyList()) } every { preferencesManager.videoSettings } returns VideoSettings.default + every { networkConnection.isOnline() } returns true + coEvery { interactor.getVideoProgress(any()) } returns VideoProgressEntity("", "", 0L, 0L) val viewModel = CourseVideoViewModel( - "", "", config, interactor, @@ -265,11 +321,15 @@ class CourseVideoViewModelTest { networkConnection, preferencesManager, courseNotifier, - videoNotifier, - analytics, + downloadDialogManager, + fileUtil, + courseRouter, + courseAnalytics, + videoPreviewHelper, coreAnalytics, downloadDao, - workerController + workerController, + downloadHelper, ) val mockLifeCycleOwner: LifecycleOwner = mockk() @@ -279,26 +339,27 @@ class CourseVideoViewModelTest { advanceUntilIdle() - coVerify(exactly = 2) { interactor.getCourseStructureForVideos() } + coVerify(exactly = 2) { interactor.getCourseStructureForVideos(any()) } - assert(viewModel.uiState.value is CourseVideosUIState.CourseData) + assert(viewModel.uiState.value is CourseVideoUIState.CourseData) } @Test - fun `setIsUpdating success`() = runTest { - every { config.isCourseNestedListEnabled() } returns false + fun `setIsUpdating success`() = runTest(UnconfinedTestDispatcher()) { + 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 + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure + every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } val viewModel = CourseVideoViewModel( - "", "", config, interactor, @@ -306,14 +367,18 @@ class CourseVideoViewModelTest { networkConnection, preferencesManager, courseNotifier, - videoNotifier, - analytics, + downloadDialogManager, + fileUtil, + courseRouter, + courseAnalytics, + videoPreviewHelper, 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 @@ -323,89 +388,99 @@ class CourseVideoViewModelTest { viewModel.uiMessage.first() as? UIMessage.SnackBarMessage } } - viewModel.saveDownloadModels("", "") + viewModel.saveDownloadModels("", "", "") advanceUntilIdle() assert(message.await()?.message.isNullOrEmpty()) } @Test - fun `saveDownloadModels only wifi download, with connection`() = runTest(UnconfinedTestDispatcher()) { - every { config.isCourseNestedListEnabled() } returns false - every { preferencesManager.videoSettings } returns VideoSettings.default - val viewModel = CourseVideoViewModel( - "", - "", - config, - interactor, - resourceManager, - networkConnection, - preferencesManager, - courseNotifier, - videoNotifier, - analytics, - coreAnalytics, - downloadDao, - workerController - ) - coEvery { interactor.getCourseStructureForVideos() } returns courseStructure - coEvery { downloadDao.readAllData() } 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 { - emit(listOf(DownloadModelEntity.createFrom(downloadModel))) - } - every { coreAnalytics.logEvent(any(), any()) } returns Unit - val message = async { - withTimeoutOrNull(5000) { - viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + fun `saveDownloadModels only wifi download, with connection`() = + runTest(UnconfinedTestDispatcher()) { + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false + every { preferencesManager.videoSettings } returns VideoSettings.default + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure + every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } + val viewModel = CourseVideoViewModel( + "", + config, + interactor, + resourceManager, + networkConnection, + preferencesManager, + courseNotifier, + downloadDialogManager, + fileUtil, + courseRouter, + courseAnalytics, + videoPreviewHelper, + coreAnalytics, + downloadDao, + workerController, + downloadHelper, + ) + 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.getAllDataFlow() } returns flow { + emit(listOf(DownloadModelEntity.createFrom(downloadModel))) + } + every { coreAnalytics.logEvent(any(), any()) } returns Unit + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } } - } - viewModel.saveDownloadModels("", "") - advanceUntilIdle() + viewModel.saveDownloadModels("", "", "") + advanceUntilIdle() - assert(message.await()?.message.isNullOrEmpty()) - } + assert(message.await()?.message.isNullOrEmpty()) + } @Test - fun `saveDownloadModels only wifi download, without connection`() = runTest(UnconfinedTestDispatcher()) { - every { config.isCourseNestedListEnabled() } returns false - every { preferencesManager.videoSettings } returns VideoSettings.default - val viewModel = CourseVideoViewModel( - "", - "", - config, - interactor, - resourceManager, - networkConnection, - preferencesManager, - courseNotifier, - videoNotifier, - analytics, - coreAnalytics, - downloadDao, - workerController - ) - 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 { workerController.saveModels(any()) } returns Unit - val message = async { - withTimeoutOrNull(5000) { - viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + fun `saveDownloadModels only wifi download, without connection`() = + runTest(UnconfinedTestDispatcher()) { + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false + every { preferencesManager.videoSettings } returns VideoSettings.default + every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure + val viewModel = CourseVideoViewModel( + "", + config, + interactor, + resourceManager, + networkConnection, + preferencesManager, + courseNotifier, + downloadDialogManager, + fileUtil, + courseRouter, + courseAnalytics, + videoPreviewHelper, + coreAnalytics, + downloadDao, + workerController, + downloadHelper, + ) + every { preferencesManager.videoSettings.wifiDownloadOnly } returns true + every { networkConnection.isWifiConnected() } returns false + every { networkConnection.isOnline() } returns false + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure + coEvery { downloadDao.getAllDataFlow() } returns flow { emit(listOf(downloadModelEntity)) } + coEvery { workerController.saveModels(any()) } returns Unit + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } } - } - viewModel.saveDownloadModels("", "") - - advanceUntilIdle() - - assert(message.await()?.message.isNullOrEmpty()) - } + viewModel.saveDownloadModels("", "", "") + advanceUntilIdle() + assert(message.await()?.message.isNullOrEmpty()) + } } diff --git a/dashboard/build.gradle b/dashboard/build.gradle index c0c3192d0..f8272cc82 100644 --- a/dashboard/build.gradle +++ b/dashboard/build.gradle @@ -1,43 +1,43 @@ plugins { id 'com.android.library' id 'org.jetbrains.kotlin.android' + id "org.jetbrains.kotlin.plugin.compose" } android { - compileSdk 34 + namespace 'org.openedx.dashboard' + compileSdkVersion compile_sdk_version defaultConfig { - minSdk 24 - targetSdk 34 + minSdk min_sdk_version + targetSdk target_sdk_version testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles "consumer-rules.pro" } - namespace 'org.openedx.dashboard' buildTypes { release { - minifyEnabled true + minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } - compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 + sourceCompatibility java_version + targetCompatibility java_version } - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17 + kotlin { + compilerOptions { + jvmTarget = jvm_target_version + freeCompilerArgs = ['-XXLanguage:+PropertyParamAnnotationDefaultTargetMode'] + } } buildFeatures { viewBinding true compose true } - composeOptions { - kotlinCompilerExtensionVersion = "$compose_compiler_version" - } flavorDimensions += "env" productFlavors { @@ -56,13 +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.compose.ui:ui-test-junit4:$compose_version" - debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version" 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" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlinx_coroutines_test_version" + androidTestImplementation "androidx.test.ext:junit:$test_ext_version" + androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_version" } \ No newline at end of file diff --git a/dashboard/proguard-rules.pro b/dashboard/proguard-rules.pro index 481bb4348..4d3a6c1df 100644 --- a/dashboard/proguard-rules.pro +++ b/dashboard/proguard-rules.pro @@ -1,21 +1,9 @@ -# 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 +# 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 -# 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 +-dontwarn java.lang.invoke.StringConcatFactory 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..9e8d35305 --- /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(alpha = 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..d70f01832 --- /dev/null +++ b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesFragment.kt @@ -0,0 +1,33 @@ +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 + ) + } + } + } + + companion object { + const val LOAD_MORE_THRESHOLD = 4 + const val TABLET_GRID_COLUMNS = 3 + const val MOBILE_GRID_COLUMNS = 2 + } +} 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..c0967b5d0 --- /dev/null +++ b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt @@ -0,0 +1,638 @@ +package org.openedx.courses.presentation + +import android.content.res.Configuration +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.courses.presentation.AllEnrolledCoursesFragment.Companion.LOAD_MORE_THRESHOLD +import org.openedx.courses.presentation.AllEnrolledCoursesFragment.Companion.MOBILE_GRID_COLUMNS +import org.openedx.courses.presentation.AllEnrolledCoursesFragment.Companion.TABLET_GRID_COLUMNS +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) + } + } + } + ) +} + +@Suppress("MaximumLineLength") +@OptIn(ExperimentalMaterialApi::class, ExperimentalComposeUiApi::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) TABLET_GRID_COLUMNS else MOBILE_GRID_COLUMNS + 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, 560.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(), + contentAlignment = Alignment.TopCenter + ) { + LazyVerticalGrid( + modifier = Modifier + .fillMaxHeight(), + state = scrollState, + columns = GridCells.Fixed(columns), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = contentPaddings, + 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, + LOAD_MORE_THRESHOLD + ) + ) { + 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) + ) + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(8.dp), + progress = course.progress.value, + 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..80c0d5fce --- /dev/null +++ b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesViewModel.kt @@ -0,0 +1,209 @@ +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() + loadInitialCourses() + } + + private fun loadInitialCourses() { + viewModelScope.launch { + _uiState.update { it.copy(showProgress = true) } + val cachedList = interactor.getEnrolledCoursesFromCache() + if (cachedList.isNotEmpty()) { + _uiState.update { it.copy(courses = cachedList.toList(), showProgress = false) } + } + getCourses(showLoadingProgress = false) + } + } + + fun getCourses(courseStatusFilter: CourseStatusFilter? = null, showLoadingProgress: Boolean = true) { + if (showLoadingProgress) { + _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..5a00301e6 --- /dev/null +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryFragment.kt @@ -0,0 +1,29 @@ +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) + } + } + } + + companion object { + const val TABLET_COURSE_LIST_ITEM_COUNT = 7 + const val MOBILE_COURSE_LIST_ITEM_COUNT = 7 + } +} 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..c7108405a --- /dev/null +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt @@ -0,0 +1,1006 @@ +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.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.padding +import androidx.compose.foundation.layout.size +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.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.KeyboardArrowRight +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.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.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.layout.ContentScale +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.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 androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +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.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.utils.TimeUtils +import org.openedx.courses.presentation.DashboardGalleryFragment.Companion.MOBILE_COURSE_LIST_ITEM_COUNT +import org.openedx.courses.presentation.DashboardGalleryFragment.Companion.TABLET_COURSE_LIST_ITEM_COUNT +import org.openedx.dashboard.R +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 +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 windowSize = rememberWindowSize() + val scaffoldState = rememberScaffoldState() + val pullRefreshState = rememberPullRefreshState( + refreshing = updating, + onRefresh = { onAction(DashboardGalleryScreenAction.SwipeRefresh) } + ) + var isInternetConnectionShown by rememberSaveable { + mutableStateOf(false) + } + + val contentWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), + compact = Modifier.fillMaxWidth(), + ) + ) + } + + val contentPadding by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = PaddingValues(0.dp), + compact = PaddingValues(horizontal = 16.dp) + ) + ) + } + + Scaffold( + scaffoldState = scaffoldState, + modifier = Modifier.fillMaxSize(), + backgroundColor = MaterialTheme.appColors.background + ) { paddingValues -> + + HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) + + Surface( + modifier = Modifier + .fillMaxSize() + .displayCutoutForLandscape() + .padding(paddingValues), + color = MaterialTheme.appColors.background + ) { + 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 = contentWidth + .fillMaxHeight() + .padding(vertical = 12.dp) + .displayCutoutForLandscape() + .align(Alignment.TopCenter), + contentPadding = contentPadding, + 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, + contentPadding: PaddingValues, + apiHostUrl: String, + useRelativeDates: Boolean, + openCourse: (EnrolledCourse) -> Unit, + navigateToDates: (EnrolledCourse) -> Unit, + onViewAllClick: () -> Unit, + resumeBlockId: (enrolledCourse: EnrolledCourse, blockId: String) -> Unit, +) { + Column( + modifier = modifier + ) { + val primaryCourse = userCourses.primary + if (primaryCourse != null) { + PrimaryCourseCard( + modifier = Modifier.padding(contentPadding), + 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, + contentPadding = contentPadding, + onCourseClick = openCourse, + onViewAllClick = onViewAllClick + ) + } + } +} + +@Composable +private fun SecondaryCourses( + courses: List, + hasNextPage: Boolean, + apiHostUrl: String, + contentPadding: PaddingValues, + onCourseClick: (EnrolledCourse) -> Unit, + onViewAllClick: () -> Unit +) { + val windowSize = rememberWindowSize() + val itemsCount = if (windowSize.isTablet) { + TABLET_COURSE_LIST_ITEM_COUNT + } else { + MOBILE_COURSE_LIST_ITEM_COUNT + } + 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(contentPadding), + text = stringResource(R.string.dashboard_view_all_with_count, courses.size + 1), + textStyle = MaterialTheme.appTypography.titleSmall, + icon = Icons.AutoMirrored.Filled.KeyboardArrowRight, + color = MaterialTheme.appColors.textDark, + iconModifier = Modifier.size(22.dp), + onClick = onViewAllClick + ) + LazyHorizontalGrid( + modifier = Modifier + .fillMaxSize() + .height(height), + rows = GridCells.Fixed(rows), + contentPadding = contentPadding, + 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(22.dp), + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + tint = MaterialTheme.appColors.textDark, + contentDescription = null + ) + } +} + +@Composable +private fun PrimaryCourseCard( + modifier: Modifier = Modifier, + primaryCourse: EnrolledCourse, + apiHostUrl: String, + useRelativeDates: Boolean, + navigateToDates: (EnrolledCourse) -> Unit, + resumeBlockId: (enrolledCourse: EnrolledCourse, blockId: String) -> Unit, + openCourse: (EnrolledCourse) -> Unit, +) { + val orientation = LocalConfiguration.current.orientation + Card( + modifier = modifier + .fillMaxWidth() + .padding(2.dp), + backgroundColor = MaterialTheme.appColors.background, + shape = MaterialTheme.appShapes.courseImageShape, + elevation = 4.dp + ) { + when (orientation) { + Configuration.ORIENTATION_LANDSCAPE -> { + Row( + modifier = Modifier + .clickable { + openCourse(primaryCourse) + } + .height(IntrinsicSize.Min) + ) { + PrimaryCourseCaption( + modifier = Modifier.weight(1f), + primaryCourse = primaryCourse, + apiHostUrl = apiHostUrl, + imageHeight = null, + ) + PrimaryCourseButtons( + modifier = Modifier.weight(1f), + primaryCourse = primaryCourse, + navigateToDates = navigateToDates, + resumeBlockId = resumeBlockId, + openCourse = openCourse, + adjustHeight = true, + useRelativeDates = useRelativeDates, + ) + } + } + + else -> { + Column( + modifier = Modifier.clickable { + openCourse(primaryCourse) + } + ) { + PrimaryCourseCaption( + primaryCourse = primaryCourse, + apiHostUrl = apiHostUrl, + ) + PrimaryCourseButtons( + primaryCourse = primaryCourse, + navigateToDates = navigateToDates, + resumeBlockId = resumeBlockId, + openCourse = openCourse, + useRelativeDates = useRelativeDates, + ) + } + } + } + } +} + +@Composable +private fun PrimaryCourseButtons( + modifier: Modifier = Modifier, + primaryCourse: EnrolledCourse, + useRelativeDates: Boolean, + adjustHeight: Boolean = false, + navigateToDates: (EnrolledCourse) -> Unit, + resumeBlockId: (enrolledCourse: EnrolledCourse, blockId: String) -> Unit, + openCourse: (EnrolledCourse) -> Unit, +) { + val context = LocalContext.current + val pastAssignments = primaryCourse.courseAssignments?.pastAssignments + Column(modifier = modifier) { + var titleModifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + .padding(top = 8.dp, bottom = 16.dp) + if (adjustHeight) { + titleModifier = titleModifier.weight(1f) + } + PrimaryCourseTitle( + modifier = titleModifier, + primaryCourse = primaryCourse, + ) + Divider() + if (!pastAssignments.isNullOrEmpty()) { + val nearestAssignment = pastAssignments.maxBy { it.date } + val title = if (pastAssignments.size == 1) nearestAssignment.title else null + 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.core_ic_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 PrimaryCourseCaption( + modifier: Modifier = Modifier, + primaryCourse: EnrolledCourse, + imageHeight: Dp? = 140.dp, + apiHostUrl: String, +) { + val context = LocalContext.current + Column(modifier = modifier) { + val imageModifier = imageHeight?.let { + Modifier + .height(it) + .fillMaxWidth() + } ?: Modifier + .height(IntrinsicSize.Max) + .fillMaxWidth() + .weight(1f) + + 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 = imageModifier, + ) + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(8.dp), + progress = primaryCourse.progress.value, + color = MaterialTheme.appColors.primary, + backgroundColor = MaterialTheme.appColors.divider + ) + } +} + +@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(22.dp), + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + tint = MaterialTheme.appColors.primaryButtonText, + contentDescription = null + ) + } +} + +@Composable +private fun PrimaryCourseTitle( + modifier: Modifier = Modifier, + primaryCourse: EnrolledCourse +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.Center + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = primaryCourse.course.org, + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textFieldHint + ) + Text( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + text = primaryCourse.course.name, + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textDark, + overflow = TextOverflow.Ellipsis, + maxLines = 3 + ) + Text( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + 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..0ca8f4a6e --- /dev/null +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt @@ -0,0 +1,167 @@ +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 { + val cachedCourseEnrollments = fileUtil.getObjectFromFile() + if (cachedCourseEnrollments == null) { + if (networkConnection.isOnline()) { + _uiState.value = DashboardGalleryUIState.Loading + } else { + _uiState.value = DashboardGalleryUIState.Empty + } + } else { + _uiState.value = + DashboardGalleryUIState.Courses( + cachedCourseEnrollments.mapToDomain(), + corePreferences.isRelativeDatesEnabled + ) + } + 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 + ) + } + } + } 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/DashboardDao.kt b/dashboard/src/main/java/org/openedx/dashboard/data/DashboardDao.kt index d24afd05d..774ef7121 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/data/DashboardDao.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/data/DashboardDao.kt @@ -16,6 +16,5 @@ interface DashboardDao { suspend fun clearCachedData() @Query("SELECT * FROM course_enrolled_table") - suspend fun readAllData() : List - -} \ No newline at end of file + suspend fun readAllData(): List +} 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..6f52021b3 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 { @@ -21,8 +25,10 @@ class DashboardRepository( preferencesManager.appConfig = result.configs.mapToDomain() if (page == 1) dao.clearCachedData() - dao.insertEnrolledCourseEntity(*result.enrollments.results.map { it.mapToRoomEntity() } - .toTypedArray()) + dao.insertEnrolledCourseEntity( + *result.enrollments.results.map { it.mapToRoomEntity() } + .toTypedArray() + ) return result.enrollments.mapToDomain() } @@ -30,4 +36,34 @@ 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..066b8ff73 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( + "Learn:My Courses", + "edx.bi.app.main_dashboard.learn.my_course" + ), + MY_PROGRAMS( + "Learn:My Programs", + "edx.bi.app.main_dashboard.learn.my_programs" + ), +} + +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 83% 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..55f995a01 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 @@ -42,6 +44,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 @@ -70,37 +73,37 @@ import coil.compose.AsyncImage 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.presentation.global.app_upgrade.AppUpgradeRecommendedBox -import org.openedx.core.system.notifier.AppUpgradeEvent +import org.openedx.core.domain.model.Progress 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.dashboard.presentation.DashboardListFragment.Companion.LOAD_MORE_THRESHOLD +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?) { @@ -121,9 +124,8 @@ class DashboardFragment : Fragment() { val uiMessage by viewModel.uiMessage.observeAsState() val refreshing by viewModel.updating.observeAsState(false) val canLoadMore by viewModel.canLoadMore.observeAsState(false) - val appUpgradeEvent by viewModel.appUpgradeEvent.observeAsState() - MyCoursesScreen( + DashboardListView( windowSize = windowSize, viewModel.apiHostUrl, uiState!!, @@ -137,10 +139,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 = { @@ -149,24 +150,19 @@ class DashboardFragment : Fragment() { paginationCallback = { viewModel.fetchMore() }, - appUpgradeParameters = AppUpdateState.AppUpgradeParameters( - appUpgradeEvent = appUpgradeEvent, - onAppUpgradeRecommendedBoxClick = { - AppUpdateState.openPlayMarket(requireContext()) - }, - ), - onSettingsClick = { - router.navigateToSettings(requireActivity().supportFragmentManager) - } ) } } } + + companion object { + const val LOAD_MORE_THRESHOLD = 4 + } } @OptIn(ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class) @Composable -internal fun MyCoursesScreen( +internal fun DashboardListView( windowSize: WindowSize, apiHostUrl: String, state: DashboardUIState, @@ -177,9 +173,7 @@ internal fun MyCoursesScreen( onReloadClick: () -> Unit, onSwipeRefresh: () -> Unit, paginationCallback: () -> Unit, - onSettingsClick: () -> Unit, onItemClick: (EnrolledCourse) -> Unit, - appUpgradeParameters: AppUpdateState.AppUpgradeParameters, ) { val scaffoldState = rememberScaffoldState() val pullRefreshState = @@ -190,7 +184,7 @@ internal fun MyCoursesScreen( } val scrollState = rememberLazyListState() val firstVisibleIndex = remember { - mutableStateOf(scrollState.firstVisibleItemIndex) + mutableIntStateOf(scrollState.firstVisibleItemIndex) } Scaffold( @@ -241,16 +235,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, shape = MaterialTheme.appShapes.screenBackgroundShape @@ -263,8 +250,9 @@ internal fun MyCoursesScreen( when (state) { is DashboardUIState.Loading -> { Box( - Modifier - .fillMaxSize(), contentAlignment = Alignment.Center + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center ) { CircularProgressIndicator(color = MaterialTheme.appColors.primary) } @@ -282,18 +270,15 @@ internal fun MyCoursesScreen( state = scrollState, contentPadding = contentPaddings, content = { - item() { - Column { - Header() - Spacer(modifier = Modifier.height(16.dp)) - } - } items(state.courses) { course -> CourseItem( apiHostUrl, course, windowSize, - onClick = { onItemClick(it) }) + onClick = { + onItemClick(it) + } + ) Divider() } item { @@ -308,8 +293,13 @@ internal fun MyCoursesScreen( } } } - }) - if (scrollState.shouldLoadMore(firstVisibleIndex, 4)) { + } + ) + if (scrollState.shouldLoadMore( + firstVisibleIndex, + LOAD_MORE_THRESHOLD + ) + ) { paginationCallback() } } @@ -318,7 +308,7 @@ internal fun MyCoursesScreen( is DashboardUIState.Empty -> { Box( modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center + contentAlignment = Alignment.Center, ) { Column( Modifier @@ -326,7 +316,6 @@ internal fun MyCoursesScreen( .then(contentWidth) .then(emptyStatePaddings) ) { - Header() EmptyState() } } @@ -342,17 +331,6 @@ internal fun MyCoursesScreen( .fillMaxWidth() .align(Alignment.BottomCenter) ) { - when (appUpgradeParameters.appUpgradeEvent) { - is AppUpgradeEvent.UpgradeRecommendedEvent -> { - AppUpgradeRecommendedBox( - modifier = Modifier.fillMaxWidth(), - onClick = appUpgradeParameters.onAppUpgradeRecommendedBoxClick - ) - } - - else -> {} - } - if (!isInternetConnectionShown && !hasInternetConnection) { OfflineModeDialog( Modifier @@ -378,7 +356,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 +366,6 @@ private fun CourseItem( ) ) } - val imageUrl = apiHostUrl.dropLast(1) + enrolledCourse.course.courseImage val context = LocalContext.current Surface( modifier = Modifier @@ -407,7 +384,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 +465,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 +496,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,21 +510,22 @@ private fun EmptyState() { @Preview(uiMode = UI_MODE_NIGHT_YES) @Composable private fun CourseItemPreview() { - OpenEdXTheme() { + OpenEdXTheme { CourseItem( "http://localhost:8000", mockCourseEnrolled, WindowSize(WindowType.Compact, WindowType.Compact), - onClick = {}) + onClick = {} + ) } } @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,8 +546,6 @@ private fun MyCoursesScreenDay() { refreshing = false, canLoadMore = false, paginationCallback = {}, - onSettingsClick = {}, - appUpgradeParameters = AppUpdateState.AppUpgradeParameters() ) } } @@ -583,9 +553,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 +576,41 @@ 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 = {}, + ) + } +} + +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 87% 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..58f83b8f2 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,26 @@ 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.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 ) : BaseViewModel() { private val coursesList = mutableListOf() @@ -56,10 +52,6 @@ class DashboardViewModel( val canLoadMore: LiveData get() = _canLoadMore - private val _appUpgradeEvent = MutableLiveData() - val appUpgradeEvent: LiveData - get() = _appUpgradeEvent - override fun onCreate(owner: LifecycleOwner) { super.onCreate(owner) viewModelScope.launch { @@ -73,7 +65,6 @@ class DashboardViewModel( init { getCourses() - collectAppUpgradeEvent() } fun getCourses() { @@ -83,6 +74,9 @@ class DashboardViewModel( } fun updateCourses() { + if (isLoading) { + return + } viewModelScope.launch { try { _updating.value = true @@ -166,16 +160,7 @@ class DashboardViewModel( } } - private fun collectAppUpgradeEvent() { - viewModelScope.launch { - appUpgradeNotifier.notifier.collect { event -> - _appUpgradeEvent.value = event - } - } - } - fun dashboardCourseClickedEvent(courseId: String, courseName: String) { analytics.dashboardCourseClickedEvent(courseId, courseName) } - } 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..b7fe74fd0 --- /dev/null +++ b/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt @@ -0,0 +1,259 @@ +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.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.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandMore +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.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.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.koin.core.parameter.parametersOf +import org.openedx.core.adapter.NavigationFragmentAdapter +import org.openedx.core.presentation.global.viewBinding +import org.openedx.core.ui.MainToolbar +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 + +class LearnFragment : Fragment(R.layout.fragment_learn) { + + private val binding by viewBinding(FragmentLearnBinding::bind) + private val viewModel by viewModel { + parametersOf(requireArguments().getString(ARG_OPEN_TAB, LearnTab.COURSES.name)) + } + private lateinit var adapter: NavigationFragmentAdapter + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initViewPager() + binding.header.setContent { + OpenEdXTheme { + val uiState by viewModel.uiState.collectAsState() + binding.viewPager.setCurrentItem( + when (uiState.learnType) { + LearnType.COURSES -> 0 + LearnType.PROGRAMS -> 1 + }, + false + ) + Header( + fragmentManager = requireParentFragment().parentFragmentManager, + selectedLearnType = uiState.learnType, + onUpdateLearnType = { learnType -> + viewModel.updateLearnType(learnType) + }, + ) + } + } + } + + 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) + } + + 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, + selectedLearnType: LearnType, + onUpdateLearnType: (LearnType) -> Unit +) { + 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 + ) { + MainToolbar( + label = stringResource(id = R.string.dashboard_learn), + onSettingsClick = { + viewModel.onSettingsClick(fragmentManager) + } + ) + if (viewModel.isProgramTypeWebView) { + LearnDropdownMenu( + modifier = Modifier + .align(Alignment.Start) + .padding(horizontal = 16.dp), + selectedLearnType = selectedLearnType, + onUpdateLearnType = onUpdateLearnType + ) + } + } +} + +@Composable +private fun LearnDropdownMenu( + modifier: Modifier = Modifier, + selectedLearnType: LearnType, + onUpdateLearnType: (LearnType) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + val iconRotation by animateFloatAsState( + targetValue = if (expanded) 180f else 0f, + label = "" + ) + + Column( + modifier = modifier + ) { + Row( + modifier = Modifier + .clickable { + expanded = true + }, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(id = selectedLearnType.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 (selectedLearnType == learnType) { + background = MaterialTheme.appColors.primary + textColor = MaterialTheme.appColors.primaryButtonText + } else { + background = Color.Transparent + textColor = MaterialTheme.appColors.textDark + } + DropdownMenuItem( + modifier = Modifier + .background(background), + onClick = { + onUpdateLearnType(learnType) + expanded = false + } + ) { + Text( + text = stringResource(id = learnType.title), + style = MaterialTheme.appTypography.titleSmall, + color = textColor + ) + } + } + } + } + } +} + +@Preview +@Composable +private fun HeaderPreview() { + OpenEdXTheme { + MainToolbar( + label = stringResource(id = R.string.dashboard_learn), + onSettingsClick = {} + ) + } +} + +@Preview +@Composable +private fun LearnDropdownMenuPreview() { + OpenEdXTheme { + LearnDropdownMenu( + selectedLearnType = LearnType.COURSES, + onUpdateLearnType = {} + ) + } +} 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/LearnUIState.kt b/dashboard/src/main/java/org/openedx/learn/presentation/LearnUIState.kt new file mode 100644 index 000000000..934caa374 --- /dev/null +++ b/dashboard/src/main/java/org/openedx/learn/presentation/LearnUIState.kt @@ -0,0 +1,5 @@ +package org.openedx.learn.presentation + +import org.openedx.learn.LearnType + +data class LearnUIState(val learnType: LearnType) 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..21e746374 --- /dev/null +++ b/dashboard/src/main/java/org/openedx/learn/presentation/LearnViewModel.kt @@ -0,0 +1,83 @@ +package org.openedx.learn.presentation + +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.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 +import org.openedx.learn.LearnType + +class LearnViewModel( + openTab: String, + private val config: Config, + private val dashboardRouter: DashboardRouter, + private val analytics: DashboardAnalytics, +) : BaseViewModel() { + private val _uiState = MutableStateFlow( + LearnUIState( + if (openTab == LearnTab.PROGRAMS.name) { + LearnType.PROGRAMS + } else { + LearnType.COURSES + } + ) + ) + + val uiState: StateFlow + get() = _uiState.asStateFlow() + + 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() + + init { + viewModelScope.launch { + _uiState.collect { uiState -> + if (uiState.learnType == LearnType.COURSES) { + logMyCoursesTabClickedEvent() + } else { + logMyProgramsTabClickedEvent() + } + } + } + } + + fun updateLearnType(learnType: LearnType) { + viewModelScope.launch { + _uiState.update { it.copy(learnType = learnType) } + } + } + + 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..74909ac48 100644 --- a/dashboard/src/main/res/values/strings.xml +++ b/dashboard/src/main/res/values/strings.xml @@ -1,7 +1,27 @@ - - Dashboard + Courses - Welcome back. Let\'s keep learning. You are not enrolled in any courses yet. + Learn + Programs + 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 85% 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..fae8a9455 100644 --- a/dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardViewModelTest.kt +++ b/dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardListViewModelTest.kt @@ -8,10 +8,8 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk -import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle @@ -25,20 +23,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.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.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 +48,6 @@ class DashboardViewModelTest { private val networkConnection = mockk() private val discoveryNotifier = mockk() private val analytics = mockk() - private val appUpgradeNotifier = mockk() private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" @@ -66,7 +62,6 @@ 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 { config.getApiHostURL() } returns "http://localhost:8000" } @@ -77,14 +72,13 @@ class DashboardViewModelTest { @Test fun `getCourses no internet connection`() = runTest { - val viewModel = DashboardViewModel( + val viewModel = DashboardListViewModel( config, networkConnection, interactor, resourceManager, discoveryNotifier, analytics, - appUpgradeNotifier ) every { networkConnection.isOnline() } returns true coEvery { interactor.getEnrolledCourses(any()) } throws UnknownHostException() @@ -92,7 +86,6 @@ class DashboardViewModelTest { coVerify(exactly = 1) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } - verify(exactly = 1) { appUpgradeNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assertEquals(noInternet, message?.message) @@ -101,14 +94,13 @@ class DashboardViewModelTest { @Test fun `getCourses unknown error`() = runTest { - val viewModel = DashboardViewModel( + val viewModel = DashboardListViewModel( config, networkConnection, interactor, resourceManager, discoveryNotifier, analytics, - appUpgradeNotifier ) every { networkConnection.isOnline() } returns true coEvery { interactor.getEnrolledCourses(any()) } throws Exception() @@ -116,7 +108,6 @@ class DashboardViewModelTest { coVerify(exactly = 1) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } - verify(exactly = 1) { appUpgradeNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assertEquals(somethingWrong, message?.message) @@ -125,14 +116,13 @@ class DashboardViewModelTest { @Test fun `getCourses from network`() = runTest { - val viewModel = DashboardViewModel( + val viewModel = DashboardListViewModel( config, networkConnection, interactor, resourceManager, discoveryNotifier, analytics, - appUpgradeNotifier ) every { networkConnection.isOnline() } returns true coEvery { interactor.getEnrolledCourses(any()) } returns dashboardCourseList @@ -141,7 +131,6 @@ class DashboardViewModelTest { coVerify(exactly = 1) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } - verify(exactly = 1) { appUpgradeNotifier.notifier } assert(viewModel.uiMessage.value == null) assert(viewModel.uiState.value is DashboardUIState.Courses) @@ -149,14 +138,13 @@ class DashboardViewModelTest { @Test fun `getCourses from network with next page`() = runTest { - val viewModel = DashboardViewModel( + val viewModel = DashboardListViewModel( config, networkConnection, interactor, resourceManager, discoveryNotifier, analytics, - appUpgradeNotifier ) every { networkConnection.isOnline() } returns true coEvery { interactor.getEnrolledCourses(any()) } returns dashboardCourseList.copy( @@ -173,7 +161,6 @@ class DashboardViewModelTest { coVerify(exactly = 1) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } - verify(exactly = 1) { appUpgradeNotifier.notifier } assert(viewModel.uiMessage.value == null) assert(viewModel.uiState.value is DashboardUIState.Courses) @@ -183,21 +170,19 @@ 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 ) advanceUntilIdle() coVerify(exactly = 0) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 1) { interactor.getEnrolledCoursesFromCache() } - verify(exactly = 1) { appUpgradeNotifier.notifier } assert(viewModel.uiMessage.value == null) assert(viewModel.uiState.value is DashboardUIState.Courses) @@ -207,14 +192,13 @@ 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 ) coEvery { interactor.getEnrolledCourses(any()) } throws UnknownHostException() @@ -223,7 +207,6 @@ class DashboardViewModelTest { coVerify(exactly = 2) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } - verify(exactly = 1) { appUpgradeNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assertEquals(noInternet, message?.message) @@ -235,14 +218,13 @@ 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 ) coEvery { interactor.getEnrolledCourses(any()) } throws Exception() @@ -251,7 +233,6 @@ class DashboardViewModelTest { coVerify(exactly = 2) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } - verify(exactly = 1) { appUpgradeNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assertEquals(somethingWrong, message?.message) @@ -263,14 +244,13 @@ 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 ) viewModel.updateCourses() @@ -278,8 +258,6 @@ class DashboardViewModelTest { coVerify(exactly = 2) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } - verify(exactly = 1) { appUpgradeNotifier.notifier } - assert(viewModel.uiMessage.value == null) assert(viewModel.updating.value == false) assert(viewModel.uiState.value is DashboardUIState.Courses) @@ -296,14 +274,13 @@ class DashboardViewModelTest { "" ) ) - val viewModel = DashboardViewModel( + val viewModel = DashboardListViewModel( config, networkConnection, interactor, resourceManager, discoveryNotifier, analytics, - appUpgradeNotifier ) viewModel.updateCourses() @@ -311,8 +288,6 @@ class DashboardViewModelTest { coVerify(exactly = 2) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } - verify(exactly = 1) { appUpgradeNotifier.notifier } - assert(viewModel.uiMessage.value == null) assert(viewModel.updating.value == false) assert(viewModel.uiState.value is DashboardUIState.Courses) @@ -321,14 +296,13 @@ 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 ) val mockLifeCycleOwner: LifecycleOwner = mockk() @@ -339,7 +313,5 @@ class DashboardViewModelTest { advanceUntilIdle() coVerify(exactly = 1) { interactor.getEnrolledCourses(any()) } - verify(exactly = 1) { appUpgradeNotifier.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..c82df34d8 --- /dev/null +++ b/dashboard/src/test/java/org/openedx/dashboard/presentation/LearnViewModelTest.kt @@ -0,0 +1,104 @@ +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.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +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.DashboardNavigator +import org.openedx.core.config.Config +import org.openedx.core.config.DashboardConfig +import org.openedx.learn.presentation.LearnTab +import org.openedx.learn.presentation.LearnViewModel + +@OptIn(ExperimentalCoroutinesApi::class) +class LearnViewModelTest { + + private val dispatcher = StandardTestDispatcher() + + private val config = mockk() + private val dashboardRouter = mockk(relaxed = true) + private val analytics = mockk(relaxed = true) + private val fragmentManager = mockk() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `onSettingsClick calls navigateToSettings`() = runTest { + val viewModel = LearnViewModel(LearnTab.COURSES.name, config, dashboardRouter, analytics) + viewModel.onSettingsClick(fragmentManager) + verify { dashboardRouter.navigateToSettings(fragmentManager) } + } + + @Test + fun `getDashboardFragment returns correct fragment based on dashboardType`() = runTest { + val viewModel = LearnViewModel(LearnTab.COURSES.name, config, dashboardRouter, analytics) + 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 { + val viewModel = LearnViewModel(LearnTab.COURSES.name, config, dashboardRouter, analytics) + viewModel.getProgramFragment + verify { dashboardRouter.getProgramFragment() } + } + + @Test + fun `isProgramTypeWebView returns correct view type`() = runTest { + val viewModel = LearnViewModel(LearnTab.COURSES.name, config, dashboardRouter, analytics) + every { config.getProgramConfig().isViewTypeWebView() } returns true + assertTrue(viewModel.isProgramTypeWebView) + } + + @Test + fun `logMyCoursesTabClickedEvent logs correct analytics event`() = runTest { + val viewModel = LearnViewModel(LearnTab.COURSES.name, config, dashboardRouter, analytics) + 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 { + val viewModel = LearnViewModel(LearnTab.COURSES.name, config, dashboardRouter, analytics) + 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..952e041de 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 @@ -66,16 +64,31 @@ BRANCH: HOST: '' ALTERNATE_HOST: '' +EXPERIMENTAL_FEATURES: + APP_LEVEL_DOWNLOADS: + ENABLED: false + APP_LEVEL_DATES: + ENABLED: false + #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 +#feature flag to do the authentication flow in the browser to log in +BROWSER_LOGIN: false +#feature flag to do the registration for in the browser +BROWSER_REGISTRATION: false #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..a7f265a45 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 @@ -66,15 +64,29 @@ BRANCH: HOST: '' ALTERNATE_HOST: '' +EXPERIMENTAL_FEATURES: + APP_LEVEL_DOWNLOADS: + ENABLED: false + #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 +#feature flag to do the authentication flow in the browser to log in +BROWSER_LOGIN: false +#feature flag to do the registration for in the browser +BROWSER_REGISTRATION: false #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..a7f265a45 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 @@ -66,15 +64,29 @@ BRANCH: HOST: '' ALTERNATE_HOST: '' +EXPERIMENTAL_FEATURES: + APP_LEVEL_DOWNLOADS: + ENABLED: false + #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 +#feature flag to do the authentication flow in the browser to log in +BROWSER_LOGIN: false +#feature flag to do the registration for in the browser +BROWSER_REGISTRATION: false #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..efb02b6f4 100644 --- a/discovery/build.gradle +++ b/discovery/build.gradle @@ -2,44 +2,44 @@ 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 { - compileSdk 34 + namespace 'org.openedx.discovery' + compileSdkVersion compile_sdk_version defaultConfig { - minSdk 24 - targetSdk 34 + minSdk min_sdk_version + targetSdk target_sdk_version testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles "consumer-rules.pro" } - namespace 'org.openedx.discovery' buildTypes { release { - minifyEnabled true + minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } - compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 + sourceCompatibility java_version + targetCompatibility java_version } - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17 + kotlin { + compilerOptions { + jvmTarget = jvm_target_version + freeCompilerArgs = ['-XXLanguage:+PropertyParamAnnotationDefaultTargetMode'] + } } buildFeatures { viewBinding true compose true } - composeOptions { - kotlinCompilerExtensionVersion = "$compose_compiler_version" - } flavorDimensions += "env" productFlavors { @@ -58,17 +58,12 @@ android { dependencies { implementation project(path: ':core') - kapt "androidx.room:room-compiler:$room_version" - implementation 'androidx.activity:activity-compose:1.8.1' + implementation "androidx.activity:activity-compose:$activity_compose_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" 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" - + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlinx_coroutines_test_version" + androidTestImplementation "androidx.test.ext:junit:$test_ext_version" + androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_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/data/converter/DiscoveryConverter.kt b/discovery/src/main/java/org/openedx/discovery/data/converter/DiscoveryConverter.kt index 22ab5cac7..9497d5e76 100644 --- a/discovery/src/main/java/org/openedx/discovery/data/converter/DiscoveryConverter.kt +++ b/discovery/src/main/java/org/openedx/discovery/data/converter/DiscoveryConverter.kt @@ -60,4 +60,4 @@ class DiscoveryConverter { if (value.isEmpty()) return null return Gson().fromJson(value, CourseVideoDb::class.java) } -} \ No newline at end of file +} diff --git a/discovery/src/main/java/org/openedx/discovery/data/model/CourseDetails.kt b/discovery/src/main/java/org/openedx/discovery/data/model/CourseDetails.kt index 5cafc1516..d7e10a4cf 100644 --- a/discovery/src/main/java/org/openedx/discovery/data/model/CourseDetails.kt +++ b/discovery/src/main/java/org/openedx/discovery/data/model/CourseDetails.kt @@ -52,27 +52,33 @@ data class CourseDetails( fun mapToDomain(): Course { return Course( - id = id ?: "", - blocksUrl = blocksUrl ?: "", - courseId = courseId ?: "", - effort = effort ?: "", - enrollmentStart = TimeUtils.iso8601ToDate(enrollmentStart ?: ""), - enrollmentEnd = TimeUtils.iso8601ToDate(enrollmentEnd ?: ""), + id = id.orEmpty(), + blocksUrl = blocksUrl.orEmpty(), + courseId = courseId.orEmpty(), + effort = effort.orEmpty(), + enrollmentStart = parseEnrollmentStartDate(), + enrollmentEnd = parseEnrollmentEndDate(), hidden = hidden ?: false, invitationOnly = invitationOnly ?: false, mobileAvailable = mobileAvailable ?: false, - name = name ?: "", - number = number ?: "", - org = organization ?: "", - shortDescription = shortDescription ?: "", - start = start ?: "", - end = end ?: "", - startDisplay = startDisplay ?: "", - startType = startType ?: "", - pacing = pacing ?: "", - overview = overview ?: "", + name = name.orEmpty(), + number = number.orEmpty(), + org = organization.orEmpty(), + shortDescription = shortDescription.orEmpty(), + start = start.orEmpty(), + end = end.orEmpty(), + startDisplay = startDisplay.orEmpty(), + startType = startType.orEmpty(), + pacing = pacing.orEmpty(), + overview = overview.orEmpty(), isEnrolled = isEnrolled ?: false, - media = media?.mapToDomain() ?: org.openedx.core.domain.model.Media() + media = mapMediaToDomain() ) } + + private fun parseEnrollmentStartDate() = TimeUtils.iso8601ToDate(enrollmentStart.orEmpty()) + + private fun parseEnrollmentEndDate() = TimeUtils.iso8601ToDate(enrollmentEnd.orEmpty()) + + private fun mapMediaToDomain() = media?.mapToDomain() ?: org.openedx.core.domain.model.Media() } diff --git a/discovery/src/main/java/org/openedx/discovery/data/model/room/CourseEntity.kt b/discovery/src/main/java/org/openedx/discovery/data/model/room/CourseEntity.kt index ecf76c24d..a9eff0f98 100644 --- a/discovery/src/main/java/org/openedx/discovery/data/model/room/CourseEntity.kt +++ b/discovery/src/main/java/org/openedx/discovery/data/model/room/CourseEntity.kt @@ -84,31 +84,29 @@ data class CourseEntity( companion object { fun createFrom(model: CourseDetails): CourseEntity { - with(model) { - return CourseEntity( - id = id ?: "", - blocksUrl = blocksUrl ?: "", - courseId = courseId ?: "", - effort = effort ?: "", - enrollmentStart = enrollmentStart ?: "", - enrollmentEnd = enrollmentEnd ?: "", - hidden = hidden ?: false, - invitationOnly = invitationOnly ?: false, - mobileAvailable = mobileAvailable ?: false, - name = name ?: "", - number = number ?: "", - org = organization ?: "", - shortDescription = shortDescription ?: "", - start = start ?: "", - end = end ?: "", - startDisplay = startDisplay ?: "", - startType = startType ?: "", - pacing = pacing ?: "", - overview = overview ?: "", - media = MediaDb.createFrom(media), - isEnrolled = isEnrolled ?: false - ) - } + return CourseEntity( + id = model.id.orEmpty(), + blocksUrl = model.blocksUrl.orEmpty(), + courseId = model.courseId.orEmpty(), + effort = model.effort.orEmpty(), + enrollmentStart = model.enrollmentStart.orEmpty(), + enrollmentEnd = model.enrollmentEnd.orEmpty(), + hidden = model.hidden ?: false, + invitationOnly = model.invitationOnly ?: false, + mobileAvailable = model.mobileAvailable ?: false, + name = model.name.orEmpty(), + number = model.number.orEmpty(), + org = model.organization.orEmpty(), + shortDescription = model.shortDescription.orEmpty(), + start = model.start.orEmpty(), + end = model.end.orEmpty(), + startDisplay = model.startDisplay.orEmpty(), + startType = model.startType.orEmpty(), + pacing = model.pacing.orEmpty(), + overview = model.overview.orEmpty(), + media = MediaDb.createFrom(model.media), + isEnrolled = model.isEnrolled ?: false + ) } } } diff --git a/discovery/src/main/java/org/openedx/discovery/data/repository/DiscoveryRepository.kt b/discovery/src/main/java/org/openedx/discovery/data/repository/DiscoveryRepository.kt index bdadccecc..05da75d29 100644 --- a/discovery/src/main/java/org/openedx/discovery/data/repository/DiscoveryRepository.kt +++ b/discovery/src/main/java/org/openedx/discovery/data/repository/DiscoveryRepository.kt @@ -9,7 +9,6 @@ import org.openedx.discovery.data.storage.DiscoveryDao import org.openedx.discovery.domain.model.Course import org.openedx.discovery.domain.model.CourseList - class DiscoveryRepository( private val api: DiscoveryApi, private val dao: DiscoveryDao, diff --git a/discovery/src/main/java/org/openedx/discovery/data/storage/DiscoveryDao.kt b/discovery/src/main/java/org/openedx/discovery/data/storage/DiscoveryDao.kt index 434d425fa..2c72514fa 100644 --- a/discovery/src/main/java/org/openedx/discovery/data/storage/DiscoveryDao.kt +++ b/discovery/src/main/java/org/openedx/discovery/data/storage/DiscoveryDao.kt @@ -24,5 +24,4 @@ interface DiscoveryDao { @Query("SELECT * FROM course_discovery_table") suspend fun readAllData(): List - } diff --git a/discovery/src/main/java/org/openedx/discovery/domain/interactor/DiscoveryInteractor.kt b/discovery/src/main/java/org/openedx/discovery/domain/interactor/DiscoveryInteractor.kt index a1991b655..32f65e3e4 100644 --- a/discovery/src/main/java/org/openedx/discovery/domain/interactor/DiscoveryInteractor.kt +++ b/discovery/src/main/java/org/openedx/discovery/domain/interactor/DiscoveryInteractor.kt @@ -30,5 +30,4 @@ class DiscoveryInteractor(private val repository: DiscoveryRepository) { suspend fun getCoursesListFromCache(): List { return repository.getCachedCoursesList() } - } diff --git a/discovery/src/main/java/org/openedx/discovery/domain/model/Course.kt b/discovery/src/main/java/org/openedx/discovery/domain/model/Course.kt index ae615821c..6e7f428e9 100644 --- a/discovery/src/main/java/org/openedx/discovery/domain/model/Course.kt +++ b/discovery/src/main/java/org/openedx/discovery/domain/model/Course.kt @@ -3,7 +3,6 @@ package org.openedx.discovery.domain.model import org.openedx.core.domain.model.Media import java.util.Date - data class Course( val id: String, val blocksUrl: String, 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..2212849b5 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt @@ -57,32 +57,28 @@ 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.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.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.NativeDiscoveryFragment.Companion.LOAD_MORE_THRESHOLD 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() { @@ -103,8 +99,6 @@ class NativeDiscoveryFragment : Fragment() { val uiMessage by viewModel.uiMessage.observeAsState() val canLoadMore by viewModel.canLoadMore.observeAsState(false) val refreshing by viewModel.isUpdating.observeAsState(false) - val appUpgradeEvent by viewModel.appUpgradeEvent.observeAsState() - val wasUpdateDialogClosed by remember { wasUpdateDialogClosed } val querySearch = arguments?.getString(ARG_SEARCH_QUERY, "") ?: "" DiscoveryScreen( @@ -117,29 +111,12 @@ class NativeDiscoveryFragment : Fragment() { hasInternetConnection = viewModel.hasInternetConnection, canShowBackButton = viewModel.canShowBackButton, isUserLoggedIn = viewModel.isUserLoggedIn, - appUpgradeParameters = AppUpdateState.AppUpgradeParameters( - appUpgradeEvent = appUpgradeEvent, - wasUpdateDialogClosed = wasUpdateDialogClosed, - appUpgradeRecommendedDialog = { - val dialog = AppUpgradeDialogFragment.newInstance() - dialog.show( - requireActivity().supportFragmentManager, - AppUpgradeDialogFragment::class.simpleName - ) - }, - onAppUpgradeRecommendedBoxClick = { - AppUpdateState.openPlayMarket(requireContext()) - }, - onAppUpgradeRequired = { - router.navigateToUpgradeRequired( - requireActivity().supportFragmentManager - ) - } - ), + isRegistrationEnabled = viewModel.isRegistrationEnabled, onSearchClick = { viewModel.discoverySearchBarClickedEvent() router.navigateToCourseSearch( - requireActivity().supportFragmentManager, "" + requireActivity().supportFragmentManager, + "" ) }, paginationCallback = { @@ -175,7 +152,8 @@ class NativeDiscoveryFragment : Fragment() { LaunchedEffect(uiState) { if (querySearch.isNotEmpty()) { router.navigateToCourseSearch( - requireActivity().supportFragmentManager, querySearch + requireActivity().supportFragmentManager, + querySearch ) arguments?.putString(ARG_SEARCH_QUERY, "") } @@ -186,6 +164,7 @@ class NativeDiscoveryFragment : Fragment() { companion object { private const val ARG_SEARCH_QUERY = "query_search" + const val LOAD_MORE_THRESHOLD = 4 fun newInstance(querySearch: String = ""): NativeDiscoveryFragment { val fragment = NativeDiscoveryFragment() fragment.arguments = bundleOf( @@ -196,7 +175,6 @@ class NativeDiscoveryFragment : Fragment() { } } - @OptIn(ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class) @Composable internal fun DiscoveryScreen( @@ -209,7 +187,7 @@ internal fun DiscoveryScreen( hasInternetConnection: Boolean, canShowBackButton: Boolean, isUserLoggedIn: Boolean, - appUpgradeParameters: AppUpdateState.AppUpgradeParameters, + isRegistrationEnabled: Boolean, onSearchClick: () -> Unit, onSwipeRefresh: () -> Unit, onReloadClick: () -> Unit, @@ -252,13 +230,13 @@ internal fun DiscoveryScreen( ) { AuthButtonsPanel( onRegisterClick = onRegisterClick, - onSignInClick = onSignInClick + onSignInClick = onSignInClick, + showRegisterButton = isRegistrationEnabled ) } } } ) { - val searchTabWidth by remember(key1 = windowSize) { mutableStateOf( windowSize.windowSizeValue( @@ -351,8 +329,9 @@ internal fun DiscoveryScreen( when (state) { is DiscoveryUIState.Loading -> { Box( - Modifier - .fillMaxSize(), contentAlignment = Alignment.Center + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center ) { CircularProgressIndicator(color = MaterialTheme.appColors.primary) } @@ -396,7 +375,8 @@ internal fun DiscoveryScreen( windowSize = windowSize, onClick = { onItemClick(course) - }) + } + ) Divider() } item { @@ -412,7 +392,11 @@ internal fun DiscoveryScreen( } } } - if (scrollState.shouldLoadMore(firstVisibleIndex, 4)) { + if (scrollState.shouldLoadMore( + firstVisibleIndex, + LOAD_MORE_THRESHOLD + ) + ) { paginationCallback() } } @@ -429,30 +413,6 @@ internal fun DiscoveryScreen( .fillMaxWidth() .align(Alignment.BottomCenter) ) { - when (appUpgradeParameters.appUpgradeEvent) { - is AppUpgradeEvent.UpgradeRecommendedEvent -> { - if (appUpgradeParameters.wasUpdateDialogClosed) { - AppUpgradeRecommendedBox( - modifier = Modifier.fillMaxWidth(), - onClick = appUpgradeParameters.onAppUpgradeRecommendedBoxClick - ) - } else { - if (!AppUpdateState.wasUpdateDialogDisplayed) { - AppUpdateState.wasUpdateDialogDisplayed = true - appUpgradeParameters.appUpgradeRecommendedDialog() - } - } - } - - is AppUpgradeEvent.UpgradeRequiredEvent -> { - if (!AppUpdateState.wasUpdateDialogDisplayed) { - AppUpdateState.wasUpdateDialogDisplayed = true - appUpgradeParameters.onAppUpgradeRequired() - } - } - - else -> {} - } if (!isInternetConnectionShown && !hasInternetConnection) { OfflineModeDialog( Modifier @@ -482,7 +442,8 @@ private fun CourseItemPreview() { apiHostUrl = "", course = mockCourse, windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - onClick = {}) + onClick = {} + ) } } @@ -517,7 +478,7 @@ private fun DiscoveryScreenPreview() { refreshing = false, hasInternetConnection = true, isUserLoggedIn = false, - appUpgradeParameters = AppUpdateState.AppUpgradeParameters(), + isRegistrationEnabled = true, onSignInClick = {}, onRegisterClick = {}, onBackClick = {}, @@ -558,7 +519,7 @@ private fun DiscoveryScreenTabletPreview() { refreshing = false, hasInternetConnection = true, isUserLoggedIn = true, - appUpgradeParameters = AppUpdateState.AppUpgradeParameters(), + isRegistrationEnabled = true, onSignInClick = {}, onRegisterClick = {}, onBackClick = {}, 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..70acffbd8 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryViewModel.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryViewModel.kt @@ -3,22 +3,18 @@ package org.openedx.discovery.presentation import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData 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.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 +22,13 @@ class NativeDiscoveryViewModel( private val interactor: DiscoveryInteractor, private val resourceManager: ResourceManager, private val analytics: DiscoveryAnalytics, - private val appUpgradeNotifier: AppUpgradeNotifier, 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 @@ -50,10 +46,6 @@ class NativeDiscoveryViewModel( val isUpdating: LiveData get() = _isUpdating - private val _appUpgradeEvent = MutableLiveData() - val appUpgradeEvent: LiveData - get() = _appUpgradeEvent - val hasInternetConnection: Boolean get() = networkConnection.isOnline() @@ -63,7 +55,6 @@ class NativeDiscoveryViewModel( init { getCoursesList() - collectAppUpgradeEvent() } private fun loadCoursesInternal( @@ -75,7 +66,9 @@ class NativeDiscoveryViewModel( isLoading = true val response = if (networkConnection.isOnline() || page > 1) { interactor.getCoursesList(username, organization, page) - } else null + } else { + null + } if (response != null) { if (response.pagination.next.isNotEmpty() && page != response.pagination.numPages) { _canLoadMore.value = true @@ -148,7 +141,6 @@ class NativeDiscoveryViewModel( _isUpdating.value = false } } - } fun fetchMore() { @@ -157,25 +149,6 @@ class NativeDiscoveryViewModel( } } - @OptIn(FlowPreview::class) - private fun collectAppUpgradeEvent() { - viewModelScope.launch { - appUpgradeNotifier.notifier - .debounce(100) - .collect { event -> - when (event) { - is AppUpgradeEvent.UpgradeRecommendedEvent -> { - _appUpgradeEvent.value = event - } - - is AppUpgradeEvent.UpgradeRequiredEvent -> { - _appUpgradeEvent.value = AppUpgradeEvent.UpgradeRequiredEvent - } - } - } - } - } - fun discoverySearchBarClickedEvent() { analytics.discoverySearchBarClickedEvent() } 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..d41a491a3 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 @@ -34,7 +35,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics @@ -49,23 +49,27 @@ import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.compose.LocalLifecycleOwner 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,34 @@ 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, + userAgent = viewModel.appUserAgent, + 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 +191,14 @@ class WebViewDiscoveryFragment : Fragment() { @SuppressLint("SetJavaScriptEnabled") private fun WebViewDiscoveryScreen( windowSize: WindowSize, + uiState: WebViewUIState, isPreLogin: Boolean, contentUrl: String, uriScheme: String, + isRegistrationEnabled: Boolean, + userAgent: String, hasInternetConnection: Boolean, - checkInternetConnection: () -> Unit, + onWebViewUIAction: (WebViewUIAction) -> Unit, onWebPageUpdated: (String) -> Unit, onUriClick: (String, WebViewLink.Authority) -> Unit, onRegisterClick: () -> Unit, @@ -184,7 +208,6 @@ private fun WebViewDiscoveryScreen( ) { val scaffoldState = rememberScaffoldState() val configuration = LocalConfiguration.current - var isLoading by remember { mutableStateOf(true) } Scaffold( scaffoldState = scaffoldState, @@ -206,7 +229,8 @@ private fun WebViewDiscoveryScreen( ) { AuthButtonsPanel( onRegisterClick = onRegisterClick, - onSignInClick = onSignInClick + onSignInClick = onSignInClick, + showRegisterButton = isRegistrationEnabled ) } } @@ -248,25 +272,33 @@ 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, + userAgent = userAgent, + 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() @@ -287,16 +319,20 @@ private fun WebViewDiscoveryScreen( private fun DiscoveryWebView( contentUrl: String, uriScheme: String, + userAgent: String, onWebPageLoaded: () -> Unit, onWebPageUpdated: (String) -> Unit, onUriClick: (String, WebViewLink.Authority) -> Unit, + onWebPageLoadError: () -> Unit ) { val webView = CatalogWebViewScreen( url = contentUrl, uriScheme = uriScheme, + userAgent = userAgent, onWebPageLoaded = onWebPageLoaded, onWebPageUpdated = onWebPageUpdated, onUriClick = onUriClick, + onWebPageLoadError = onWebPageLoadError ) AndroidView( @@ -360,17 +396,20 @@ 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, + userAgent = "", 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..f15588ff9 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryViewModel.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryViewModel.kt @@ -1,14 +1,21 @@ 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.AppData +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, + private val appData: AppData, private val config: Config, private val networkConnection: NetworkConnection, private val corePreferences: CorePreferences, @@ -16,11 +23,16 @@ 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() + + val appUserAgent get() = appData.appUserAgent private var _discoveryUrl = webViewConfig.baseUrl val discoveryUrl: String @@ -37,6 +49,24 @@ 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 +107,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..4cf9ebf30 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,14 @@ 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.discovery.presentation.catalog.WebViewLink.Authority as linkAuthority @SuppressLint("SetJavaScriptEnabled", "ComposableNaming") @@ -13,14 +16,16 @@ import org.openedx.discovery.presentation.catalog.WebViewLink.Authority as linkA fun CatalogWebViewScreen( url: String, uriScheme: String, + userAgent: String, isAllLinksExternal: Boolean = false, onWebPageLoaded: () -> Unit, 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 (request.url.toString() == view.url) { + onWebPageLoadError() + } + super.onReceivedError(view, request, error) + } } with(settings) { @@ -88,11 +104,13 @@ fun CatalogWebViewScreen( setSupportZoom(true) loadsImagesAutomatically = true domStorageEnabled = true + userAgentString = "$userAgentString $userAgent" } isVerticalScrollBarEnabled = false 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..7d1e7659f 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. @@ -29,24 +29,22 @@ class WebViewLink( companion object { fun parse(uriStr: String?, uriScheme: String): WebViewLink? { - if (uriStr.isNullOrEmpty()) { - return null - } + if (uriStr.isNullOrEmpty()) return null + val sanitizedUriStr = uriStr.replace("+", "%2B") val uri = Uri.parse(sanitizedUriStr) - // Validate the URI scheme - if (uriScheme != uri.scheme) { - return null - } - - // Validate the Uri authority - val uriAuthority = Authority.entries.find { it.key == uri.authority } ?: return null - - // Parse the Uri params - val params = uri.getQueryParams() + // Validate URI scheme and authority + val isSchemeValid = uriScheme == uri.scheme + val uriAuthority = Authority.entries.find { it.key == uri.authority } - return WebViewLink(uriAuthority, params) + return if (isSchemeValid && uriAuthority != null) { + // Parse the URI params + val params = uri.getQueryParams() + WebViewLink(uriAuthority, params) + } else { + null + } } } } diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/detail/AuthorizationDialogFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/detail/AuthorizationDialogFragment.kt new file mode 100644 index 000000000..b6c7e18a8 --- /dev/null +++ b/discovery/src/main/java/org/openedx/discovery/presentation/detail/AuthorizationDialogFragment.kt @@ -0,0 +1,318 @@ +package org.openedx.discovery.presentation.detail + +import android.content.res.Configuration +import android.content.res.Configuration.UI_MODE_NIGHT_NO +import android.content.res.Configuration.UI_MODE_NIGHT_YES +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.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.material.Card +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.automirrored.filled.Login +import androidx.compose.material.icons.filled.Close +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.LocalConfiguration +import androidx.compose.ui.platform.ViewCompositionStrategy +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.fragment.app.DialogFragment +import org.koin.android.ext.android.inject +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.discovery.presentation.DiscoveryRouter +import org.openedx.foundation.extension.setWidthPercent +import org.openedx.core.R as coreR + +class AuthorizationDialogFragment : DialogFragment() { + + private val router: DiscoveryRouter by inject() + + override fun onResume() { + super.onResume() + if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) { + setWidthPercent(percentage = LANDSCAPE_WIDTH_PERCENT) + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ) = ComposeView(requireContext()).apply { + dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val courseId = requireArguments().getString(ARG_COURSE_ID) ?: "" + AuthorizationDialogView( + onRegisterButtonClick = { + router.navigateToSignUp(requireActivity().supportFragmentManager, courseId) + dismiss() + }, + onSignInButtonClick = { + router.navigateToSignIn( + requireActivity().supportFragmentManager, + courseId, + null + ) + dismiss() + }, + onCancelButtonClick = { + dismiss() + } + ) + } + } + } + + companion object { + private const val ARG_COURSE_ID = "arg_course_id" + private const val LANDSCAPE_WIDTH_PERCENT = 66 + fun newInstance( + courseId: String, + ): AuthorizationDialogFragment { + val dialog = AuthorizationDialogFragment() + dialog.arguments = bundleOf( + ARG_COURSE_ID to courseId, + ) + return dialog + } + } +} + +@Composable +private fun AuthorizationDialogView( + onRegisterButtonClick: () -> Unit, + onSignInButtonClick: () -> Unit, + onCancelButtonClick: () -> Unit +) { + val configuration = LocalConfiguration.current + if (configuration.orientation == Configuration.ORIENTATION_PORTRAIT) { + AuthorizationDialogPortraitView( + onRegisterButtonClick = onRegisterButtonClick, + onSignInButtonClick = onSignInButtonClick, + onCancelButtonClick = onCancelButtonClick + ) + } else { + AuthorizationDialogLandscapeView( + onRegisterButtonClick = onRegisterButtonClick, + onSignInButtonClick = onSignInButtonClick, + onCancelButtonClick = onCancelButtonClick + ) + } +} + +@Composable +private fun AuthorizationDialogPortraitView( + onRegisterButtonClick: () -> Unit, + onSignInButtonClick: () -> Unit, + onCancelButtonClick: () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth(fraction = 0.95f) + .clip(MaterialTheme.appShapes.courseImageShape), + backgroundColor = MaterialTheme.appColors.background, + shape = MaterialTheme.appShapes.courseImageShape + ) { + Column( + modifier = Modifier.padding(30.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + contentAlignment = Alignment.CenterEnd, + modifier = Modifier.fillMaxWidth() + ) { + IconButton( + modifier = Modifier.size(24.dp), + onClick = onCancelButtonClick + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(id = coreR.string.core_cancel), + tint = MaterialTheme.appColors.primary + ) + } + } + Icon( + modifier = Modifier + .width(76.dp) + .height(72.dp), + imageVector = Icons.AutoMirrored.Filled.Login, + contentDescription = null, + tint = MaterialTheme.appColors.onBackground + ) + Spacer(Modifier.height(36.dp)) + Text( + text = stringResource(id = coreR.string.core_authorization), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleLarge + ) + Spacer(Modifier.height(8.dp)) + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = coreR.string.core_authorization_request), + color = MaterialTheme.appColors.textFieldText, + style = MaterialTheme.appTypography.titleSmall, + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(42.dp)) + Row { + OpenEdXOutlinedButton( + modifier = Modifier.weight(1f), + borderColor = MaterialTheme.appColors.primaryButtonBackground, + textColor = MaterialTheme.appColors.primaryButtonBackground, + text = stringResource(id = coreR.string.core_sign_in), + onClick = onSignInButtonClick + ) + Spacer(Modifier.width(16.dp)) + OpenEdXButton( + modifier = Modifier.weight(1f), + text = stringResource(id = coreR.string.core_register), + onClick = onRegisterButtonClick + ) + } + } + } +} + +@Composable +private fun AuthorizationDialogLandscapeView( + onRegisterButtonClick: () -> Unit, + onSignInButtonClick: () -> Unit, + onCancelButtonClick: () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .clip(MaterialTheme.appShapes.courseImageShape), + backgroundColor = MaterialTheme.appColors.background, + shape = MaterialTheme.appShapes.courseImageShape + ) { + Column( + modifier = Modifier.padding(38.dp) + ) { + Box( + contentAlignment = Alignment.CenterEnd, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 12.dp) + ) { + IconButton( + modifier = Modifier.size(24.dp), + onClick = onCancelButtonClick + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(id = coreR.string.core_cancel), + tint = MaterialTheme.appColors.primary + ) + } + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column( + Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + modifier = Modifier + .width(76.dp) + .height(72.dp), + imageVector = Icons.AutoMirrored.Filled.Login, + contentDescription = null, + tint = MaterialTheme.appColors.onBackground + ) + Spacer(Modifier.height(36.dp)) + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = coreR.string.core_authorization), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleLarge, + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(8.dp)) + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = coreR.string.core_authorization_request), + color = MaterialTheme.appColors.textFieldText, + style = MaterialTheme.appTypography.titleSmall, + textAlign = TextAlign.Center + ) + } + Spacer(Modifier.width(42.dp)) + Column( + Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally + ) { + OpenEdXOutlinedButton( + borderColor = MaterialTheme.appColors.primaryButtonBackground, + textColor = MaterialTheme.appColors.primaryButtonBackground, + text = stringResource(id = coreR.string.core_sign_in), + onClick = onSignInButtonClick, + ) + Spacer(Modifier.height(16.dp)) + OpenEdXButton( + text = stringResource(id = coreR.string.core_register), + onClick = onRegisterButtonClick + ) + } + } + } + } +} + +@Preview(uiMode = UI_MODE_NIGHT_NO) +@Preview(uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun AuthorizationDialogPortraitViewPreview() { + OpenEdXTheme { + AuthorizationDialogPortraitView( + onSignInButtonClick = {}, + onRegisterButtonClick = {}, + onCancelButtonClick = {} + ) + } +} + +@Preview(uiMode = UI_MODE_NIGHT_NO) +@Preview(uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun AuthorizationDialogLandscapeViewPreview() { + OpenEdXTheme { + AuthorizationDialogLandscapeView( + onSignInButtonClick = {}, + onRegisterButtonClick = {}, + onCancelButtonClick = {} + ) + } +} 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..d49f9e1c4 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() }, @@ -151,9 +154,12 @@ class CourseDetailsFragment : Fragment() { if (currentState is CourseDetailsUIState.CourseData) { when { (!currentState.isUserLoggedIn) -> { - router.navigateToLogistration( - parentFragmentManager, - currentState.course.courseId + val dialog = AuthorizationDialogFragment.newInstance( + viewModel.courseId + ) + dialog.show( + requireActivity().supportFragmentManager, + AuthorizationDialogFragment::class.simpleName ) } @@ -162,7 +168,6 @@ class CourseDetailsFragment : Fragment() { requireActivity().supportFragmentManager, currentState.course.courseId, currentState.course.name, - "", ) } @@ -198,7 +203,6 @@ class CourseDetailsFragment : Fragment() { } } - @OptIn(ExperimentalComposeUiApi::class) @Composable internal fun CourseDetailsScreen( @@ -209,6 +213,7 @@ internal fun CourseDetailsScreen( htmlBody: String, hasInternetConnection: Boolean, isUserLoggedIn: Boolean, + isRegistrationEnabled: Boolean, onReloadClick: () -> Unit, onBackClick: () -> Unit, onButtonClick: () -> Unit, @@ -236,13 +241,13 @@ internal fun CourseDetailsScreen( Box(modifier = Modifier.padding(horizontal = 16.dp, vertical = 32.dp)) { AuthButtonsPanel( onRegisterClick = onRegisterClick, - onSignInClick = onSignInClick + onSignInClick = onSignInClick, + showRegisterButton = isRegistrationEnabled ) } } } ) { - val screenWidth by remember(key1 = windowSize) { mutableStateOf( windowSize.windowSizeValue( @@ -362,7 +367,8 @@ internal fun CourseDetailsScreen( body = htmlBody, onWebPageLoaded = { webViewAlpha = 1f - }) + } + ) } } } @@ -388,7 +394,6 @@ internal fun CourseDetailsScreen( } } - @Composable private fun CourseDetailNativeContent( windowSize: WindowSize, @@ -427,7 +432,7 @@ private fun CourseDetailNativeContent( Box(contentAlignment = Alignment.Center) { ImageHeader( modifier = Modifier - .aspectRatio(1.86f) + .aspectRatio(ratio = 1.86f) .padding(6.dp), apiHostUrl = apiHostUrl, courseImage = course.media.image?.large, @@ -496,7 +501,6 @@ private fun CourseDetailNativeContent( } } - @Composable private fun CourseDetailNativeContentLandscape( windowSize: WindowSize, @@ -529,7 +533,7 @@ private fun CourseDetailNativeContentLandscape( Column( Modifier .fillMaxHeight() - .weight(3f), + .weight(weight = 3f), verticalArrangement = Arrangement.SpaceBetween ) { Column { @@ -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() { @@ -638,11 +643,10 @@ private fun CourseDescription( request: WebResourceRequest? ): Boolean { val clickUrl = request?.url?.toString() ?: "" - return if (clickUrl.isNotEmpty() && - (clickUrl.startsWith("http://") || - clickUrl.startsWith("https://")) - ) { - context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(clickUrl))) + return if (clickUrl.isNotEmpty() && clickUrl.startsWith("http")) { + context.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse(clickUrl)) + ) true } else if (clickUrl.startsWith("mailto:")) { val email = clickUrl.replace("mailto:", "") @@ -674,6 +678,7 @@ private fun CourseDescription( StandardCharsets.UTF_8.name(), null ) + applyDarkModeIfEnabled(isDarkTheme) } }) } @@ -690,6 +695,7 @@ private fun CourseDetailNativeContentPreview() { apiHostUrl = "http://localhost:8000", hasInternetConnection = false, isUserLoggedIn = true, + isRegistrationEnabled = true, htmlBody = "Preview text", onReloadClick = {}, onBackClick = {}, @@ -712,6 +718,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..b212c588f 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) { @@ -126,7 +130,10 @@ class CourseDetailsViewModel( private fun getColorFromULong(color: ULong): String { if (color == ULong.MIN_VALUE) return "black" - return java.lang.Long.toHexString(color.toLong()).substring(2, 8) + return java.lang.Long.toHexString(color.toLong()).substring( + startIndex = 2, + endIndex = 8 + ) } private fun courseEnrollClickedEvent(courseId: String, courseTitle: String) { @@ -139,7 +146,8 @@ class CourseDetailsViewModel( private fun logEvent( event: DiscoveryAnalyticsEvent, - courseId: String, courseTitle: String, + courseId: String, + courseTitle: String, ) { analytics.logEvent( event.eventName, 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..7c397f206 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,26 @@ 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 +86,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 +107,43 @@ 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, + isRegistrationEnabled = viewModel.isRegistrationEnabled, + userAgent = viewModel.appUserAgent, hasInternetConnection = hasInternetConnection, - checkInternetConnection = { - hasInternetConnection = viewModel.hasInternetConnection + onWebViewUIAction = { action -> + when (action) { + WebViewUIAction.WEB_PAGE_LOADED -> { + viewModel.onWebPageLoaded() + } + + WebViewUIAction.WEB_PAGE_ERROR -> { + viewModel.onWebPageError() + } + + WebViewUIAction.RELOAD_WEB_PAGE -> { + hasInternetConnection = viewModel.hasInternetConnection + viewModel.onWebPageLoading() + } + } }, onRegisterClick = { viewModel.navigateToSignUp( @@ -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,18 +240,20 @@ class CourseInfoFragment : Fragment() { private fun CourseInfoScreen( windowSize: WindowSize, uiState: CourseInfoUIState, + webViewUIState: WebViewUIState, uiMessage: UIMessage?, uriScheme: String, + isRegistrationEnabled: Boolean, + userAgent: String, hasInternetConnection: Boolean, - checkInternetConnection: () -> Unit, + onWebViewUIAction: (WebViewUIAction) -> Unit, onRegisterClick: () -> Unit, onSignInClick: () -> Unit, onBackClick: () -> Unit, - onUriClick: (String, WebViewLink.Authority) -> Unit, + onUriClick: (String, linkAuthority) -> Unit, ) { val scaffoldState = rememberScaffoldState() val configuration = LocalConfiguration.current - var isLoading by remember { mutableStateOf(true) } HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) @@ -240,7 +262,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 +272,8 @@ private fun CourseInfoScreen( ) { AuthButtonsPanel( onRegisterClick = onRegisterClick, - onSignInClick = onSignInClick + onSignInClick = onSignInClick, + showRegisterButton = isRegistrationEnabled ) } } @@ -291,24 +314,28 @@ 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, + userAgent = userAgent, + 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() @@ -329,16 +356,19 @@ private fun CourseInfoScreen( private fun CourseInfoWebView( contentUrl: String, uriScheme: String, + userAgent: String, onWebPageLoaded: () -> Unit, onUriClick: (String, linkAuthority) -> Unit, + onWebPageLoadError: () -> Unit ) { - val webView = CatalogWebViewScreen( url = contentUrl, uriScheme = uriScheme, + userAgent = userAgent, isAllLinksExternal = true, onWebPageLoaded = onWebPageLoaded, onUriClick = onUriClick, + onWebPageLoadError = onWebPageLoadError ) AndroidView( @@ -347,9 +377,6 @@ private fun CourseInfoWebView( factory = { webView }, - update = { - webView.loadUrl(contentUrl) - } ) } @@ -360,19 +387,22 @@ 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, + userAgent = "", 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..184001160 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,16 @@ 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.AppData +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,12 +28,17 @@ 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 class CourseInfoViewModel( val pathId: String, val infoType: String, + private val appData: AppData, private val config: Config, private val networkConnection: NetworkConnection, private val router: DiscoveryRouter, @@ -46,13 +51,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,8 +73,12 @@ class CourseInfoViewModel( val hasInternetConnection: Boolean get() = networkConnection.isOnline() + val isRegistrationEnabled: Boolean get() = config.isRegistrationEnabled() + val uriScheme: String get() = config.getUriScheme() + val appUserAgent get() = appData.appUserAgent + private val webViewConfig get() = config.getDiscoveryConfig().webViewConfig private fun getInitialUrl(): String { @@ -122,7 +135,6 @@ class CourseInfoViewModel( fm = fragmentManager, courseId = courseId, courseTitle = "", - enrollmentMode = "", ) } } @@ -146,11 +158,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,17 +177,46 @@ 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 { private const val ARG_PATH_ID = "path_id" } diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramFragment.kt index 4e97efe18..1c78faf23 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,42 @@ 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 +85,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,10 +96,9 @@ 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) { - val courseId = (uiState as ProgramUIState.CourseEnrolled).courseId val isEnrolled = (uiState as ProgramUIState.CourseEnrolled).isEnrolled @@ -119,14 +126,29 @@ 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, + userAgent = viewModel.appUserAgent, 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() }, @@ -181,34 +203,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 +240,20 @@ private fun ProgramInfoScreen( windowSize: WindowSize, uiState: ProgramUIState?, contentUrl: String, + cookieManager: AppCookieManager, uriScheme: String, + userAgent: 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 = {}, + onUriClick: (String, linkAuthority) -> Unit, ) { val scaffoldState = rememberScaffoldState() val configuration = LocalConfiguration.current - val isLoading = uiState is ProgramUIState.Loading + val coroutineScope = rememberCoroutineScope() when (uiState) { is ProgramUIState.UiMessage -> { @@ -247,7 +269,7 @@ private fun ProgramInfoScreen( .fillMaxSize() .semantics { testTagsAsResourceId = true }, backgroundColor = MaterialTheme.appColors.background - ) { + ) { paddingValues -> val modifierScreenWidth by remember(key1 = windowSize) { mutableStateOf( windowSize.windowSizeValue( @@ -261,21 +283,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 +314,45 @@ 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, + userAgent = userAgent, + 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 (isLoading && hasInternetConnection) { + + if (uiState is ProgramUIState.Error) { + FullScreenErrorView(errorType = uiState.errorType) { + onWebViewUIAction(WebViewUIAction.RELOAD_WEB_PAGE) + } + } + + if (uiState == ProgramUIState.Loading && hasInternetConnection) { Box( modifier = Modifier .fillMaxSize() @@ -339,12 +377,14 @@ fun MyProgramsPreview() { windowSize = WindowSize(WindowType.Compact, WindowType.Compact), uiState = ProgramUIState.Loading, contentUrl = "https://www.example.com/", + cookieManager = koinViewModel().cookieManager, uriScheme = "", + userAgent = "", 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..1f468a843 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramUIState.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramUIState.kt @@ -1,10 +1,12 @@ package org.openedx.discovery.presentation.program -import org.openedx.core.UIMessage +import org.openedx.core.presentation.global.ErrorType +import org.openedx.foundation.presentation.UIMessage 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..fd954df30 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,26 +2,28 @@ 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.AppData +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 appData: AppData, private val config: Config, private val networkConnection: NetworkConnection, private val router: DiscoveryRouter, @@ -34,14 +36,14 @@ 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() + val appUserAgent get() = appData.appUserAgent + + private val _uiState = MutableStateFlow(ProgramUIState.Loading) + val uiState: StateFlow get() = _uiState.asStateFlow() fun showLoading(isLoading: Boolean) { viewModelScope.launch { @@ -92,9 +94,11 @@ class ProgramViewModel( fm = fragmentManager, courseId = courseId, courseTitle = "", - enrollmentMode = "" ) } + viewModelScope.launch { + _uiState.emit(ProgramUIState.Loaded) + } } fun navigateToDiscovery() { @@ -105,7 +109,17 @@ 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..a38420a5e 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,25 @@ 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.search.CourseSearchFragment.Companion.LOAD_MORE_THRESHOLD 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() { @@ -101,7 +102,8 @@ class CourseSearchFragment : Fragment() { val uiState by viewModel.uiState.observeAsState( CourseSearchUIState.Courses( - emptyList(), 0 + emptyList(), + 0 ) ) val uiMessage by viewModel.uiMessage.observeAsState() @@ -118,6 +120,7 @@ class CourseSearchFragment : Fragment() { refreshing = refreshing, querySearch = querySearch, isUserLoggedIn = viewModel.isUserLoggedIn, + isRegistrationEnabled = viewModel.isRegistrationEnabled, onBackClick = { requireActivity().supportFragmentManager.popBackStack() }, @@ -149,6 +152,7 @@ class CourseSearchFragment : Fragment() { companion object { private const val ARG_SEARCH_QUERY = "query_search" + const val LOAD_MORE_THRESHOLD = 4 fun newInstance(querySearch: String): CourseSearchFragment { val fragment = CourseSearchFragment() fragment.arguments = bundleOf( @@ -159,7 +163,6 @@ class CourseSearchFragment : Fragment() { } } - @OptIn(ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class) @Composable private fun CourseSearchScreen( @@ -171,6 +174,7 @@ private fun CourseSearchScreen( refreshing: Boolean, querySearch: String, isUserLoggedIn: Boolean, + isRegistrationEnabled: Boolean, onBackClick: () -> Unit, onSearchTextChanged: (String) -> Unit, onSwipeRefresh: () -> Unit, @@ -203,7 +207,6 @@ private fun CourseSearchScreen( focusManager.clearFocus() } - Scaffold( scaffoldState = scaffoldState, modifier = Modifier @@ -222,13 +225,13 @@ private fun CourseSearchScreen( ) { AuthButtonsPanel( onRegisterClick = onRegisterClick, - onSignInClick = onSignInClick + onSignInClick = onSignInClick, + showRegisterButton = isRegistrationEnabled ) } } } ) { - val screenWidth by remember(key1 = windowSize) { mutableStateOf( windowSize.windowSizeValue( @@ -383,7 +386,8 @@ private fun CourseSearchScreen( windowSize = windowSize, onClick = { courseId -> onItemClick(courseId) - }) + } + ) Divider() } item { @@ -398,7 +402,7 @@ private fun CourseSearchScreen( } } } - if (scrollState.shouldLoadMore(firstVisibleIndex, 4)) { + if (scrollState.shouldLoadMore(firstVisibleIndex, LOAD_MORE_THRESHOLD)) { paginationCallback() } } @@ -433,6 +437,7 @@ fun CourseSearchScreenPreview() { refreshing = false, querySearch = "", isUserLoggedIn = true, + isRegistrationEnabled = true, onBackClick = {}, onSearchTextChanged = {}, onSwipeRefresh = {}, @@ -458,6 +463,7 @@ fun CourseSearchScreenTabletPreview() { refreshing = false, querySearch = "", isUserLoggedIn = false, + isRegistrationEnabled = true, onBackClick = {}, onSearchTextChanged = {}, onSwipeRefresh = {}, @@ -469,7 +475,6 @@ fun CourseSearchScreenTabletPreview() { } } - private val mockCourse = Course( id = "id", blocksUrl = "blocksUrl", 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..f001b46eb 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)) @@ -64,7 +65,7 @@ class CourseSearchViewModel( viewModelScope.launch { queryChannel .asSharedFlow() - .debounce(400) + .debounce(SEARCH_DEBOUNCE) .collect { nextPage = 1 currentQuery = it @@ -142,4 +143,7 @@ class CourseSearchViewModel( } } + companion object { + private const val SEARCH_DEBOUNCE = 400L + } } 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..eeb497f56 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,18 +39,17 @@ 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.core.R as CoreR - +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 сoreR @Composable fun ImageHeader( @@ -67,20 +66,15 @@ 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) - .error(CoreR.drawable.core_no_image_course) - .placeholder(CoreR.drawable.core_no_image_course) + .data(courseImage?.toImageLink(apiHostUrl)) + .error(сoreR.drawable.core_no_image_course) + .placeholder(сoreR.drawable.core_no_image_course) .build(), contentDescription = stringResource( - id = CoreR.string.core_accessibility_header_image_for, + id = сoreR.string.core_accessibility_header_image_for, courseName ), contentScale = contentScale, @@ -98,7 +92,6 @@ fun DiscoveryCourseItem( windowSize: WindowSize, onClick: (String) -> Unit, ) { - val imageWidth by remember(key1 = windowSize) { mutableStateOf( windowSize.windowSizeValue( @@ -108,7 +101,6 @@ fun DiscoveryCourseItem( ) } - val imageUrl = apiHostUrl.dropLast(1) + course.media.courseImage?.uri Surface( modifier = Modifier .testTag("btn_course_card") @@ -126,9 +118,9 @@ fun DiscoveryCourseItem( ) { AsyncImage( model = ImageRequest.Builder(LocalContext.current) - .data(imageUrl) - .error(org.openedx.core.R.drawable.core_no_image_course) - .placeholder(org.openedx.core.R.drawable.core_no_image_course) + .data(course.media.courseImage?.uri?.toImageLink(apiHostUrl) ?: "") + .error(сoreR.drawable.core_no_image_course) + .placeholder(сoreR.drawable.core_no_image_course) .build(), contentDescription = null, contentScale = ContentScale.Crop, @@ -146,7 +138,8 @@ fun DiscoveryCourseItem( modifier = Modifier .testTag("txt_course_org") .padding(top = 12.dp), - text = course.org, color = MaterialTheme.appColors.textFieldHint, + text = course.org, + color = MaterialTheme.appColors.textFieldHint, style = MaterialTheme.appTypography.labelMedium ) Text( @@ -223,7 +216,7 @@ fun WarningLabel( private fun WarningLabelPreview() { OpenEdXTheme { WarningLabel( - painter = painterResource(id = CoreR.drawable.core_ic_offline), + painter = painterResource(id = сoreR.drawable.core_ic_offline), text = stringResource(id = R.string.discovery_no_internet_label) ) } 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..d6270fe7b 100644 --- a/discovery/src/test/java/org/openedx/discovery/presentation/NativeDiscoveryViewModelTest.kt +++ b/discovery/src/test/java/org/openedx/discovery/presentation/NativeDiscoveryViewModelTest.kt @@ -5,10 +5,8 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk -import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain @@ -21,15 +19,14 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule import org.openedx.core.R -import org.openedx.core.UIMessage import org.openedx.core.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.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) @@ -38,7 +35,6 @@ class NativeDiscoveryViewModelTest { @get:Rule val testInstantTaskExecutorRule: TestRule = InstantTaskExecutorRule() - private val dispatcher = StandardTestDispatcher() private val config = mockk() @@ -46,7 +42,6 @@ class NativeDiscoveryViewModelTest { private val interactor = mockk() private val networkConnection = mockk() private val analytics = mockk() - private val appUpgradeNotifier = mockk() private val corePreferences = mockk() private val noInternet = "Slow or no internet connection" @@ -57,7 +52,6 @@ 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 { corePreferences.user } returns null every { config.getApiHostURL() } returns "http://localhost:8000" every { config.isPreLoginExperienceEnabled() } returns false @@ -76,7 +70,6 @@ class NativeDiscoveryViewModelTest { interactor, resourceManager, analytics, - appUpgradeNotifier, corePreferences ) every { networkConnection.isOnline() } returns true @@ -85,7 +78,6 @@ class NativeDiscoveryViewModelTest { coVerify(exactly = 1) { interactor.getCoursesList(any(), any(), any()) } coVerify(exactly = 0) { interactor.getCoursesListFromCache() } - verify(exactly = 1) { appUpgradeNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assertEquals(noInternet, message?.message) @@ -101,7 +93,6 @@ class NativeDiscoveryViewModelTest { interactor, resourceManager, analytics, - appUpgradeNotifier, corePreferences ) every { networkConnection.isOnline() } returns true @@ -125,7 +116,6 @@ class NativeDiscoveryViewModelTest { interactor, resourceManager, analytics, - appUpgradeNotifier, corePreferences ) every { networkConnection.isOnline() } returns false @@ -148,7 +138,6 @@ class NativeDiscoveryViewModelTest { interactor, resourceManager, analytics, - appUpgradeNotifier, corePreferences ) every { networkConnection.isOnline() } returns true @@ -158,7 +147,8 @@ class NativeDiscoveryViewModelTest { "2", 7, "1" - ), emptyList() + ), + emptyList() ) advanceUntilIdle() @@ -178,7 +168,6 @@ class NativeDiscoveryViewModelTest { interactor, resourceManager, analytics, - appUpgradeNotifier, corePreferences ) every { networkConnection.isOnline() } returns true @@ -188,7 +177,8 @@ class NativeDiscoveryViewModelTest { "", 7, "1" - ), emptyList() + ), + emptyList() ) advanceUntilIdle() @@ -200,7 +190,6 @@ class NativeDiscoveryViewModelTest { assert(viewModel.canLoadMore.value == false) } - @Test fun `updateData no internet connection`() = runTest { val viewModel = NativeDiscoveryViewModel( @@ -209,7 +198,6 @@ class NativeDiscoveryViewModelTest { interactor, resourceManager, analytics, - appUpgradeNotifier, corePreferences ) every { networkConnection.isOnline() } returns true @@ -234,7 +222,6 @@ class NativeDiscoveryViewModelTest { interactor, resourceManager, analytics, - appUpgradeNotifier, corePreferences ) every { networkConnection.isOnline() } returns true @@ -259,7 +246,6 @@ class NativeDiscoveryViewModelTest { interactor, resourceManager, analytics, - appUpgradeNotifier, corePreferences ) every { networkConnection.isOnline() } returns true @@ -269,7 +255,8 @@ class NativeDiscoveryViewModelTest { "2", 7, "1" - ), emptyList() + ), + emptyList() ) viewModel.updateData() advanceUntilIdle() @@ -290,7 +277,6 @@ class NativeDiscoveryViewModelTest { interactor, resourceManager, analytics, - appUpgradeNotifier, corePreferences ) every { networkConnection.isOnline() } returns true @@ -300,7 +286,8 @@ class NativeDiscoveryViewModelTest { "", 7, "1" - ), emptyList() + ), + emptyList() ) viewModel.updateData() advanceUntilIdle() @@ -312,5 +299,4 @@ class NativeDiscoveryViewModelTest { assert(viewModel.canLoadMore.value == false) assert(viewModel.uiState.value is DiscoveryUIState.Courses) } - } 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..13c1f3895 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 @@ -247,7 +256,6 @@ class CourseDetailsViewModelTest { ) } returns Unit - viewModel.enrollInACourse("", "") advanceUntilIdle() @@ -274,7 +282,8 @@ class CourseDetailsViewModelTest { interactor, resourceManager, notifier, - analytics + analytics, + calendarSyncScheduler, ) every { config.isPreLoginExperienceEnabled() } returns false every { preferencesManager.user } returns null @@ -295,7 +304,6 @@ class CourseDetailsViewModelTest { every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseDetails(any()) } returns mockCourse - delay(200) viewModel.enrollInACourse("", "") advanceUntilIdle() @@ -328,7 +336,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 +354,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..150d02e3e 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) @@ -38,7 +38,6 @@ class CourseSearchViewModelTest { @get:Rule val testInstantTaskExecutorRule: TestRule = InstantTaskExecutorRule() - private val dispatcher = UnconfinedTestDispatcher() private val config = mockk() @@ -148,7 +147,8 @@ class CourseSearchViewModelTest { "", 5, "" - ), emptyList() + ), + emptyList() ) every { analytics.discoveryCourseSearchEvent(any(), any()) } returns Unit @@ -174,7 +174,8 @@ class CourseSearchViewModelTest { "2", 5, "" - ), listOf(mockCourse, mockCourse) + ), + listOf(mockCourse, mockCourse) ) coEvery { interactor.getCoursesListByQuery( @@ -209,7 +210,8 @@ class CourseSearchViewModelTest { "2", 5, "" - ), listOf(mockCourse, mockCourse) + ), + listOf(mockCourse, mockCourse) ) coEvery { interactor.getCoursesListByQuery( @@ -245,7 +247,8 @@ class CourseSearchViewModelTest { "2", 5, "" - ), listOf(mockCourse, mockCourse) + ), + listOf(mockCourse, mockCourse) ) viewModel.updateSearchQuery() diff --git a/discussion/build.gradle b/discussion/build.gradle index 77d393d7a..213571427 100644 --- a/discussion/build.gradle +++ b/discussion/build.gradle @@ -2,15 +2,16 @@ plugins { id 'com.android.library' id 'org.jetbrains.kotlin.android' id 'kotlin-parcelize' + id "org.jetbrains.kotlin.plugin.compose" } android { namespace 'org.openedx.discussion' - compileSdk 34 + compileSdkVersion compile_sdk_version defaultConfig { - minSdk 24 - targetSdk 34 + minSdk min_sdk_version + targetSdk target_sdk_version testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles "consumer-rules.pro" @@ -18,25 +19,25 @@ android { buildTypes { release { - minifyEnabled true + minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 + sourceCompatibility java_version + targetCompatibility java_version } - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17 + kotlin { + compilerOptions { + jvmTarget = jvm_target_version + freeCompilerArgs = ['-XXLanguage:+PropertyParamAnnotationDefaultTargetMode'] + } } buildFeatures { viewBinding true compose true } - composeOptions { - kotlinCompilerExtensionVersion = "$compose_compiler_version" - } flavorDimensions += "env" productFlavors { @@ -55,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' 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" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlinx_coroutines_test_version" + androidTestImplementation "androidx.test.ext:junit:$test_ext_version" + androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_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/androidTest/java/org/openedx/discussion/ExampleInstrumentedTest.kt b/discussion/src/androidTest/java/org/openedx/discussion/ExampleInstrumentedTest.kt index ef1311ede..733b313ce 100644 --- a/discussion/src/androidTest/java/org/openedx/discussion/ExampleInstrumentedTest.kt +++ b/discussion/src/androidTest/java/org/openedx/discussion/ExampleInstrumentedTest.kt @@ -1,13 +1,11 @@ package org.openedx.discussion -import androidx.test.platform.app.InstrumentationRegistry import androidx.test.ext.junit.runners.AndroidJUnit4 - +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith -import org.junit.Assert.* - /** * Instrumented test, which will execute on an Android device. * @@ -21,4 +19,4 @@ class ExampleInstrumentedTest { val appContext = InstrumentationRegistry.getInstrumentation().targetContext assertEquals("org.openedx.discussion.test", appContext.packageName) } -} \ No newline at end of file +} 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..4f1eee74a 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,12 +1,24 @@ package org.openedx.discussion.data.api import org.openedx.core.data.model.BlocksCompletionBody -import org.openedx.discussion.data.model.request.* +import org.openedx.discussion.data.model.request.CommentBody +import org.openedx.discussion.data.model.request.FollowBody +import org.openedx.discussion.data.model.request.ReadBody +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.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.* +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.Headers +import retrofit2.http.PATCH +import retrofit2.http.POST +import retrofit2.http.Path +import retrofit2.http.Query interface DiscussionApi { @@ -26,6 +38,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 +61,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, @@ -54,28 +80,28 @@ interface DiscussionApi { suspend fun setThreadRead( @Path("thread_id") threadId: String, @Body body: ReadBody - ): ThreadsResponse.Thread + ): Thread @Headers("Cache-Control: no-cache", "Content-type: application/merge-patch+json") @PATCH("/api/discussion/v1/threads/{thread_id}/") suspend fun setThreadVoted( @Path("thread_id") threadId: String, @Body body: VoteBody - ): ThreadsResponse.Thread + ): Thread @Headers("Cache-Control: no-cache", "Content-type: application/merge-patch+json") @PATCH("/api/discussion/v1/threads/{thread_id}/") suspend fun setThreadFlagged( @Path("thread_id") threadId: String, @Body reportBody: ReportBody - ): ThreadsResponse.Thread + ): Thread @Headers("Cache-Control: no-cache", "Content-type: application/merge-patch+json") @PATCH("/api/discussion/v1/threads/{thread_id}/") suspend fun setThreadFollowed( @Path("thread_id") threadId: String, @Body followBody: FollowBody - ): ThreadsResponse.Thread + ): Thread @Headers("Cache-Control: no-cache", "Content-type: application/merge-patch+json") @PATCH("/api/discussion/v1/comments/{comment_id}/") @@ -101,15 +127,14 @@ interface DiscussionApi { @POST("/api/discussion/v1/comments/") suspend fun createComment( @Body commentBody: CommentBody - ) : CommentResult + ): CommentResult @POST("/api/discussion/v1/threads/") - suspend fun createThread(@Body threadBody: ThreadBody) : ThreadsResponse.Thread + suspend fun createThread(@Body threadBody: ThreadBody): Thread @POST("/api/completion/v1/completion-batch") suspend fun markBlocksCompletion( @Body blocksCompletionBody: BlocksCompletionBody ) - -} \ No newline at end of file +} diff --git a/discussion/src/main/java/org/openedx/discussion/data/model/request/FollowBody.kt b/discussion/src/main/java/org/openedx/discussion/data/model/request/FollowBody.kt index eec64c7b3..f83d7289b 100644 --- a/discussion/src/main/java/org/openedx/discussion/data/model/request/FollowBody.kt +++ b/discussion/src/main/java/org/openedx/discussion/data/model/request/FollowBody.kt @@ -5,4 +5,4 @@ import com.google.gson.annotations.SerializedName data class FollowBody( @SerializedName("following") val following: Boolean -) \ No newline at end of file +) diff --git a/discussion/src/main/java/org/openedx/discussion/data/model/request/ReadBody.kt b/discussion/src/main/java/org/openedx/discussion/data/model/request/ReadBody.kt index 054d11f9f..012165559 100644 --- a/discussion/src/main/java/org/openedx/discussion/data/model/request/ReadBody.kt +++ b/discussion/src/main/java/org/openedx/discussion/data/model/request/ReadBody.kt @@ -5,4 +5,4 @@ import com.google.gson.annotations.SerializedName data class ReadBody( @SerializedName("read") val read: Boolean -) \ No newline at end of file +) diff --git a/discussion/src/main/java/org/openedx/discussion/data/model/request/ReportBody.kt b/discussion/src/main/java/org/openedx/discussion/data/model/request/ReportBody.kt index 05b556ce8..bae817a41 100644 --- a/discussion/src/main/java/org/openedx/discussion/data/model/request/ReportBody.kt +++ b/discussion/src/main/java/org/openedx/discussion/data/model/request/ReportBody.kt @@ -5,4 +5,4 @@ import com.google.gson.annotations.SerializedName data class ReportBody( @SerializedName("abuse_flagged") val abuseFlagged: Boolean -) \ No newline at end of file +) diff --git a/discussion/src/main/java/org/openedx/discussion/data/model/request/ThreadBody.kt b/discussion/src/main/java/org/openedx/discussion/data/model/request/ThreadBody.kt index d9b8d9cff..2712c0d55 100644 --- a/discussion/src/main/java/org/openedx/discussion/data/model/request/ThreadBody.kt +++ b/discussion/src/main/java/org/openedx/discussion/data/model/request/ThreadBody.kt @@ -15,4 +15,4 @@ data class ThreadBody( val rawBody: String, @SerializedName("following") val following: Boolean = true -) \ No newline at end of file +) diff --git a/discussion/src/main/java/org/openedx/discussion/data/model/request/VoteBody.kt b/discussion/src/main/java/org/openedx/discussion/data/model/request/VoteBody.kt index cb93889de..cfa71cbbd 100644 --- a/discussion/src/main/java/org/openedx/discussion/data/model/request/VoteBody.kt +++ b/discussion/src/main/java/org/openedx/discussion/data/model/request/VoteBody.kt @@ -5,4 +5,4 @@ import com.google.gson.annotations.SerializedName data class VoteBody( @SerializedName("voted") val voted: Boolean -) \ No newline at end of file +) diff --git a/discussion/src/main/java/org/openedx/discussion/data/model/response/CommentsResponse.kt b/discussion/src/main/java/org/openedx/discussion/data/model/response/CommentsResponse.kt index a2248b036..08bf03a0f 100644 --- a/discussion/src/main/java/org/openedx/discussion/data/model/response/CommentsResponse.kt +++ b/discussion/src/main/java/org/openedx/discussion/data/model/response/CommentsResponse.kt @@ -3,7 +3,6 @@ package org.openedx.discussion.data.model.response import com.google.gson.annotations.SerializedName import org.openedx.core.data.model.Pagination import org.openedx.core.data.model.ProfileImage -import org.openedx.core.extension.TextConverter import org.openedx.discussion.domain.model.CommentsData import org.openedx.discussion.domain.model.DiscussionComment @@ -78,7 +77,6 @@ data class CommentResult( updatedAt, rawBody, renderedBody, - TextConverter.textToLinkedImageText(renderedBody), abuseFlagged, voted, voteCount, @@ -96,4 +94,4 @@ data class CommentResult( users?.entries?.associate { it.key to it.value.mapToDomain() } ) } -} \ No newline at end of file +} diff --git a/discussion/src/main/java/org/openedx/discussion/data/model/response/ThreadsResponse.kt b/discussion/src/main/java/org/openedx/discussion/data/model/response/ThreadsResponse.kt index 2aea8cd43..c8f56ff8e 100644 --- a/discussion/src/main/java/org/openedx/discussion/data/model/response/ThreadsResponse.kt +++ b/discussion/src/main/java/org/openedx/discussion/data/model/response/ThreadsResponse.kt @@ -3,7 +3,6 @@ package org.openedx.discussion.data.model.response import com.google.gson.annotations.SerializedName import org.openedx.core.data.model.Pagination import org.openedx.core.data.model.ProfileImage -import org.openedx.core.extension.TextConverter import org.openedx.discussion.domain.model.DiscussionType import org.openedx.discussion.domain.model.ThreadsData @@ -104,7 +103,6 @@ data class ThreadsResponse( updatedAt, rawBody, renderedBody, - TextConverter.textToLinkedImageText(renderedBody), abuseFlagged, voted, voteCount, @@ -132,17 +130,19 @@ data class ThreadsResponse( ) } - fun serverTypeToLocalType(): DiscussionType { + private fun serverTypeToLocalType(): DiscussionType { val actualType = if (type.contains("-")) { type.replace("-", "_") - } else type + } else { + type + } return try { DiscussionType.valueOf(actualType.uppercase()) } catch (e: Exception) { - throw IllegalStateException("Unknown thread type") + e.printStackTrace() + error("Unknown thread type") } } - } fun mapToDomain(): ThreadsData { @@ -152,6 +152,4 @@ data class ThreadsResponse( pagination.mapToDomain() ) } - } - diff --git a/discussion/src/main/java/org/openedx/discussion/data/model/response/TopicsResponse.kt b/discussion/src/main/java/org/openedx/discussion/data/model/response/TopicsResponse.kt index b1e751755..a8cd6cd3b 100644 --- a/discussion/src/main/java/org/openedx/discussion/data/model/response/TopicsResponse.kt +++ b/discussion/src/main/java/org/openedx/discussion/data/model/response/TopicsResponse.kt @@ -36,4 +36,4 @@ data class TopicsResponse( nonCoursewareTopics = nonCoursewareTopics?.map { it.mapToDomain() } ?: emptyList() ) } -} \ No newline at end of file +} diff --git a/discussion/src/main/java/org/openedx/discussion/data/repository/DiscussionRepository.kt b/discussion/src/main/java/org/openedx/discussion/data/repository/DiscussionRepository.kt index 4ca6cde8d..6e8143a36 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, @@ -122,7 +137,6 @@ class DiscussionRepository( ) = api.createComment(CommentBody(threadId, rawBody, parentId)).mapToDomain() - suspend fun createThread( topicId: String, courseId: String, @@ -141,5 +155,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..3a267c1cb 100644 --- a/discussion/src/main/java/org/openedx/discussion/domain/interactor/DiscussionInteractor.kt +++ b/discussion/src/main/java/org/openedx/discussion/domain/interactor/DiscussionInteractor.kt @@ -1,6 +1,7 @@ package org.openedx.discussion.domain.interactor import org.openedx.discussion.data.repository.DiscussionRepository +import org.openedx.discussion.domain.model.DiscussionComment class DiscussionInteractor( private val repository: DiscussionRepository @@ -31,12 +32,18 @@ class DiscussionInteractor( ) = repository.getCourseThreads(courseId, null, topicId, orderBy, view, page) + suspend fun getThread(threadId: String, courseId: String, topicId: String) = + repository.getCourseThread(threadId, courseId, topicId) + suspend fun searchThread(courseId: String, query: String, page: Int) = repository.searchThread(courseId, query, page) suspend fun getThreadComments(threadId: String, page: Int) = repository.getThreadComments(threadId, page) + suspend fun getResponse(responseId: String): DiscussionComment = + repository.getResponse(responseId) + suspend fun getThreadQuestionComments(threadId: String, endorsed: Boolean, page: Int) = repository.getThreadQuestionComments(threadId, endorsed, page) @@ -87,5 +94,6 @@ class DiscussionInteractor( follow: Boolean ) = repository.createThread(topicId, courseId, type, title, rawBody, follow) - suspend fun markBlocksCompletion(courseId: String, blocksId: List) = repository.markBlocksCompletion(courseId, blocksId) -} \ No newline at end of file + suspend fun markBlocksCompletion(courseId: String, blocksId: List) = + repository.markBlocksCompletion(courseId, blocksId) +} diff --git a/discussion/src/main/java/org/openedx/discussion/domain/model/CommentsData.kt b/discussion/src/main/java/org/openedx/discussion/domain/model/CommentsData.kt index 01937f16e..f033770e0 100644 --- a/discussion/src/main/java/org/openedx/discussion/domain/model/CommentsData.kt +++ b/discussion/src/main/java/org/openedx/discussion/domain/model/CommentsData.kt @@ -5,4 +5,4 @@ import org.openedx.core.domain.model.Pagination data class CommentsData( val results: List, val pagination: Pagination -) \ No newline at end of file +) diff --git a/discussion/src/main/java/org/openedx/discussion/domain/model/DiscussionComment.kt b/discussion/src/main/java/org/openedx/discussion/domain/model/DiscussionComment.kt index 9f71a0617..6ffcc3d64 100644 --- a/discussion/src/main/java/org/openedx/discussion/domain/model/DiscussionComment.kt +++ b/discussion/src/main/java/org/openedx/discussion/domain/model/DiscussionComment.kt @@ -1,9 +1,8 @@ package org.openedx.discussion.domain.model import android.os.Parcelable -import org.openedx.core.domain.model.ProfileImage -import org.openedx.core.extension.LinkedImageText import kotlinx.parcelize.Parcelize +import org.openedx.core.domain.model.ProfileImage @Parcelize data class DiscussionComment( @@ -14,7 +13,6 @@ data class DiscussionComment( val updatedAt: String, val rawBody: String, val renderedBody: String, - val parsedRenderedBody: LinkedImageText, val abuseFlagged: Boolean, val voted: Boolean, val voteCount: Int, @@ -30,4 +28,4 @@ data class DiscussionComment( val children: List, val profileImage: ProfileImage?, val users: Map? -) : Parcelable \ No newline at end of file +) : Parcelable diff --git a/discussion/src/main/java/org/openedx/discussion/domain/model/Thread.kt b/discussion/src/main/java/org/openedx/discussion/domain/model/Thread.kt index 8d2572788..c87cbc368 100644 --- a/discussion/src/main/java/org/openedx/discussion/domain/model/Thread.kt +++ b/discussion/src/main/java/org/openedx/discussion/domain/model/Thread.kt @@ -1,10 +1,9 @@ package org.openedx.discussion.domain.model import android.os.Parcelable +import kotlinx.parcelize.Parcelize import org.openedx.core.domain.model.ProfileImage -import org.openedx.core.extension.LinkedImageText import org.openedx.discussion.R -import kotlinx.parcelize.Parcelize @Parcelize data class Thread( @@ -15,7 +14,6 @@ data class Thread( val updatedAt: String, val rawBody: String, val renderedBody: String, - val parsedRenderedBody: LinkedImageText, val abuseFlagged: Boolean, val voted: Boolean, val voteCount: Int, diff --git a/discussion/src/main/java/org/openedx/discussion/domain/model/ThreadsData.kt b/discussion/src/main/java/org/openedx/discussion/domain/model/ThreadsData.kt index 2575d1cf6..92f8d8cc6 100644 --- a/discussion/src/main/java/org/openedx/discussion/domain/model/ThreadsData.kt +++ b/discussion/src/main/java/org/openedx/discussion/domain/model/ThreadsData.kt @@ -7,6 +7,3 @@ data class ThreadsData( val textSearchRewrite: String, val pagination: Pagination ) - - - diff --git a/discussion/src/main/java/org/openedx/discussion/domain/model/TopicsData.kt b/discussion/src/main/java/org/openedx/discussion/domain/model/TopicsData.kt index 12d9ad926..72a8e5489 100644 --- a/discussion/src/main/java/org/openedx/discussion/domain/model/TopicsData.kt +++ b/discussion/src/main/java/org/openedx/discussion/domain/model/TopicsData.kt @@ -1,6 +1,5 @@ package org.openedx.discussion.domain.model - data class TopicsData( val coursewareTopics: List, val nonCoursewareTopics: List @@ -11,4 +10,4 @@ data class Topic( val name: String, val threadListUrl: String, val children: List -) \ No newline at end of file +) diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/DiscussionRouter.kt b/discussion/src/main/java/org/openedx/discussion/presentation/DiscussionRouter.kt index 54f519004..481049907 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/DiscussionRouter.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/DiscussionRouter.kt @@ -41,4 +41,4 @@ interface DiscussionRouter { fm: FragmentManager, username: String ) -} \ No newline at end of file +} 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..5bbee6ff9 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,25 +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.comments.DiscussionCommentsFragment.Companion.LOAD_MORE_THRESHOLD 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() { @@ -86,7 +120,6 @@ class DiscussionCommentsFragment : Fragment() { val uiState by viewModel.uiState.observeAsState(DiscussionCommentsUIState.Loading) val uiMessage by viewModel.uiMessage.observeAsState() val canLoadMore by viewModel.canLoadMore.observeAsState(false) - val scrollToBottom by viewModel.scrollToBottom.observeAsState(false) val refreshing by viewModel.isUpdating.observeAsState(false) DiscussionCommentsScreen( @@ -95,7 +128,6 @@ class DiscussionCommentsFragment : Fragment() { uiMessage = uiMessage, title = viewModel.title, canLoadMore = canLoadMore, - scrollToBottom = scrollToBottom, refreshing = refreshing, onSwipeRefresh = { viewModel.updateThreadComments() @@ -121,12 +153,15 @@ class DiscussionCommentsFragment : Fragment() { }, onCommentClick = { router.navigateToDiscussionResponses( - requireActivity().supportFragmentManager, it, viewModel.thread.closed + requireActivity().supportFragmentManager, + it, + viewModel.thread.closed ) }, onUserPhotoClick = { username -> router.navigateToAnothersProfile( - requireActivity().supportFragmentManager, username + requireActivity().supportFragmentManager, + username ) }, onAddResponseClick = { @@ -146,6 +181,7 @@ class DiscussionCommentsFragment : Fragment() { const val ACTION_UPVOTE_THREAD = "action_upvote_thread" const val ACTION_REPORT_THREAD = "action_report_thread" const val ACTION_FOLLOW_THREAD = "action_follow_thread" + const val LOAD_MORE_THRESHOLD = 4 private const val ARG_THREAD = "argThread" @@ -157,7 +193,6 @@ class DiscussionCommentsFragment : Fragment() { return fragment } } - } @OptIn(ExperimentalMaterialApi::class) @@ -168,7 +203,6 @@ private fun DiscussionCommentsScreen( uiMessage: UIMessage?, title: String, canLoadMore: Boolean, - scrollToBottom: Boolean, refreshing: Boolean, onSwipeRefresh: () -> Unit, paginationCallBack: () -> Unit, @@ -314,7 +348,7 @@ private fun DiscussionCommentsScreen( .padding(horizontal = paddingContent) .padding(top = 24.dp, bottom = 4.dp), text = pluralStringResource( - id = org.openedx.discussion.R.plurals.discussion_responses_capitalized, + id = R.plurals.discussion_responses_capitalized, uiState.count, uiState.count ), @@ -340,7 +374,8 @@ private fun DiscussionCommentsScreen( }, onUserPhotoClick = { onUserPhotoClick(comment.author) - }) + } + ) } item { if (canLoadMore) { @@ -353,7 +388,7 @@ private fun DiscussionCommentsScreen( } } } - if (scrollState.shouldLoadMore(firstVisibleIndex, 4)) { + if (scrollState.shouldLoadMore(firstVisibleIndex, LOAD_MORE_THRESHOLD)) { paginationCallBack() } if (!isSystemInDarkTheme()) { @@ -418,7 +453,9 @@ private fun DiscussionCommentsScreen( Icon( modifier = Modifier.padding(7.dp), painter = painterResource(id = R.drawable.discussion_ic_send), - contentDescription = stringResource(id = R.string.discussion_add_response), + contentDescription = stringResource( + id = R.string.discussion_add_response + ), tint = iconButtonColor ) } @@ -429,8 +466,9 @@ private fun DiscussionCommentsScreen( is DiscussionCommentsUIState.Loading -> { Box( - Modifier - .fillMaxSize(), contentAlignment = Alignment.Center + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center ) { CircularProgressIndicator(color = MaterialTheme.appColors.primary) } @@ -447,14 +485,13 @@ private fun DiscussionCommentsScreen( } } - @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 DiscussionCommentsScreenPreview() { - OpenEdXTheme() { + OpenEdXTheme { DiscussionCommentsScreen( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), uiState = DiscussionCommentsUIState.Success( @@ -466,13 +503,10 @@ private fun DiscussionCommentsScreenPreview() { title = "Test Screen", canLoadMore = false, paginationCallBack = {}, - onItemClick = { _, _, _ -> - - }, + onItemClick = { _, _, _ -> }, onCommentClick = {}, onAddResponseClick = {}, onBackClick = {}, - scrollToBottom = false, refreshing = false, onSwipeRefresh = {}, onUserPhotoClick = {} @@ -480,12 +514,11 @@ private fun DiscussionCommentsScreenPreview() { } } - @Preview(name = "NEXUS_9_Light", device = Devices.NEXUS_9, uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(name = "NEXUS_9_Dark", device = Devices.NEXUS_9, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun DiscussionCommentsScreenTabletPreview() { - OpenEdXTheme() { + OpenEdXTheme { DiscussionCommentsScreen( windowSize = WindowSize(WindowType.Medium, WindowType.Medium), uiState = DiscussionCommentsUIState.Success( @@ -497,13 +530,10 @@ private fun DiscussionCommentsScreenTabletPreview() { title = "Test Screen", canLoadMore = false, paginationCallBack = {}, - onItemClick = { _, _, _ -> - - }, + onItemClick = { _, _, _ -> }, onCommentClick = {}, onAddResponseClick = {}, onBackClick = {}, - scrollToBottom = false, refreshing = false, onSwipeRefresh = {}, onUserPhotoClick = {} @@ -519,7 +549,6 @@ private val mockThread = org.openedx.discussion.domain.model.Thread( "", "", "", - TextConverter.textToLinkedImageText(""), false, true, 20, @@ -554,7 +583,6 @@ private val mockComment = DiscussionComment( "", "", "", - TextConverter.textToLinkedImageText(""), false, true, 20, @@ -570,4 +598,4 @@ private val mockComment = DiscussionComment( emptyList(), profileImage = ProfileImage("", "", "", "", false), mapOf() -) \ No newline at end of file +) diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/comments/DiscussionCommentsUIState.kt b/discussion/src/main/java/org/openedx/discussion/presentation/comments/DiscussionCommentsUIState.kt index 389e8b44c..f3fe81a12 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/comments/DiscussionCommentsUIState.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/comments/DiscussionCommentsUIState.kt @@ -2,7 +2,6 @@ package org.openedx.discussion.presentation.comments import org.openedx.discussion.domain.model.DiscussionComment - sealed class DiscussionCommentsUIState { data class Success( val thread: org.openedx.discussion.domain.model.Thread, @@ -10,5 +9,5 @@ sealed class DiscussionCommentsUIState { val count: Int ) : DiscussionCommentsUIState() - object Loading : DiscussionCommentsUIState() -} \ No newline at end of file + data object Loading : DiscussionCommentsUIState() +} 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..fbd5b464e 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, @@ -48,10 +48,6 @@ class DiscussionCommentsViewModel( val isUpdating: LiveData get() = _isUpdating - private val _scrollToBottom = MutableLiveData() - val scrollToBottom: LiveData - get() = _scrollToBottom - private val comments = mutableListOf() private var page = 1 private var isLoading = false @@ -68,10 +64,11 @@ class DiscussionCommentsViewModel( comments.toList(), commentCount ) - _scrollToBottom.value = true } else { _uiMessage.value = - UIMessage.ToastMessage(resourceManager.getString(org.openedx.discussion.R.string.discussion_comment_added)) + UIMessage.ToastMessage( + resourceManager.getString(org.openedx.discussion.R.string.discussion_comment_added) + ) } thread = thread.copy(commentCount = thread.commentCount + 1) sendThreadUpdated() @@ -290,7 +287,9 @@ class DiscussionCommentsViewModel( comments.add(response) } else { _uiMessage.value = - UIMessage.ToastMessage(resourceManager.getString(org.openedx.discussion.R.string.discussion_comment_added)) + UIMessage.ToastMessage( + resourceManager.getString(org.openedx.discussion.R.string.discussion_comment_added) + ) } _uiState.value = DiscussionCommentsUIState.Success(thread, comments.toList(), commentCount) @@ -305,5 +304,4 @@ class DiscussionCommentsViewModel( } } } - -} \ No newline at end of file +} 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..863cc89ef 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,24 +9,55 @@ 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.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.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.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.ViewCompositionStrategy @@ -41,22 +72,31 @@ 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.R 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.responses.DiscussionResponsesFragment.Companion.LOAD_MORE_THRESHOLD 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() { @@ -111,6 +151,7 @@ class DiscussionResponsesFragment : Fragment() { ) } } + DiscussionCommentsFragment.ACTION_REPORT_COMMENT -> { viewModel.setCommentReported( id, @@ -127,7 +168,8 @@ class DiscussionResponsesFragment : Fragment() { }, onUserPhotoClick = { username -> router.navigateToAnothersProfile( - requireActivity().supportFragmentManager, username + requireActivity().supportFragmentManager, + username ) } ) @@ -138,6 +180,7 @@ class DiscussionResponsesFragment : Fragment() { companion object { private const val ARG_COMMENT = "comment" private const val ARG_IS_CLOSED = "isClosed" + const val LOAD_MORE_THRESHOLD = 4 fun newInstance( comment: DiscussionComment, @@ -175,8 +218,9 @@ private fun DiscussionResponsesScreen( val focusManager = LocalFocusManager.current val firstVisibleIndex = remember { - mutableStateOf(scrollState.firstVisibleItemIndex) + mutableIntStateOf(scrollState.firstVisibleItemIndex) } + val isShouldLoadMore = scrollState.shouldLoadMore(firstVisibleIndex, LOAD_MORE_THRESHOLD) val pullRefreshState = rememberPullRefreshState(refreshing = refreshing, onRefresh = { onSwipeRefresh() }) @@ -202,7 +246,6 @@ private fun DiscussionResponsesScreen( .navigationBarsPadding(), backgroundColor = MaterialTheme.appColors.background ) { - val screenWidth by remember(key1 = windowSize) { mutableStateOf( windowSize.windowSizeValue( @@ -308,7 +351,7 @@ private fun DiscussionResponsesScreen( bool ) }, - onUserPhotoClick = {username -> + onUserPhotoClick = { username -> onUserPhotoClick(username) } ) @@ -321,7 +364,7 @@ private fun DiscussionResponsesScreen( .padding(horizontal = paddingContent) .padding(top = 24.dp, bottom = 8.dp), text = pluralStringResource( - id = org.openedx.discussion.R.plurals.discussion_comments, + id = R.plurals.discussion_comments, uiState.mainComment.childCount, uiState.mainComment.childCount ), @@ -333,28 +376,36 @@ private fun DiscussionResponsesScreen( } items(uiState.childComments) { comment -> + var itemHeight by remember { mutableIntStateOf(0) } + val boxHeight = if (itemHeight > 0) { + Modifier.height(with(LocalDensity.current) { itemHeight.toDp() }) + } else { + Modifier + } Row( Modifier .fillMaxWidth() - .height(IntrinsicSize.Min) .padding(start = paddingContent), verticalAlignment = Alignment.CenterVertically ) { Box( modifier = Modifier - .fillMaxHeight() .width(1.dp) + .then(boxHeight) .background(MaterialTheme.appColors.cardViewBorder) ) CommentMainItem( modifier = Modifier .padding(4.dp) - .fillMaxWidth(), + .fillMaxWidth() + .onGloballyPositioned { coordinates -> + itemHeight = coordinates.size.height + }, comment = comment, onClick = { action, commentId, bool -> onItemClick(action, commentId, bool) }, - onUserPhotoClick = {username -> + onUserPhotoClick = { username -> onUserPhotoClick(username) } ) @@ -371,7 +422,7 @@ private fun DiscussionResponsesScreen( } } } - if (scrollState.shouldLoadMore(firstVisibleIndex, 4)) { + if (isShouldLoadMore) { paginationCallBack() } } @@ -407,7 +458,9 @@ private fun DiscussionResponsesScreen( shape = MaterialTheme.appShapes.buttonShape, placeholder = { Text( - text = stringResource(id = org.openedx.discussion.R.string.discussion_add_comment), + text = stringResource( + id = R.string.discussion_add_comment + ), color = MaterialTheme.appColors.textFieldHint, style = MaterialTheme.appTypography.labelLarge, ) @@ -436,7 +489,9 @@ private fun DiscussionResponsesScreen( ) { Icon( modifier = Modifier.padding(7.dp), - painter = painterResource(id = org.openedx.discussion.R.drawable.discussion_ic_send), + painter = painterResource( + id = R.drawable.discussion_ic_send + ), contentDescription = null, tint = iconButtonColor ) @@ -445,10 +500,12 @@ private fun DiscussionResponsesScreen( } } } + is DiscussionResponsesUIState.Loading -> { Box( - Modifier - .fillMaxSize(), contentAlignment = Alignment.Center + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center ) { CircularProgressIndicator(color = MaterialTheme.appColors.primary) } @@ -471,11 +528,12 @@ private fun DiscussionResponsesScreen( @Preview(name = "NEXUS_5_Dark", device = Devices.NEXUS_5, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun DiscussionResponsesScreenPreview() { - OpenEdXTheme() { + OpenEdXTheme { DiscussionResponsesScreen( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), uiState = DiscussionResponsesUIState.Success( - mockComment, listOf( + mockComment, + listOf( mockComment, mockComment ) @@ -485,12 +543,8 @@ private fun DiscussionResponsesScreenPreview() { refreshing = false, onSwipeRefresh = {}, paginationCallBack = { }, - onItemClick = { _, _, _ -> - - }, - addCommentClick = { - - }, + onItemClick = { _, _, _ -> }, + addCommentClick = {}, onBackClick = {}, isClosed = false, onUserPhotoClick = {} @@ -502,11 +556,12 @@ private fun DiscussionResponsesScreenPreview() { @Preview(name = "NEXUS_9_Dark", device = Devices.NEXUS_9, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun DiscussionResponsesScreenTabletPreview() { - OpenEdXTheme() { + OpenEdXTheme { DiscussionResponsesScreen( windowSize = WindowSize(WindowType.Medium, WindowType.Medium), uiState = DiscussionResponsesUIState.Success( - mockComment, listOf( + mockComment, + listOf( mockComment, mockComment ) @@ -516,12 +571,8 @@ private fun DiscussionResponsesScreenTabletPreview() { refreshing = false, onSwipeRefresh = {}, paginationCallBack = { }, - onItemClick = { _, _, _ -> - - }, - addCommentClick = { - - }, + onItemClick = { _, _, _ -> }, + addCommentClick = {}, onBackClick = {}, isClosed = false, onUserPhotoClick = {} @@ -537,7 +588,6 @@ private val mockComment = DiscussionComment( "", "", "", - TextConverter.textToLinkedImageText(""), false, true, 20, @@ -554,6 +604,3 @@ private val mockComment = DiscussionComment( ProfileImage("", "", "", "", false), mapOf() ) - - - diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/responses/DiscussionResponsesUIState.kt b/discussion/src/main/java/org/openedx/discussion/presentation/responses/DiscussionResponsesUIState.kt index 95f216988..dce4cf147 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/responses/DiscussionResponsesUIState.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/responses/DiscussionResponsesUIState.kt @@ -9,4 +9,4 @@ sealed class DiscussionResponsesUIState { ) : DiscussionResponsesUIState() object Loading : DiscussionResponsesUIState() -} \ No newline at end of file +} 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..e4c675609 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, @@ -87,10 +87,14 @@ class DiscussionResponsesViewModel( } catch (e: Exception) { if (e.isInternetError()) { _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) + UIMessage.SnackBarMessage( + resourceManager.getString(R.string.core_error_no_connection) + ) } else { _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) + UIMessage.SnackBarMessage( + resourceManager.getString(R.string.core_error_unknown_error) + ) } } finally { isLoading = false @@ -117,10 +121,14 @@ class DiscussionResponsesViewModel( } catch (e: Exception) { if (e.isInternetError()) { _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) + UIMessage.SnackBarMessage( + resourceManager.getString(R.string.core_error_no_connection) + ) } else { _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) + UIMessage.SnackBarMessage( + resourceManager.getString(R.string.core_error_unknown_error) + ) } } } @@ -143,10 +151,14 @@ class DiscussionResponsesViewModel( } catch (e: Exception) { if (e.isInternetError()) { _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) + UIMessage.SnackBarMessage( + resourceManager.getString(R.string.core_error_no_connection) + ) } else { _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) + UIMessage.SnackBarMessage( + resourceManager.getString(R.string.core_error_unknown_error) + ) } } } @@ -162,20 +174,25 @@ class DiscussionResponsesViewModel( comments.add(response) } else { _uiMessage.value = - UIMessage.ToastMessage(resourceManager.getString(org.openedx.discussion.R.string.discussion_comment_added)) + UIMessage.ToastMessage( + resourceManager.getString(org.openedx.discussion.R.string.discussion_comment_added) + ) } _uiState.value = DiscussionResponsesUIState.Success(comment, comments.toList()) } catch (e: Exception) { if (e.isInternetError()) { _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) + UIMessage.SnackBarMessage( + resourceManager.getString(R.string.core_error_no_connection) + ) } else { _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) + UIMessage.SnackBarMessage( + resourceManager.getString(R.string.core_error_unknown_error) + ) } } } } - -} \ No newline at end of file +} 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..e67fe40b3 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.search.DiscussionSearchThreadFragment.Companion.LOAD_MORE_THRESHOLD 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() { @@ -75,7 +104,8 @@ class DiscussionSearchThreadFragment : Fragment() { val uiState by viewModel.uiState.observeAsState( DiscussionSearchThreadUIState.Threads( - emptyList(), 0 + emptyList(), + 0 ) ) val uiMessage by viewModel.uiMessage.observeAsState() @@ -105,9 +135,9 @@ class DiscussionSearchThreadFragment : Fragment() { } } - companion object { private const val ARG_COURSE_ID = "courseId" + const val LOAD_MORE_THRESHOLD = 4 fun newInstance( courseId: String ): DiscussionSearchThreadFragment { @@ -118,10 +148,9 @@ class DiscussionSearchThreadFragment : Fragment() { return fragment } } - } -@OptIn(ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class) +@OptIn(ExperimentalMaterialApi::class) @Composable private fun DiscussionSearchThreadScreen( windowSize: WindowSize, @@ -160,7 +189,6 @@ private fun DiscussionSearchThreadScreen( .navigationBarsPadding(), backgroundColor = MaterialTheme.appColors.background ) { - val screenWidth by remember(key1 = windowSize) { mutableStateOf( windowSize.windowSizeValue( @@ -319,7 +347,7 @@ private fun DiscussionSearchThreadScreen( } } } - if (scrollState.shouldLoadMore(firstVisibleIndex, 4)) { + if (scrollState.shouldLoadMore(firstVisibleIndex, LOAD_MORE_THRESHOLD)) { paginationCallback() } } @@ -337,7 +365,6 @@ private fun DiscussionSearchThreadScreen( } } - @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable @@ -353,9 +380,8 @@ fun DiscussionSearchThreadScreenPreview() { onSearchTextChanged = {}, onSwipeRefresh = {}, paginationCallback = {}, - onBackClick = { - - }) + onBackClick = {} + ) } } @@ -374,9 +400,8 @@ fun DiscussionSearchThreadScreenTabletPreview() { onSearchTextChanged = {}, onSwipeRefresh = {}, paginationCallback = {}, - onBackClick = { - - }) + onBackClick = {} + ) } } @@ -388,7 +413,6 @@ private val mockThread = org.openedx.discussion.domain.model.Thread( "", "", "", - TextConverter.textToLinkedImageText(""), false, true, 20, @@ -413,4 +437,4 @@ private val mockThread = org.openedx.discussion.domain.model.Thread( 10, false, false -) \ No newline at end of file +) diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadUIState.kt b/discussion/src/main/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadUIState.kt index b3f75f9e3..f134bce82 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadUIState.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadUIState.kt @@ -4,5 +4,5 @@ sealed class DiscussionSearchThreadUIState { class Threads(val data: List, val count: Int) : DiscussionSearchThreadUIState() - object Loading : DiscussionSearchThreadUIState() -} \ No newline at end of file + data object Loading : DiscussionSearchThreadUIState() +} 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..d95dcba9e 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, @@ -34,7 +34,8 @@ class DiscussionSearchThreadViewModel( private val _uiState = MutableLiveData( DiscussionSearchThreadUIState.Threads( - emptyList(), 0 + emptyList(), + 0 ) ) val uiState: LiveData @@ -52,7 +53,6 @@ class DiscussionSearchThreadViewModel( val isUpdating: LiveData get() = _isUpdating - private var nextPage: Int? = 1 private var currentQuery: String? = null private val threadsList = mutableListOf() @@ -90,7 +90,7 @@ class DiscussionSearchThreadViewModel( viewModelScope.launch { queryChannel .asSharedFlow() - .debounce(400) + .debounce(SEARCH_DEBOUNCE) .collect { query -> nextPage = 1 threadsList.clear() @@ -168,4 +168,7 @@ class DiscussionSearchThreadViewModel( .launchIn(viewModelScope) } -} \ No newline at end of file + companion object { + private const val SEARCH_DEBOUNCE = 400L + } +} 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..bda4e3730 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 @@ -26,7 +26,6 @@ import androidx.compose.foundation.verticalScroll 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.MaterialTheme import androidx.compose.material.ModalBottomSheetLayout @@ -73,25 +72,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() { @@ -136,7 +135,6 @@ class DiscussionAddThreadFragment : Fragment() { if (success != null) { viewModel.sendThreadAdded() requireActivity().supportFragmentManager.popBackStack() - } } } @@ -160,8 +158,6 @@ class DiscussionAddThreadFragment : Fragment() { } } - -@OptIn(ExperimentalMaterialApi::class) @Composable private fun DiscussionAddThreadScreen( windowSize: WindowSize, @@ -219,7 +215,6 @@ private fun DiscussionAddThreadScreen( .navigationBarsPadding(), backgroundColor = MaterialTheme.appColors.background ) { - val screenWidth by remember(key1 = windowSize) { mutableStateOf( windowSize.windowSizeValue( @@ -278,7 +273,6 @@ private fun DiscussionAddThreadScreen( ) } ) { - HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) Box( @@ -345,10 +339,12 @@ private fun DiscussionAddThreadScreen( color = MaterialTheme.appColors.textPrimary ) Spacer(Modifier.height(16.dp)) - Tabs(tabs = listOf( - stringResource(id = discussionR.string.discussion_discussion), - stringResource(id = discussionR.string.discussion_question) - ), currentPage = currentPage, + Tabs( + tabs = listOf( + stringResource(id = discussionR.string.discussion_discussion), + stringResource(id = discussionR.string.discussion_question) + ), + currentPage = currentPage, onItemClick = { bool -> if (bool) { discussionType = DiscussionType.QUESTION.value @@ -357,7 +353,8 @@ private fun DiscussionAddThreadScreen( discussionType = DiscussionType.DISCUSSION.value currentPage = 0 } - }) + } + ) Spacer(Modifier.height(24.dp)) SelectableField( text = postToTopic.first, @@ -369,7 +366,8 @@ private fun DiscussionAddThreadScreen( bottomSheetScaffoldState.show() } } - }) + } + ) Spacer(Modifier.height(24.dp)) OpenEdXOutlinedTextField( modifier = Modifier @@ -390,12 +388,16 @@ private fun DiscussionAddThreadScreen( modifier = Modifier .fillMaxWidth() .height(150.dp), - title = if (currentPage == 0) stringResource(id = org.openedx.discussion.R.string.discussion_discussion) else stringResource( - id = discussionR.string.discussion_question - ), + title = if (currentPage == 0) { + stringResource(id = discussionR.string.discussion_discussion) + } else { + stringResource( + id = discussionR.string.discussion_question + ) + }, isSingleLine = false, withRequiredMark = true, - imeAction = ImeAction.Done, + imeAction = ImeAction.Default, keyboardActions = { focusManager -> focusManager.clearFocus() keyboardController?.hide() @@ -418,7 +420,8 @@ private fun DiscussionAddThreadScreen( checked = followPost, onCheckedChange = { followPost = it - }) + } + ) Spacer(Modifier.width(6.dp)) Text( text = if (currentPage == 0) { @@ -473,13 +476,18 @@ private fun Tabs( isLimited: Boolean = false, ) { val isFirstPage = currentPage == 0 - TabRow(selectedTabIndex = currentPage, + TabRow( + selectedTabIndex = currentPage, backgroundColor = MaterialTheme.appColors.surface, modifier = Modifier .fillMaxWidth() .padding(vertical = 4.dp) - .clip(RoundedCornerShape(20)) - .border(1.dp, MaterialTheme.appColors.cardViewBorder, RoundedCornerShape(20)), + .clip(RoundedCornerShape(percent = 20)) + .border( + 1.dp, + MaterialTheme.appColors.cardViewBorder, + RoundedCornerShape(percent = 20) + ), indicator = { _ -> Box {} } @@ -492,16 +500,19 @@ private fun Tabs( MaterialTheme.appColors.textPrimaryVariant } Tab( - modifier = if (selected) Modifier - .clip(RoundedCornerShape(20)) - .background( - MaterialTheme.appColors.primary - ) - else Modifier - .clip(RoundedCornerShape(20)) - .background( - MaterialTheme.appColors.surface - ), + modifier = if (selected) { + Modifier + .clip(RoundedCornerShape(percent = 20)) + .background( + MaterialTheme.appColors.primary + ) + } else { + Modifier + .clip(RoundedCornerShape(percent = 20)) + .background( + MaterialTheme.appColors.surface + ) + }, selected = selected, onClick = { if (!isLimited && !selected) { @@ -519,7 +530,7 @@ private fun SelectableField( text: String, onClick: () -> Unit, ) { - Column() { + Column { Text( modifier = Modifier.fillMaxWidth(), text = stringResource(id = discussionR.string.discussion_topic), @@ -557,23 +568,19 @@ private fun SelectableField( } } - @Preview(uiMode = UI_MODE_NIGHT_NO) @Preview(uiMode = UI_MODE_NIGHT_YES) @Composable private fun DiscussionAddThreadScreenPreview() { - OpenEdXTheme() { + OpenEdXTheme { DiscussionAddThreadScreen( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), topicData = Pair("", "General"), topics = emptyList(), uiMessage = null, isLoading = false, - onBackClick = { - }, - onPostDiscussionClick = { _, _, _, _, _ -> - - } + onBackClick = {}, + onPostDiscussionClick = { _, _, _, _, _ -> } ) } } @@ -582,18 +589,15 @@ private fun DiscussionAddThreadScreenPreview() { @Preview(uiMode = UI_MODE_NIGHT_YES, device = Devices.NEXUS_9) @Composable private fun DiscussionAddThreadScreenTabletPreview() { - OpenEdXTheme() { + OpenEdXTheme { DiscussionAddThreadScreen( windowSize = WindowSize(WindowType.Medium, WindowType.Medium), topicData = Pair("", "General"), topics = emptyList(), uiMessage = null, isLoading = false, - onBackClick = { - }, - onPostDiscussionClick = { _, _, _, _, _ -> - - } + onBackClick = {}, + onPostDiscussionClick = { _, _, _, _, _ -> } ) } -} \ No newline at end of file +} 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..b16b9f300 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, @@ -65,9 +65,8 @@ class DiscussionAddThreadViewModel( } fun getHandledTopicById(topicId: String): Pair { - return getHandledTopics().find{ - it.second == topicId - } ?: getHandledTopics()[0] + val topics = getHandledTopics() + return topics.find { it.second == topicId } ?: topics.firstOrNull() ?: Pair("", "") } fun sendThreadAdded() { @@ -75,5 +74,4 @@ class DiscussionAddThreadViewModel( notifier.send(DiscussionThreadAdded()) } } - -} \ No newline at end of file +} 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..f610dfa9d 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.threads.DiscussionThreadsFragment.Companion.LOAD_MORE_THRESHOLD 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() { @@ -139,6 +185,7 @@ class DiscussionThreadsFragment : Fragment() { private const val ARG_TOPIC_ID = "topicId" private const val ARG_TITLE = "title" private const val ARG_FRAGMENT_VIEW_TYPE = "fragmentViewType" + const val LOAD_MORE_THRESHOLD = 4 fun newInstance( threadType: String, @@ -162,6 +209,7 @@ class DiscussionThreadsFragment : Fragment() { } } +@Suppress("MaximumLineLength", "MaxLineLength") @OptIn(ExperimentalMaterialApi::class) @Composable private fun DiscussionThreadsScreen( @@ -180,7 +228,6 @@ private fun DiscussionThreadsScreen( paginationCallback: () -> Unit, onBackClick: () -> Unit ) { - val scaffoldState = rememberScaffoldState() val bottomSheetScaffoldState = rememberModalBottomSheetState( initialValue = ModalBottomSheetValue.Hidden, @@ -253,7 +300,6 @@ private fun DiscussionThreadsScreen( modifier = scaffoldModifier, backgroundColor = MaterialTheme.appColors.background ) { - val contentWidth by remember(key1 = windowSize) { mutableStateOf( windowSize.windowSizeValue( @@ -367,7 +413,13 @@ private fun DiscussionThreadsScreen( } } Surface( - modifier = Modifier.padding(top = if (viewType == FragmentViewType.FULL_CONTENT) 6.dp else 0.dp), + modifier = Modifier.padding( + top = if (viewType == FragmentViewType.FULL_CONTENT) { + 6.dp + } else { + 0.dp + } + ), color = MaterialTheme.appColors.background ) { Box(Modifier.pullRefresh(pullRefreshState)) { @@ -391,9 +443,11 @@ private fun DiscussionThreadsScreen( ) { IconText( text = filterType.first, - painter = painterResource(id = discussionR.drawable.discussion_ic_filter), + 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( @@ -421,9 +475,11 @@ private fun DiscussionThreadsScreen( ) IconText( text = sortType.first, - painter = painterResource(id = discussionR.drawable.discussion_ic_sort), + 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 +531,9 @@ private fun DiscussionThreadsScreen( Modifier .size(40.dp) .clip(CircleShape) - .background(MaterialTheme.appColors.primary) + .background( + MaterialTheme.appColors.secondaryButtonBackground + ) .clickable { onCreatePostClick() }, @@ -483,9 +541,13 @@ private fun DiscussionThreadsScreen( ) { Icon( 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 + painter = painterResource( + discussionR.drawable.discussion_ic_add_comment + ), + contentDescription = stringResource( + discussionR.string.discussion_add_comment + ), + tint = MaterialTheme.appColors.primaryButtonText ) } } @@ -504,13 +566,15 @@ private fun DiscussionThreadsScreen( .padding(vertical = 16.dp), contentAlignment = Alignment.Center ) { - CircularProgressIndicator(color = MaterialTheme.appColors.primary) + CircularProgressIndicator( + color = MaterialTheme.appColors.primary + ) } } } if (scrollState.shouldLoadMore( firstVisibleIndex, - 4 + LOAD_MORE_THRESHOLD ) ) { paginationCallback() @@ -536,7 +600,9 @@ private fun DiscussionThreadsScreen( Spacer(modifier = Modifier.height(20.dp)) Icon( modifier = Modifier.size(100.dp), - painter = painterResource(id = discussionR.drawable.discussion_ic_empty), + painter = painterResource( + id = discussionR.drawable.discussion_ic_empty + ), contentDescription = null, tint = MaterialTheme.appColors.textPrimary ) @@ -551,7 +617,9 @@ private fun DiscussionThreadsScreen( Spacer(Modifier.height(12.dp)) Text( modifier = Modifier.fillMaxWidth(), - text = stringResource(discussionR.string.discussion_click_button_create_discussion), + text = stringResource( + discussionR.string.discussion_click_button_create_discussion + ), style = MaterialTheme.appTypography.bodyLarge, color = MaterialTheme.appColors.textPrimary, textAlign = TextAlign.Center @@ -560,19 +628,25 @@ private fun DiscussionThreadsScreen( OpenEdXOutlinedButton( modifier = Modifier .widthIn(184.dp, Dp.Unspecified), - text = stringResource(id = discussionR.string.discussion_create_post), + text = stringResource( + id = discussionR.string.discussion_create_post + ), onClick = { onCreatePostClick() }, content = { Icon( - painter = painterResource(id = discussionR.drawable.discussion_ic_add_comment), + painter = painterResource( + id = discussionR.drawable.discussion_ic_add_comment + ), contentDescription = null, tint = MaterialTheme.appColors.primary ) Spacer(modifier = Modifier.width(6.dp)) Text( - text = stringResource(id = discussionR.string.discussion_create_post), + text = stringResource( + id = discussionR.string.discussion_create_post + ), color = MaterialTheme.appColors.primary, style = MaterialTheme.appTypography.labelLarge ) @@ -589,7 +663,8 @@ private fun DiscussionThreadsScreen( is DiscussionThreadsUIState.Loading -> { Box( Modifier - .fillMaxSize(), contentAlignment = Alignment.Center + .fillMaxSize(), + contentAlignment = Alignment.Center ) { CircularProgressIndicator(color = MaterialTheme.appColors.primary) } @@ -666,7 +741,6 @@ private val mockThread = org.openedx.discussion.domain.model.Thread( "", "", "", - TextConverter.textToLinkedImageText(""), false, true, 20, @@ -691,4 +765,4 @@ private val mockThread = org.openedx.discussion.domain.model.Thread( 10, false, false -) \ No newline at end of file +) diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsUIState.kt b/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsUIState.kt index 94106edc0..8587f2697 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsUIState.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsUIState.kt @@ -5,4 +5,4 @@ sealed class DiscussionThreadsUIState { DiscussionThreadsUIState() object Loading : DiscussionThreadsUIState() -} \ No newline at end of file +} diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsViewModel.kt b/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsViewModel.kt index 2dbbd9af6..e79c7672b 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, @@ -245,4 +245,4 @@ class DiscussionThreadsViewModel( } } } -} \ No newline at end of file +} diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/threads/FilterType.kt b/discussion/src/main/java/org/openedx/discussion/presentation/threads/FilterType.kt index 377717f45..afab6a45a 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/threads/FilterType.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/threads/FilterType.kt @@ -13,4 +13,4 @@ enum class FilterType( companion object { const val type = "filter_type" } -} \ No newline at end of file +} diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/threads/SortType.kt b/discussion/src/main/java/org/openedx/discussion/presentation/threads/SortType.kt index 1aeac0c33..6c0211b6a 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/threads/SortType.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/threads/SortType.kt @@ -13,4 +13,4 @@ enum class SortType( companion object { const val type = "sort_type" } -} \ No newline at end of file +} 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..1f4876eb4 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 ) { @@ -109,7 +110,6 @@ private fun DiscussionTopicsUI( modifier = Modifier.fillMaxSize(), backgroundColor = MaterialTheme.appColors.background ) { - val screenWidth by remember(key1 = windowSize) { mutableStateOf( windowSize.windowSizeValue( @@ -157,15 +157,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 +190,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 +201,10 @@ 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 +212,13 @@ 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,9 +226,10 @@ private fun DiscussionTopicsUI( onItemClick( DiscussionTopicsViewModel.FOLLOWING_POSTS, "", - context.getString(discussionR.string.discussion_posts_following) + context.getString(R.string.discussion_posts_following) ) - }) + } + ) } } itemsIndexed(uiState.data) { index, topic -> @@ -253,6 +259,9 @@ private fun DiscussionTopicsUI( } DiscussionTopicsUIState.Loading -> {} + else -> { + NoContentScreen(noContentScreenType = NoContentScreenType.COURSE_DISCUSSIONS) + } } } } @@ -279,6 +288,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 @@ -299,4 +325,4 @@ private val mockTopic = Topic( name = "All Topics", threadListUrl = "", children = emptyList() -) \ No newline at end of file +) 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..c85a8bcad 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 @@ -2,8 +2,8 @@ package org.openedx.discussion.presentation.topics 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..84a5d3e15 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,27 @@ 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))) + _uiMessage.emit( + UIMessage.SnackBarMessage( + resourceManager.getString(R.string.core_error_no_connection) + ) + ) } } finally { courseNotifier.send(CourseLoading(false)) @@ -64,15 +70,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 +87,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() } } } @@ -102,4 +98,4 @@ class DiscussionTopicsViewModel( const val ALL_POSTS = "All posts" const val FOLLOWING_POSTS = "Following" } -} \ No newline at end of file +} 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..1a544e40a 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 @@ -1,5 +1,3 @@ -@file:OptIn(ExperimentalComposeUiApi::class) - package org.openedx.discussion.presentation.ui import android.content.res.Configuration.UI_MODE_NIGHT_NO @@ -27,11 +25,10 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.automirrored.outlined.HelpOutline -import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.runtime.Composable 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.Shape @@ -48,10 +45,9 @@ import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import coil.request.ImageRequest import org.openedx.core.domain.model.ProfileImage -import org.openedx.core.extension.TextConverter import org.openedx.core.ui.AutoSizeText -import org.openedx.core.ui.HyperlinkImageText import org.openedx.core.ui.IconText +import org.openedx.core.ui.RenderHtmlContent import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes @@ -147,18 +143,23 @@ fun ThreadMainItem( } IconText( text = stringResource(id = R.string.discussion_follow), - painter = painterResource(if (thread.following) R.drawable.discussion_star_filled else R.drawable.discussion_star), + painter = painterResource( + if (thread.following) { + R.drawable.discussion_star_filled + } else { + R.drawable.discussion_star + } + ), textStyle = MaterialTheme.appTypography.labelLarge, color = MaterialTheme.appColors.textPrimaryVariant, onClick = { onClick(DiscussionCommentsFragment.ACTION_FOLLOW_THREAD, !thread.following) - }) + } + ) } Spacer(modifier = Modifier.height(24.dp)) - HyperlinkImageText( - title = thread.title, - imageText = thread.parsedRenderedBody, - linkTextColor = MaterialTheme.appColors.primary + RenderHtmlContent( + html = thread.rawBody, ) Spacer(modifier = Modifier.height(24.dp)) Row( @@ -193,7 +194,6 @@ fun ThreadMainItem( Spacer(modifier = Modifier.height(16.dp)) Divider(color = MaterialTheme.appColors.cardViewBorder) } - } @Composable @@ -306,12 +306,12 @@ fun CommentItem( comment.id, !comment.abuseFlagged ) - }) + } + ) } Spacer(modifier = Modifier.height(14.dp)) - HyperlinkImageText( - imageText = comment.parsedRenderedBody, - linkTextColor = MaterialTheme.appColors.primary + RenderHtmlContent( + html = comment.rawBody, ) Spacer(modifier = Modifier.height(16.dp)) Row( @@ -352,12 +352,10 @@ fun CommentItem( } ) } - } } } - @Composable fun CommentMainItem( modifier: Modifier, @@ -450,9 +448,8 @@ fun CommentMainItem( } } Spacer(modifier = Modifier.height(14.dp)) - HyperlinkImageText( - imageText = comment.parsedRenderedBody, - linkTextColor = MaterialTheme.appColors.primary + RenderHtmlContent( + html = comment.rawBody, ) Spacer(modifier = Modifier.height(16.dp)) Row( @@ -489,9 +486,9 @@ fun CommentMainItem( comment.id, !comment.abuseFlagged ) - }) + } + ) } - } } } @@ -539,13 +536,13 @@ fun ThreadItem( ) { Box { Icon( - modifier = Modifier.size((MaterialTheme.appTypography.labelSmall.fontSize.value + 4).dp), + modifier = Modifier.size((MaterialTheme.appTypography.labelLarge.fontSize.value).dp), painter = painterResource(id = R.drawable.discussion_ic_unread_replies), tint = MaterialTheme.appColors.textPrimaryVariant, contentDescription = null ) Image( - modifier = Modifier.size((MaterialTheme.appTypography.labelSmall.fontSize.value + 4).dp), + modifier = Modifier.size((MaterialTheme.appTypography.labelLarge.fontSize.value).dp), painter = painterResource(id = R.drawable.discussion_ic_unread_replies_dot), contentDescription = null ) @@ -587,14 +584,12 @@ 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 ) } - } - @Composable fun ThreadItemCategory( name: String, @@ -611,7 +606,8 @@ fun ThreadItemCategory( MaterialTheme.appShapes.cardShape ) .clip(MaterialTheme.appShapes.cardShape) - .clickable { onClick() }), + .clickable { onClick() } + ), shape = MaterialTheme.appShapes.cardShape, backgroundColor = MaterialTheme.appColors.surface ) { @@ -652,26 +648,26 @@ fun TopicItem( horizontalArrangement = Arrangement.SpaceBetween ) { Text( - text = topic.name, style = MaterialTheme.appTypography.titleMedium, + text = topic.name, + style = MaterialTheme.appTypography.titleMedium, color = MaterialTheme.appColors.textPrimary ) Icon( - imageVector = Icons.Filled.ChevronRight, + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, tint = MaterialTheme.appColors.primary, contentDescription = "Expandable Arrow" ) } - } @Preview @Composable private fun TopicItemPreview() { - OpenEdXTheme() { - TopicItem(topic = mockTopic, - onClick = { _, _ -> - - }) + OpenEdXTheme { + TopicItem( + topic = mockTopic, + onClick = { _, _ -> } + ) } } @@ -679,17 +675,18 @@ private fun TopicItemPreview() { @Preview(uiMode = UI_MODE_NIGHT_YES) @Composable private fun ThreadItemPreview() { - OpenEdXTheme() { + OpenEdXTheme { ThreadItem( thread = mockThread, - onClick = {}) + onClick = {} + ) } } @Preview @Composable private fun CommentItemPreview() { - OpenEdXTheme() { + OpenEdXTheme { CommentItem( modifier = Modifier.fillMaxWidth(), comment = mockComment, @@ -718,7 +715,6 @@ private val mockComment = DiscussionComment( "", "", "", - TextConverter.textToLinkedImageText(""), false, true, 20, @@ -744,7 +740,6 @@ private val mockThread = org.openedx.discussion.domain.model.Thread( "", "", "", - TextConverter.textToLinkedImageText(""), false, true, 20, diff --git a/discussion/src/main/java/org/openedx/discussion/system/notifier/DiscussionCommentAdded.kt b/discussion/src/main/java/org/openedx/discussion/system/notifier/DiscussionCommentAdded.kt index d90f329a8..8cdfb6649 100644 --- a/discussion/src/main/java/org/openedx/discussion/system/notifier/DiscussionCommentAdded.kt +++ b/discussion/src/main/java/org/openedx/discussion/system/notifier/DiscussionCommentAdded.kt @@ -2,7 +2,6 @@ package org.openedx.discussion.system.notifier import org.openedx.discussion.domain.model.DiscussionComment - data class DiscussionCommentAdded( val comment: DiscussionComment -) : DiscussionEvent \ No newline at end of file +) : DiscussionEvent diff --git a/discussion/src/main/java/org/openedx/discussion/system/notifier/DiscussionCommentDataChanged.kt b/discussion/src/main/java/org/openedx/discussion/system/notifier/DiscussionCommentDataChanged.kt index 04d46aa1e..a727e3afc 100644 --- a/discussion/src/main/java/org/openedx/discussion/system/notifier/DiscussionCommentDataChanged.kt +++ b/discussion/src/main/java/org/openedx/discussion/system/notifier/DiscussionCommentDataChanged.kt @@ -2,4 +2,4 @@ package org.openedx.discussion.system.notifier import org.openedx.discussion.domain.model.DiscussionComment -class DiscussionCommentDataChanged(val discussionComment: DiscussionComment) : DiscussionEvent \ No newline at end of file +class DiscussionCommentDataChanged(val discussionComment: DiscussionComment) : DiscussionEvent diff --git a/discussion/src/main/java/org/openedx/discussion/system/notifier/DiscussionEvent.kt b/discussion/src/main/java/org/openedx/discussion/system/notifier/DiscussionEvent.kt index abd2c1891..dcf442ffa 100644 --- a/discussion/src/main/java/org/openedx/discussion/system/notifier/DiscussionEvent.kt +++ b/discussion/src/main/java/org/openedx/discussion/system/notifier/DiscussionEvent.kt @@ -1,4 +1,3 @@ package org.openedx.discussion.system.notifier -interface DiscussionEvent { -} \ No newline at end of file +interface DiscussionEvent diff --git a/discussion/src/main/java/org/openedx/discussion/system/notifier/DiscussionNotifier.kt b/discussion/src/main/java/org/openedx/discussion/system/notifier/DiscussionNotifier.kt index 2d1d01206..23569fc27 100644 --- a/discussion/src/main/java/org/openedx/discussion/system/notifier/DiscussionNotifier.kt +++ b/discussion/src/main/java/org/openedx/discussion/system/notifier/DiscussionNotifier.kt @@ -15,5 +15,4 @@ class DiscussionNotifier { suspend fun send(event: DiscussionCommentDataChanged) = channel.emit(event) suspend fun send(event: DiscussionThreadDataChanged) = channel.emit(event) suspend fun send(event: DiscussionThreadAdded) = channel.emit(event) - -} \ No newline at end of file +} diff --git a/discussion/src/main/java/org/openedx/discussion/system/notifier/DiscussionThreadAdded.kt b/discussion/src/main/java/org/openedx/discussion/system/notifier/DiscussionThreadAdded.kt index 2b2f23525..e6db4b8ec 100644 --- a/discussion/src/main/java/org/openedx/discussion/system/notifier/DiscussionThreadAdded.kt +++ b/discussion/src/main/java/org/openedx/discussion/system/notifier/DiscussionThreadAdded.kt @@ -1,3 +1,3 @@ package org.openedx.discussion.system.notifier -class DiscussionThreadAdded : DiscussionEvent \ No newline at end of file +class DiscussionThreadAdded : DiscussionEvent diff --git a/discussion/src/main/java/org/openedx/discussion/system/notifier/DiscussionThreadDataChanged.kt b/discussion/src/main/java/org/openedx/discussion/system/notifier/DiscussionThreadDataChanged.kt index 752814095..feb71317e 100644 --- a/discussion/src/main/java/org/openedx/discussion/system/notifier/DiscussionThreadDataChanged.kt +++ b/discussion/src/main/java/org/openedx/discussion/system/notifier/DiscussionThreadDataChanged.kt @@ -2,4 +2,4 @@ package org.openedx.discussion.system.notifier class DiscussionThreadDataChanged( val thread: org.openedx.discussion.domain.model.Thread -) : DiscussionEvent \ No newline at end of file +) : DiscussionEvent 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..02bd2bcba 100644 --- a/discussion/src/main/res/values/strings.xml +++ b/discussion/src/main/res/values/strings.xml @@ -1,10 +1,8 @@ - Discussions All Posts Unread Unanswered Posts I\'m following - Refine: Recent activity Most activity Most votes @@ -23,9 +21,6 @@ Title Follow this discussion Follow this question - Post discussion - Post question - General Search all posts Main categories Select post type @@ -39,56 +34,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..f3a9704f5 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,27 +4,42 @@ 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.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 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.* -import org.junit.* +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.data.storage.CorePreferences +import org.openedx.core.domain.model.Pagination +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.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 +@Suppress("LargeClass") @OptIn(ExperimentalCoroutinesApi::class) class DiscussionCommentsViewModelTest { @@ -52,7 +67,6 @@ class DiscussionCommentsViewModelTest { "", "", "", - TextConverter.textToLinkedImageText(""), false, true, 20, @@ -91,7 +105,6 @@ class DiscussionCommentsViewModelTest { "", "", "", - TextConverter.textToLinkedImageText(""), false, true, 20, @@ -109,11 +122,11 @@ class DiscussionCommentsViewModelTest { mapOf() ) - //endregion - + // endregion private val comments = listOf( - mockComment.copy(id = "0"), mockComment.copy(id = "1") + mockComment.copy(id = "0"), + mockComment.copy(id = "1") ) @Before @@ -121,7 +134,9 @@ class DiscussionCommentsViewModelTest { 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 { resourceManager.getString(org.openedx.discussion.R.string.discussion_comment_added) } returns commentAddedSuccessfully + every { + resourceManager.getString(org.openedx.discussion.R.string.discussion_comment_added) + } returns commentAddedSuccessfully } @After @@ -268,7 +283,6 @@ class DiscussionCommentsViewModelTest { coVerify(exactly = 0) { interactor.getThreadQuestionComments(any(), any(), any()) } coVerify(exactly = 1) { interactor.setThreadRead(any()) } - assert(viewModel.uiMessage.value == null) assert(viewModel.uiState.value is DiscussionCommentsUIState.Success) assert(viewModel.isUpdating.value == false) @@ -291,7 +305,6 @@ class DiscussionCommentsViewModelTest { mockThread ) - coEvery { interactor.getThreadQuestionComments(any(), any(), any()) } returns CommentsData( comments, Pagination(10, "", 4, "1") @@ -359,7 +372,6 @@ class DiscussionCommentsViewModelTest { mockThread ) - coEvery { interactor.setThreadVoted(any(), any()) } throws UnknownHostException() viewModel.setThreadUpvoted(true) @@ -446,7 +458,6 @@ class DiscussionCommentsViewModelTest { mockThread ) - coEvery { interactor.setCommentFlagged(any(), any()) } throws UnknownHostException() viewModel.setCommentReported("", true) @@ -476,7 +487,6 @@ class DiscussionCommentsViewModelTest { mockThread ) - coEvery { interactor.setCommentFlagged(any(), any()) } throws Exception() viewModel.setCommentReported("", true) @@ -516,7 +526,6 @@ class DiscussionCommentsViewModelTest { assert(viewModel.uiState.value is DiscussionCommentsUIState.Success) } - @Test fun `setCommentUpvoted no internet connection exception`() = runTest { coEvery { interactor.getThreadComments(any(), any()) } returns CommentsData( @@ -683,7 +692,6 @@ class DiscussionCommentsViewModelTest { assert(viewModel.uiState.value is DiscussionCommentsUIState.Success) } - @Test fun `setThreadFollowed no internet connection exception`() = runTest { coEvery { interactor.getThreadComments(any(), any()) } returns CommentsData( @@ -728,7 +736,6 @@ class DiscussionCommentsViewModelTest { mockThread ) - coEvery { interactor.setThreadFollowed(any(), any()) } throws Exception() viewModel.setThreadFollowed(true) @@ -799,10 +806,8 @@ class DiscussionCommentsViewModelTest { assert(viewModel.uiMessage.value == null) assert(viewModel.uiState.value is DiscussionCommentsUIState.Success) - assert(viewModel.scrollToBottom.value == true) } - @Test fun `DiscussionCommentAdded notifier test all comments not loaded`() = runTest { coEvery { interactor.getThreadComments(any(), any()) } returns CommentsData( @@ -835,7 +840,6 @@ class DiscussionCommentsViewModelTest { val message = viewModel.uiMessage.value as? UIMessage.ToastMessage assert(commentAddedSuccessfully == message?.message) assert(viewModel.uiState.value is DiscussionCommentsUIState.Success) - assert(viewModel.scrollToBottom.value == null) } @Test @@ -895,7 +899,6 @@ class DiscussionCommentsViewModelTest { val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage Assert.assertEquals(noInternet, message?.message) - } @Test @@ -922,7 +925,6 @@ class DiscussionCommentsViewModelTest { val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage Assert.assertEquals(somethingWrong, message?.message) - } @Test @@ -972,7 +974,6 @@ class DiscussionCommentsViewModelTest { viewModel.createComment("") advanceUntilIdle() - } @Test @@ -1022,5 +1023,4 @@ class DiscussionCommentsViewModelTest { assert(viewModel.uiState.value is DiscussionCommentsUIState.Success) } - -} \ No newline at end of file +} 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..bb3579eda 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,33 @@ 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) @@ -38,45 +47,6 @@ class DiscussionResponsesViewModelTest { private val somethingWrong = "Something went wrong" private val commentAddedSuccessfully = "Comment Successfully added" - //region mockThread - - val mockThread = org.openedx.discussion.domain.model.Thread( - "", - "", - "", - "", - "", - "", - "", - LinkedImageText("", emptyMap(), emptyMap(), emptyList()), - false, - true, - 20, - emptyList(), - false, - "", - "", - "", - "", - DiscussionType.DISCUSSION, - "", - "", - "Discussion title long Discussion title long good item", - true, - false, - true, - 21, - 4, - false, - false, - mapOf(), - 0, - false, - false - ) - - //endregion - //region mockComment private val mockComment = DiscussionComment( @@ -87,7 +57,6 @@ class DiscussionResponsesViewModelTest { "", "", "", - LinkedImageText("", emptyMap(), emptyMap(), emptyList()), false, true, 20, @@ -107,9 +76,9 @@ class DiscussionResponsesViewModelTest { //endregion - private val comments = listOf( - mockComment.copy(id = "0"), mockComment.copy(id = "1") + mockComment.copy(id = "0"), + mockComment.copy(id = "1") ) @Before @@ -117,7 +86,9 @@ class DiscussionResponsesViewModelTest { 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 { resourceManager.getString(org.openedx.discussion.R.string.discussion_comment_added) } returns commentAddedSuccessfully + every { + resourceManager.getString(org.openedx.discussion.R.string.discussion_comment_added) + } returns commentAddedSuccessfully } @After @@ -463,7 +434,6 @@ class DiscussionResponsesViewModelTest { val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage Assert.assertEquals(noInternet, message?.message) - } @Test @@ -487,7 +457,6 @@ class DiscussionResponsesViewModelTest { val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage Assert.assertEquals(somethingWrong, message?.message) - } @Test @@ -509,7 +478,6 @@ class DiscussionResponsesViewModelTest { coVerify(exactly = 1) { interactor.createComment(any(), any(), any()) } - assert(viewModel.uiMessage.value != null) assert(viewModel.uiState.value is DiscussionResponsesUIState.Success) } @@ -555,5 +523,4 @@ class DiscussionResponsesViewModelTest { assert(viewModel.uiState.value is DiscussionResponsesUIState.Success) } - -} \ No newline at end of file +} 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..14eb3f062 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,25 @@ 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.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) @@ -56,7 +58,6 @@ class DiscussionSearchThreadViewModelTest { "", "", "", - TextConverter.textToLinkedImageText(""), false, true, 20, @@ -284,7 +285,6 @@ class DiscussionSearchThreadViewModelTest { assert((viewModel.uiState.value as DiscussionSearchThreadUIState.Threads).data.isEmpty()) } - @Test fun `notifier DiscussionThreadDataChanged with list`() = runTest { val viewModel = DiscussionSearchThreadViewModel(interactor, resourceManager, notifier, "") @@ -318,5 +318,4 @@ class DiscussionSearchThreadViewModelTest { assert(viewModel.uiState.value is DiscussionSearchThreadUIState.Threads) assert((viewModel.uiState.value as DiscussionSearchThreadUIState.Threads).data.size == 1) } - -} \ No newline at end of file +} 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..65b4a1ae8 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,28 +1,31 @@ package org.openedx.discussion.presentation.threads import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import org.openedx.core.R -import org.openedx.core.UIMessage -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 -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 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.* +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.data.storage.CorePreferences +import org.openedx.core.R +import org.openedx.discussion.domain.interactor.DiscussionInteractor +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 org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException @OptIn(ExperimentalCoroutinesApi::class) @@ -35,15 +38,12 @@ class DiscussionAddThreadViewModelTest { private val resourceManager = mockk() private val interactor = mockk() - private val preferencesManager = mockk() private val notifier = mockk(relaxed = true) private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" - private val commentAddedSuccessfully = "Comment Successfully added" //region mockThread - val mockThread = org.openedx.discussion.domain.model.Thread( "", "", @@ -52,7 +52,6 @@ class DiscussionAddThreadViewModelTest { "", "", "", - TextConverter.textToLinkedImageText(""), false, true, 20, @@ -78,67 +77,9 @@ class DiscussionAddThreadViewModelTest { false, false ) - - //endregion - - //region mockComment - - private val mockComment = DiscussionComment( - "", - "", - "", - "", - "", - "", - "", - TextConverter.textToLinkedImageText(""), - false, - true, - 20, - emptyList(), - false, - "", - "", - false, - "", - "", - "", - 21, - emptyList(), - null, - emptyMap() - ) - - private val mockCommentAdded = DiscussionComment( - "", - "", - "", - "", - "", - "", - "", - TextConverter.textToLinkedImageText(""), - false, - true, - 20, - emptyList(), - false, - "", - "", - false, - "", - "", - "", - 21, - emptyList(), - null, - mapOf("" to DiscussionProfile(ProfileImage("", "", "", "", false))) - ) - //endregion //region mockTopic - private val mockTopic = Topic( id = "", name = "All Topics", @@ -154,10 +95,6 @@ class DiscussionAddThreadViewModelTest { //endregion - private val comments = listOf( - mockComment.copy(id = "0"), mockComment.copy(id = "1") - ) - @Before fun setUp() { Dispatchers.setMain(dispatcher) @@ -272,6 +209,4 @@ class DiscussionAddThreadViewModelTest { assert(viewModel.getHandledTopicById("10").second == "0") } - - -} \ No newline at end of file +} diff --git a/discussion/src/test/java/org/openedx/discussion/presentation/threads/DiscussionThreadsViewModelTest.kt b/discussion/src/test/java/org/openedx/discussion/presentation/threads/DiscussionThreadsViewModelTest.kt index 92e5cd2fa..15e49570d 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,7 @@ 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 +33,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) @@ -63,7 +62,6 @@ class DiscussionThreadsViewModelTest { "", "", "", - TextConverter.textToLinkedImageText(""), false, true, 20, @@ -488,7 +486,6 @@ class DiscussionThreadsViewModelTest { DiscussionTopicsViewModel.TOPIC ) - val mockLifeCycleOwner: LifecycleOwner = mockk() val lifecycleRegistry = LifecycleRegistry(mockLifeCycleOwner) lifecycleRegistry.addObserver(viewModel) @@ -537,6 +534,4 @@ class DiscussionThreadsViewModelTest { coVerify(exactly = 2) { interactor.getThreads(any(), any(), any(), any(), any()) } } - - -} \ No newline at end of file +} 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..4241976c6 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,15 @@ 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 +97,15 @@ 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 +117,23 @@ 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 +148,15 @@ 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 +173,15 @@ 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 +193,23 @@ 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 @@ -247,5 +222,4 @@ class DiscussionTopicsViewModelTest { assert(message.await()?.message.isNullOrEmpty()) assert(viewModel.uiState.value is DiscussionTopicsUIState.Topics) } - } diff --git a/docs/0001-strategy-for-data-streams.rst b/docs/decisions/0001-strategy-for-data-streams.rst similarity index 100% rename from docs/0001-strategy-for-data-streams.rst rename to docs/decisions/0001-strategy-for-data-streams.rst diff --git a/docs/how-tos/auth-using-browser.rst b/docs/how-tos/auth-using-browser.rst new file mode 100644 index 000000000..49a23603b --- /dev/null +++ b/docs/how-tos/auth-using-browser.rst @@ -0,0 +1,48 @@ +How to use Browser-based Login and Registration +=============================================== + +Introduction +------------ + +If your Open edX instance is set up with a custom authentication system that requires logging in +via the browser, you can use the ``BROWSER_LOGIN`` and ``BROWSER_REGISTRATION`` flags to redirect +login and registration to the browser. + +The ``BROWSER_LOGIN`` flag is used to redirect login to the browser. In this case clicking on the +login button will open the authorization flow in an Android custom browser tab and redirect back to +the application. + +The ``BROWSER_REGISTRATION`` flag is used to redirect registration to the browser. In this case +clicking on the registration button will open the registration page in a regular browser tab. Once +registered, the user will as of writing this document **not** be automatically redirected to the +application. + +Usage +----- + +In order to use the ``BROWSER_LOGIN`` feature, you need to set up an OAuth2 provider via +``/admin/oauth2_provider/application/`` that has a redirect URL with the following format + + ``://oauth2Callback`` + +Here application ID is the ID for the Android application and defaults to ``"org.openedx.app"``. This +URI scheme is handled by the application and will be used by the app to get the OAuth2 token for +using the APIs. + +Note that normally the Django OAuth Toolkit doesn't allow custom schemes like the above as redirect +URIs, so you will need to explicitly allow the by adding this URI scheme to +``ALLOWED_REDIRECT_URI_SCHEMES`` in the Django OAuth Toolkit settings in ``OAUTH2_PROVIDER``. You +can add the following line to your django settings python file: + +.. code-block:: python + + OAUTH2_PROVIDER["ALLOWED_REDIRECT_URI_SCHEMES"] = ["https", "org.openedx.app"] + +Replace ``"org.openedx.app"`` with the correct id for your application. You must list all allowed +schemes here, including ``"https"`` and ``"http"``. + +The authentication will then redirect to the browser in a custom tab that redirects back to the app. + +..note:: + + If a user logs out from the application, they might still be logged in, in the browser. \ No newline at end of file diff --git a/docs/how-tos/index.rst b/docs/how-tos/index.rst new file mode 100644 index 000000000..202bad08b --- /dev/null +++ b/docs/how-tos/index.rst @@ -0,0 +1,8 @@ +"How-To" Guides +############### + + +.. toctree:: +:glob: + +* diff --git a/downloads/.gitignore b/downloads/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/downloads/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/downloads/build.gradle b/downloads/build.gradle new file mode 100644 index 000000000..cf1c95f77 --- /dev/null +++ b/downloads/build.gradle @@ -0,0 +1,67 @@ +plugins { + id 'com.android.library' + id 'org.jetbrains.kotlin.android' + id "org.jetbrains.kotlin.plugin.compose" + id 'kotlin-parcelize' +} + +android { + namespace 'org.openedx.downloads' + compileSdkVersion compile_sdk_version + + defaultConfig { + minSdk min_sdk_version + targetSdk target_sdk_version + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles "consumer-rules.pro" + } + + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + + compileOptions { + sourceCompatibility java_version + targetCompatibility java_version + } + kotlin { + compilerOptions { + jvmTarget = jvm_target_version + freeCompilerArgs = ['-XXLanguage:+PropertyParamAnnotationDefaultTargetMode'] + } + } + + buildFeatures { + viewBinding true + compose true + } + + flavorDimensions += "env" + productFlavors { + prod { + dimension 'env' + } + develop { + dimension 'env' + } + stage { + dimension 'env' + } + } +} + +dependencies { + implementation project(path: ':core') + + testImplementation "junit:junit:$junit_version" + testImplementation "io.mockk:mockk:$mockk_version" + testImplementation "androidx.arch.core:core-testing:$android_arch_version" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlinx_coroutines_test_version" + androidTestImplementation "androidx.test.ext:junit:$test_ext_version" + androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_version" +} diff --git a/downloads/consumer-rules.pro b/downloads/consumer-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/downloads/proguard-rules.pro b/downloads/proguard-rules.pro new file mode 100644 index 000000000..cdb308aa0 --- /dev/null +++ b/downloads/proguard-rules.pro @@ -0,0 +1,7 @@ +# 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/downloads/src/main/AndroidManifest.xml b/downloads/src/main/AndroidManifest.xml new file mode 100644 index 000000000..e10007615 --- /dev/null +++ b/downloads/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/downloads/src/main/java/org/openedx/downloads/data/repository/DownloadRepository.kt b/downloads/src/main/java/org/openedx/downloads/data/repository/DownloadRepository.kt new file mode 100644 index 000000000..3a23f8118 --- /dev/null +++ b/downloads/src/main/java/org/openedx/downloads/data/repository/DownloadRepository.kt @@ -0,0 +1,56 @@ +package org.openedx.downloads.data.repository + +import kotlinx.coroutines.flow.flow +import org.openedx.core.data.api.CourseApi +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.data.storage.CourseDao +import org.openedx.core.domain.model.CourseStructure +import org.openedx.core.exception.NoCachedDataException +import org.openedx.core.module.db.DownloadDao + +class DownloadRepository( + private val api: CourseApi, + private val dao: DownloadDao, + private val courseDao: CourseDao, + private val corePreferences: CorePreferences, +) { + fun getDownloadCoursesPreview(refresh: Boolean) = flow { + if (!refresh) { + val cachedDownloadCoursesPreview = dao.getDownloadCoursesPreview() + emit(cachedDownloadCoursesPreview.map { it.mapToDomain() }) + } + val username = corePreferences.user?.username ?: "" + val response = api.getDownloadCoursesPreview(username) + val downloadCoursesPreview = response.map { it.mapToDomain() } + emit(downloadCoursesPreview) + val downloadCoursesPreviewEntity = response.map { it.mapToRoomEntity() } + dao.insertDownloadCoursePreview(downloadCoursesPreviewEntity) + } + + suspend fun getCourseStructureFromCache(courseId: String): CourseStructure { + val cachedCourseStructure = courseDao.getCourseStructureById(courseId) + if (cachedCourseStructure != null) { + return cachedCourseStructure.mapToDomain() + } else { + throw NoCachedDataException() + } + } + + suspend fun getCourseStructure(courseId: String): CourseStructure { + try { + val response = api.getCourseStructure( + cacheControlHeaderParam = "stale-if-error=0", + blocksApiVersion = "v4", + username = corePreferences.user?.username, + courseId = courseId + ) + courseDao.insertCourseStructureEntity(response.mapToRoomEntity()) + return response.mapToDomain() + } catch (_: Exception) { + return getCourseStructureFromCache(courseId) + } + } + + suspend fun getDownloadModelsByCourseIds(courseId: String) = + dao.getDownloadModelsByCourseIds(courseId).map { it.mapToDomain() } +} diff --git a/downloads/src/main/java/org/openedx/downloads/domain/interactor/DownloadInteractor.kt b/downloads/src/main/java/org/openedx/downloads/domain/interactor/DownloadInteractor.kt new file mode 100644 index 000000000..6082e7751 --- /dev/null +++ b/downloads/src/main/java/org/openedx/downloads/domain/interactor/DownloadInteractor.kt @@ -0,0 +1,17 @@ +package org.openedx.downloads.domain.interactor + +import org.openedx.downloads.data.repository.DownloadRepository + +class DownloadInteractor( + private val repository: DownloadRepository +) { + fun getDownloadCoursesPreview(refresh: Boolean) = repository.getDownloadCoursesPreview(refresh) + + suspend fun getDownloadModelsByCourseIds(courseId: String) = + repository.getDownloadModelsByCourseIds(courseId) + + suspend fun getCourseStructureFromCache(courseId: String) = + repository.getCourseStructureFromCache(courseId) + + suspend fun getCourseStructure(courseId: String) = repository.getCourseStructure(courseId) +} diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/DownloadsRouter.kt b/downloads/src/main/java/org/openedx/downloads/presentation/DownloadsRouter.kt new file mode 100644 index 000000000..0b6445f19 --- /dev/null +++ b/downloads/src/main/java/org/openedx/downloads/presentation/DownloadsRouter.kt @@ -0,0 +1,14 @@ +package org.openedx.downloads.presentation + +import androidx.fragment.app.FragmentManager + +interface DownloadsRouter { + + fun navigateToSettings(fm: FragmentManager) + + fun navigateToCourseOutline( + fm: FragmentManager, + courseId: String, + courseTitle: String, + ) +} diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsFragment.kt b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsFragment.kt new file mode 100644 index 000000000..1dc4d1be9 --- /dev/null +++ b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsFragment.kt @@ -0,0 +1,78 @@ +package org.openedx.downloads.presentation.download + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.Fragment +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.openedx.core.ui.theme.OpenEdXTheme + +class DownloadsFragment : Fragment() { + + private val viewModel by viewModel() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + lifecycle.addObserver(viewModel) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val uiState by viewModel.uiState.collectAsState() + val uiMessage by viewModel.uiMessage.collectAsState(null) + DownloadsScreen( + uiState = uiState, + uiMessage = uiMessage, + apiHostUrl = viewModel.apiHostUrl, + hasInternetConnection = viewModel.hasInternetConnection, + onAction = { action -> + when (action) { + DownloadsViewActions.OpenSettings -> { + viewModel.onSettingsClick(requireActivity().supportFragmentManager) + } + + DownloadsViewActions.SwipeRefresh -> { + viewModel.refreshData() + } + + is DownloadsViewActions.OpenCourse -> { + viewModel.navigateToCourseOutline( + fm = requireActivity().supportFragmentManager, + courseId = action.courseId + ) + } + + is DownloadsViewActions.DownloadCourse -> { + viewModel.downloadCourse( + requireActivity().supportFragmentManager, + action.courseId + ) + } + + is DownloadsViewActions.CancelDownloading -> { + viewModel.cancelDownloading(action.courseId) + } + + is DownloadsViewActions.RemoveDownloads -> { + viewModel.removeDownloads( + requireActivity().supportFragmentManager, + action.courseId + ) + } + } + } + ) + } + } + } +} diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsScreen.kt b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsScreen.kt new file mode 100644 index 000000000..e633368b3 --- /dev/null +++ b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsScreen.kt @@ -0,0 +1,567 @@ +package org.openedx.downloads.presentation.download + +import android.content.res.Configuration.ORIENTATION_LANDSCAPE +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.lazy.items +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.Divider +import androidx.compose.material.DropdownMenu +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.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.CloudDone +import androidx.compose.material.icons.filled.MoreHoriz +import androidx.compose.material.icons.outlined.CloudDownload +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.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.layout.ContentScale +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.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import coil.request.ImageRequest +import org.openedx.core.domain.model.DownloadCoursePreview +import org.openedx.core.extension.safeDivBy +import org.openedx.core.module.db.DownloadModel +import org.openedx.core.module.db.DownloadedState +import org.openedx.core.module.db.DownloadedState.LOADING_COURSE_STRUCTURE +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.IconText +import org.openedx.core.ui.MainToolbar +import org.openedx.core.ui.OfflineModeDialog +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.OpenEdXDropdownMenuItem +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.appShapes +import org.openedx.core.ui.theme.appTypography +import org.openedx.downloads.R +import org.openedx.foundation.extension.toFileSize +import org.openedx.foundation.extension.toImageLink +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun DownloadsScreen( + uiState: DownloadsUIState, + uiMessage: UIMessage?, + apiHostUrl: String, + hasInternetConnection: Boolean, + onAction: (DownloadsViewActions) -> Unit, +) { + val scaffoldState = rememberScaffoldState() + val windowSize = rememberWindowSize() + val configuration = LocalConfiguration.current + val contentWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), + compact = Modifier.fillMaxWidth(), + ) + ) + } + val pullRefreshState = rememberPullRefreshState( + refreshing = uiState.isRefreshing, + onRefresh = { onAction(DownloadsViewActions.SwipeRefresh) } + ) + var isInternetConnectionShown by rememberSaveable { + mutableStateOf(false) + } + + Scaffold( + scaffoldState = scaffoldState, + modifier = Modifier + .fillMaxSize(), + backgroundColor = MaterialTheme.appColors.background, + topBar = { + MainToolbar( + modifier = Modifier + .statusBarsInset() + .displayCutoutForLandscape(), + label = stringResource(id = R.string.downloads), + onSettingsClick = { + onAction(DownloadsViewActions.OpenSettings) + } + ) + }, + content = { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .pullRefresh(pullRefreshState) + ) { + if (uiState.isLoading) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } + } else if (uiState.downloadCoursePreviews.isEmpty()) { + EmptyState( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) + } else { + Box( + modifier = Modifier + .fillMaxSize() + .displayCutoutForLandscape() + .padding(paddingValues) + .padding(horizontal = 16.dp), + contentAlignment = Alignment.TopCenter + ) { + if (configuration.orientation == ORIENTATION_LANDSCAPE || windowSize.isTablet) { + LazyVerticalGrid( + modifier = contentWidth.fillMaxHeight(), + state = rememberLazyGridState(), + columns = GridCells.Fixed(2), + verticalArrangement = Arrangement.spacedBy(20.dp), + horizontalArrangement = Arrangement.spacedBy(20.dp), + contentPadding = PaddingValues(bottom = 46.dp, top = 12.dp), + content = { + items(uiState.downloadCoursePreviews) { item -> + val downloadModels = + uiState.downloadModels.filter { it.courseId == item.id } + val downloadState = uiState.courseDownloadState[item.id] + ?: DownloadedState.NOT_DOWNLOADED + CourseItem( + modifier = Modifier.height(314.dp), + downloadCoursePreview = item, + downloadModels = downloadModels, + downloadedState = downloadState, + apiHostUrl = apiHostUrl, + onCourseClick = { + onAction(DownloadsViewActions.OpenCourse(item.id)) + }, + onDownloadClick = { + onAction(DownloadsViewActions.DownloadCourse(item.id)) + }, + onCancelClick = { + onAction(DownloadsViewActions.CancelDownloading(item.id)) + }, + onRemoveClick = { + onAction(DownloadsViewActions.RemoveDownloads(item.id)) + } + ) + } + } + ) + } else { + LazyColumn( + modifier = contentWidth, + contentPadding = PaddingValues(bottom = 46.dp, top = 12.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + items(uiState.downloadCoursePreviews) { item -> + val downloadModels = + uiState.downloadModels.filter { it.courseId == item.id } + val downloadState = uiState.courseDownloadState[item.id] + ?: DownloadedState.NOT_DOWNLOADED + CourseItem( + downloadCoursePreview = item, + downloadModels = downloadModels, + downloadedState = downloadState, + apiHostUrl = apiHostUrl, + onCourseClick = { + onAction(DownloadsViewActions.OpenCourse(item.id)) + }, + onDownloadClick = { + onAction(DownloadsViewActions.DownloadCourse(item.id)) + }, + onCancelClick = { + onAction(DownloadsViewActions.CancelDownloading(item.id)) + }, + onRemoveClick = { + onAction(DownloadsViewActions.RemoveDownloads(item.id)) + } + ) + } + } + } + } + } + + HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) + + PullRefreshIndicator( + uiState.isRefreshing, + pullRefreshState, + Modifier.align(Alignment.TopCenter) + ) + + if (!isInternetConnectionShown && !hasInternetConnection) { + OfflineModeDialog( + Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + onDismissCLick = { + isInternetConnectionShown = true + }, + onReloadClick = { + isInternetConnectionShown = true + onAction(DownloadsViewActions.SwipeRefresh) + } + ) + } + } + } + ) +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +private fun CourseItem( + modifier: Modifier = Modifier, + downloadCoursePreview: DownloadCoursePreview, + downloadModels: List, + downloadedState: DownloadedState, + apiHostUrl: String, + onCourseClick: () -> Unit, + onDownloadClick: () -> Unit, + onRemoveClick: () -> Unit, + onCancelClick: () -> Unit +) { + val windowSize = rememberWindowSize() + val configuration = LocalConfiguration.current + var isDropdownExpanded by remember { mutableStateOf(false) } + val downloadedSize = downloadModels + .filter { it.downloadedState == DownloadedState.DOWNLOADED } + .sumOf { it.size } + val availableSize = downloadCoursePreview.totalSize - downloadedSize + val availableSizeString = availableSize.toFileSize(space = false, round = 1) + val progress = downloadedSize.toFloat().safeDivBy(downloadCoursePreview.totalSize.toFloat()) + Card( + modifier = modifier + .fillMaxWidth(), + backgroundColor = MaterialTheme.appColors.background, + shape = MaterialTheme.appShapes.courseImageShape, + elevation = 4.dp, + onClick = onCourseClick + ) { + Box { + Column( + modifier = Modifier.animateContentSize() + ) { + val imageModifier = + if (configuration.orientation == ORIENTATION_LANDSCAPE || windowSize.isTablet) { + Modifier.weight(1f) + } else { + Modifier.height(120.dp) + } + AsyncImage( + modifier = imageModifier.fillMaxWidth(), + model = ImageRequest.Builder(LocalContext.current) + .data(downloadCoursePreview.image.toImageLink(apiHostUrl)) + .error(org.openedx.core.R.drawable.core_no_image_course) + .placeholder(org.openedx.core.R.drawable.core_no_image_course) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + ) + Column( + modifier = Modifier + .padding(horizontal = 12.dp) + .padding(top = 8.dp, bottom = 12.dp), + ) { + Text( + text = downloadCoursePreview.name, + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textDark, + overflow = TextOverflow.Ellipsis, + minLines = 1, + maxLines = 2 + ) + Spacer(modifier = Modifier.height(8.dp)) + if (downloadedState != DownloadedState.DOWNLOADED && downloadedSize != 0L) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(CircleShape), + progress = progress, + color = MaterialTheme.appColors.successGreen, + backgroundColor = MaterialTheme.appColors.divider + ) + } + if (downloadedSize != 0L) { + Spacer(modifier = Modifier.height(4.dp)) + IconText( + icon = Icons.Filled.CloudDone, + color = MaterialTheme.appColors.successGreen, + text = stringResource( + R.string.downloaded_downloaded_size, + downloadedSize.toFileSize(space = false, round = 1) + ) + ) + } + if (downloadedState != DownloadedState.DOWNLOADED) { + Spacer(modifier = Modifier.height(4.dp)) + IconText( + icon = Icons.Outlined.CloudDownload, + color = MaterialTheme.appColors.textPrimaryVariant, + text = stringResource( + R.string.downloaded_available_size, + availableSizeString + ) + ) + } + Spacer(modifier = Modifier.height(8.dp)) + if (downloadedState.isWaitingOrDownloading) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Box(contentAlignment = Alignment.Center) { + CircularProgressIndicator( + modifier = Modifier.size(36.dp), + backgroundColor = Color.LightGray, + strokeWidth = 2.dp, + color = MaterialTheme.appColors.primary + ) + IconButton( + modifier = Modifier + .size(28.dp) + .padding(2.dp), + onClick = onCancelClick + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource( + id = R.string.downloads_accessibility_stop_downloading_course + ), + tint = MaterialTheme.appColors.error + ) + } + } + Spacer(modifier = Modifier.width(8.dp)) + val text = if (downloadedState == LOADING_COURSE_STRUCTURE) { + stringResource(R.string.downloads_loading_course_structure) + } else { + stringResource(org.openedx.core.R.string.core_downloading) + } + Text( + text = text, + style = MaterialTheme.appTypography.titleSmall, + color = MaterialTheme.appColors.textPrimary + ) + } + } else if (downloadedState == DownloadedState.NOT_DOWNLOADED) { + OpenEdXButton( + onClick = { + onDownloadClick() + }, + content = { + IconText( + text = stringResource(R.string.downloads_download_course), + icon = Icons.Outlined.CloudDownload, + color = MaterialTheme.appColors.primaryButtonText, + textStyle = MaterialTheme.appTypography.labelLarge + ) + } + ) + } + } + } + + Column( + modifier = Modifier + .align(Alignment.TopEnd), + ) { + if (downloadedSize != 0L || downloadedState.isWaitingOrDownloading) { + MoreButton( + onClick = { + isDropdownExpanded = true + } + ) + } + DropdownMenu( + modifier = Modifier + .crop(vertical = 8.dp) + .defaultMinSize(minWidth = 269.dp) + .background(MaterialTheme.appColors.background), + expanded = isDropdownExpanded, + onDismissRequest = { isDropdownExpanded = false }, + ) { + Column { + if (downloadedSize != 0L) { + OpenEdXDropdownMenuItem( + text = stringResource(R.string.downloads_remove_course_downloads), + onClick = { + isDropdownExpanded = false + onRemoveClick() + } + ) + Divider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.appColors.divider + ) + } + if (downloadedState.isWaitingOrDownloading) { + OpenEdXDropdownMenuItem( + text = stringResource(R.string.downloads_cancel_download), + onClick = { + isDropdownExpanded = false + onCancelClick() + } + ) + } + } + } + } + } + } +} + +@Composable +private fun MoreButton( + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + IconButton( + modifier = modifier, + onClick = onClick + ) { + Icon( + modifier = Modifier + .size(30.dp) + .background( + color = MaterialTheme.appColors.onPrimary.copy(alpha = 0.5f), + shape = CircleShape + ) + .padding(4.dp), + imageVector = Icons.Default.MoreHoriz, + contentDescription = null, + tint = MaterialTheme.appColors.onSurface + ) + } +} + +@Composable +private fun EmptyState( + modifier: Modifier = Modifier +) { + Box( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier.width(200.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + painter = painterResource(id = org.openedx.core.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 = R.string.downloads_empty_state_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.downloads_empty_state_description), + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.labelMedium, + textAlign = TextAlign.Center + ) + } + } +} + +@Preview +@Composable +private fun DownloadsScreenPreview() { + OpenEdXTheme { + DownloadsScreen( + uiState = DownloadsUIState(isLoading = false), + uiMessage = null, + apiHostUrl = "", + hasInternetConnection = true, + onAction = {} + ) + } +} + +@Preview +@Composable +private fun CourseItemPreview() { + OpenEdXTheme { + CourseItem( + downloadCoursePreview = DownloadCoursePreview("", "name", "", 100), + downloadModels = emptyList(), + apiHostUrl = "", + downloadedState = DownloadedState.NOT_DOWNLOADED, + onCourseClick = {}, + onDownloadClick = {}, + onCancelClick = {}, + onRemoveClick = {}, + ) + } +} diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsUIState.kt b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsUIState.kt new file mode 100644 index 000000000..e3f24b666 --- /dev/null +++ b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsUIState.kt @@ -0,0 +1,13 @@ +package org.openedx.downloads.presentation.download + +import org.openedx.core.domain.model.DownloadCoursePreview +import org.openedx.core.module.db.DownloadModel +import org.openedx.core.module.db.DownloadedState + +data class DownloadsUIState( + val isLoading: Boolean = true, + val isRefreshing: Boolean = false, + val downloadCoursePreviews: List = emptyList(), + val downloadModels: List = emptyList(), + val courseDownloadState: Map = emptyMap(), +) diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt new file mode 100644 index 000000000..24381a2a5 --- /dev/null +++ b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt @@ -0,0 +1,385 @@ +package org.openedx.downloads.presentation.download + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.School +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.catch +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.openedx.core.BlockType +import org.openedx.core.R +import org.openedx.core.config.Config +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.CourseStructure +import org.openedx.core.domain.model.DownloadCoursePreview +import org.openedx.core.module.DownloadWorkerController +import org.openedx.core.module.db.DownloadDao +import org.openedx.core.module.db.DownloadedState +import org.openedx.core.module.download.BaseDownloadViewModel +import org.openedx.core.module.download.DownloadHelper +import org.openedx.core.presentation.CoreAnalytics +import org.openedx.core.presentation.DownloadsAnalytics +import org.openedx.core.presentation.DownloadsAnalyticsEvent +import org.openedx.core.presentation.DownloadsAnalyticsKey +import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogItem +import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.system.notifier.CourseDashboardUpdate +import org.openedx.core.system.notifier.CourseNotifier +import org.openedx.core.system.notifier.CourseStructureGot +import org.openedx.core.system.notifier.CourseStructureUpdated +import org.openedx.core.system.notifier.DiscoveryNotifier +import org.openedx.downloads.domain.interactor.DownloadInteractor +import org.openedx.downloads.presentation.DownloadsRouter +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 DownloadsViewModel( + private val downloadsRouter: DownloadsRouter, + private val networkConnection: NetworkConnection, + private val interactor: DownloadInteractor, + private val downloadDialogManager: DownloadDialogManager, + private val resourceManager: ResourceManager, + private val fileUtil: FileUtil, + private val config: Config, + private val analytics: DownloadsAnalytics, + private val discoveryNotifier: DiscoveryNotifier, + private val courseNotifier: CourseNotifier, + private val router: DownloadsRouter, + preferencesManager: CorePreferences, + coreAnalytics: CoreAnalytics, + downloadDao: DownloadDao, + workerController: DownloadWorkerController, + downloadHelper: DownloadHelper, +) : BaseDownloadViewModel( + downloadDao, + preferencesManager, + workerController, + coreAnalytics, + downloadHelper, +) { + val apiHostUrl get() = config.getApiHostURL() + + private val _uiState = MutableStateFlow(DownloadsUIState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _uiMessage = MutableSharedFlow() + val uiMessage: SharedFlow = _uiMessage.asSharedFlow() + + private val courseBlockIds = mutableMapOf>() + + val hasInternetConnection: Boolean get() = networkConnection.isOnline() + + private var downloadJobs = mutableMapOf() + + init { + fetchDownloads(refresh = false) + observeCourseDashboardUpdates() + observeDownloadingModels() + observeDownloadModelsStatus() + observeCourseStructureUpdates() + } + + private fun observeCourseDashboardUpdates() { + viewModelScope.launch { + discoveryNotifier.notifier.collect { notifier -> + if (notifier is CourseDashboardUpdate) { + fetchDownloads(refresh = true) + } + } + } + } + + private fun observeCourseStructureUpdates() { + viewModelScope.launch { + courseNotifier.notifier.collect { notifier -> + when (notifier) { + is CourseStructureGot, is CourseStructureUpdated -> { + fetchDownloads(refresh = true) + } + } + } + } + } + + private fun observeDownloadingModels() { + viewModelScope.launch { + downloadingModelsFlow.collect { downloadModels -> + _uiState.update { state -> + state.copy(downloadModels = downloadModels) + } + } + } + } + + private fun observeDownloadModelsStatus() { + viewModelScope.launch { + downloadModelsStatusFlow.collect { statusMap -> + val updatedCourseStates = courseBlockIds.mapValues { (courseId, blockIds) -> + val currentCourseState = uiState.value.courseDownloadState[courseId] + val blockStates = blockIds.mapNotNull { statusMap[it] } + val computedState = if (blockStates.isEmpty()) { + DownloadedState.NOT_DOWNLOADED + } else { + val downloadedSize = _uiState.value.downloadModels + .filter { it.courseId == courseId } + .sumOf { it.size } + val courseSize = _uiState.value.downloadCoursePreviews + .find { it.id == courseId }?.totalSize ?: 0 + val isSizeMatch: Boolean = + downloadedSize.toDouble() / courseSize >= SIZE_MATCH_THRESHOLD + determineCourseState(blockStates, isSizeMatch) + } + if (currentCourseState == DownloadedState.LOADING_COURSE_STRUCTURE && + computedState == DownloadedState.NOT_DOWNLOADED + ) { + DownloadedState.LOADING_COURSE_STRUCTURE + } else { + computedState + } + } + + _uiState.update { state -> + state.copy(courseDownloadState = updatedCourseStates) + } + } + } + } + + private fun determineCourseState( + blockStates: List, + isSizeMatch: Boolean + ): DownloadedState { + return when { + blockStates.all { it == DownloadedState.DOWNLOADED } && isSizeMatch -> DownloadedState.DOWNLOADED + blockStates.all { it == DownloadedState.WAITING } -> DownloadedState.WAITING + blockStates.any { it == DownloadedState.DOWNLOADING } -> DownloadedState.DOWNLOADING + else -> DownloadedState.NOT_DOWNLOADED + } + } + + private fun fetchDownloads(refresh: Boolean) { + viewModelScope.launch { + updateLoadingState(isLoading = !refresh, isRefreshing = refresh) + interactor.getDownloadCoursesPreview(refresh) + .onCompletion { + resetLoadingState() + } + .catch { e -> + emitErrorMessage(e) + } + .collect { downloadCoursePreviews -> + downloadCoursePreviews.forEach { preview -> + runCatching { initializeCourseBlocks(preview.id, useCache = true) } + .onFailure { it.printStackTrace() } + } + allBlocks.values + .filter { it.type == BlockType.SEQUENTIAL } + .forEach { addDownloadableChildrenForSequentialBlock(it) } + initDownloadModelsStatus() + _uiState.update { state -> + state.copy( + downloadCoursePreviews = downloadCoursePreviews, + isLoading = false, + isRefreshing = false + ) + } + } + } + } + + private fun updateLoadingState(isLoading: Boolean, isRefreshing: Boolean) { + _uiState.update { state -> + state.copy(isLoading = isLoading, isRefreshing = isRefreshing) + } + } + + private fun emitErrorMessage(e: Throwable) { + viewModelScope.launch { + val text = if (e.isInternetError()) { + R.string.core_error_no_connection + } else { + R.string.core_error_unknown_error + } + _uiMessage.emit( + UIMessage.SnackBarMessage(resourceManager.getString(text)) + ) + } + } + + fun refreshData() { + fetchDownloads(refresh = true) + } + + fun onSettingsClick(fragmentManager: FragmentManager) { + downloadsRouter.navigateToSettings(fragmentManager) + } + + fun downloadCourse(fragmentManager: FragmentManager, courseId: String) { + logEvent(DownloadsAnalyticsEvent.DOWNLOAD_COURSE_CLICKED) + try { + showDownloadPopup(fragmentManager, courseId) + } catch (e: Exception) { + logEvent(DownloadsAnalyticsEvent.DOWNLOAD_ERROR) + updateCourseState(courseId, DownloadedState.NOT_DOWNLOADED) + emitErrorMessage(e) + } + } + + fun cancelDownloading(courseId: String) { + logEvent(DownloadsAnalyticsEvent.CANCEL_DOWNLOAD_CLICKED) + viewModelScope.launch { + downloadJobs[courseId]?.cancel() + interactor.getDownloadModelsByCourseIds(courseId) + .filter { it.downloadedState.isWaitingOrDownloading } + .forEach { removeBlockDownloadModel(it.id) } + } + } + + fun removeDownloads(fragmentManager: FragmentManager, courseId: String) { + logEvent(DownloadsAnalyticsEvent.REMOVE_DOWNLOAD_CLICKED) + viewModelScope.launch { + val downloadModels = interactor.getDownloadModelsByCourseIds(courseId) + val downloadedModels = downloadModels.filter { + it.downloadedState == DownloadedState.DOWNLOADED + } + val totalSize = downloadedModels.sumOf { it.size } + val title = getCoursePreview(courseId)?.name.orEmpty() + val downloadDialogItem = DownloadDialogItem( + title = title, + size = totalSize, + icon = Icons.Default.School + ) + downloadDialogManager.showRemoveDownloadModelPopup( + downloadDialogItem = downloadDialogItem, + fragmentManager = fragmentManager, + removeDownloadModels = { + downloadModels.forEach { super.removeBlockDownloadModel(it.id) } + logEvent(DownloadsAnalyticsEvent.DOWNLOAD_REMOVED) + } + ) + } + } + + private suspend fun initializeCourseBlocks( + courseId: String, + useCache: Boolean + ): CourseStructure { + val courseStructure = if (useCache) { + interactor.getCourseStructureFromCache(courseId) + } else { + interactor.getCourseStructure(courseId) + } + courseBlockIds[courseStructure.id] = courseStructure.blockData.map { it.id } + addBlocks(courseStructure.blockData) + return courseStructure + } + + private fun showDownloadPopup(fragmentManager: FragmentManager, courseId: String) { + viewModelScope.launch { + val coursePreview = getCoursePreview(courseId) ?: return@launch + val downloadModels = interactor.getDownloadModelsByCourseIds(courseId) + val downloadedModelsSize = downloadModels + .filter { it.downloadedState == DownloadedState.DOWNLOADED } + .sumOf { it.size } + downloadDialogManager.showPopup( + coursePreview = coursePreview.copy(totalSize = coursePreview.totalSize - downloadedModelsSize), + isBlocksDownloaded = false, + fragmentManager = fragmentManager, + removeDownloadModels = ::removeDownloadModels, + saveDownloadModels = { + initiateSaveDownloadModels(courseId) + }, + onDismissClick = { + logEvent(DownloadsAnalyticsEvent.DOWNLOAD_CANCELLED) + updateCourseState(courseId, DownloadedState.NOT_DOWNLOADED) + }, + onConfirmClick = { + logEvent(DownloadsAnalyticsEvent.DOWNLOAD_CONFIRMED) + } + ) + } + } + + private fun initiateSaveDownloadModels(courseId: String) { + downloadJobs[courseId] = viewModelScope.launch { + try { + updateCourseState(courseId, DownloadedState.LOADING_COURSE_STRUCTURE) + val courseStructure = initializeCourseBlocks(courseId, useCache = false) + courseStructure.blockData + .filter { it.type == BlockType.SEQUENTIAL } + .forEach { sequentialBlock -> + addDownloadableChildrenForSequentialBlock(sequentialBlock) + super.saveDownloadModels( + fileUtil.getExternalAppDir().path, + courseId, + sequentialBlock.id + ) + } + } catch (e: Exception) { + updateCourseState(courseId, DownloadedState.NOT_DOWNLOADED) + emitErrorMessage(e) + } + } + } + + fun navigateToCourseOutline(fm: FragmentManager, courseId: String) { + val coursePreview = getCoursePreview(courseId) ?: return + router.navigateToCourseOutline( + fm = fm, + courseId = coursePreview.id, + courseTitle = coursePreview.name, + ) + } + + private fun logEvent(event: DownloadsAnalyticsEvent) { + analytics.logEvent( + event = event.eventName, + params = mapOf(DownloadsAnalyticsKey.NAME.key to event.biValue) + ) + } + + private fun resetLoadingState() { + _uiState.update { state -> + state.copy(isLoading = false, isRefreshing = false) + } + } + + private fun updateCourseState(courseId: String, state: DownloadedState) { + _uiState.update { currentState -> + currentState.copy( + courseDownloadState = currentState.courseDownloadState.toMutableMap().apply { + put(courseId, state) + } + ) + } + } + + private fun getCoursePreview(courseId: String): DownloadCoursePreview? { + return _uiState.value.downloadCoursePreviews.find { it.id == courseId } + } + + companion object { + const val SIZE_MATCH_THRESHOLD = 0.95 + } +} + +interface DownloadsViewActions { + object OpenSettings : DownloadsViewActions + object SwipeRefresh : DownloadsViewActions + data class OpenCourse(val courseId: String) : DownloadsViewActions + data class DownloadCourse(val courseId: String) : DownloadsViewActions + data class CancelDownloading(val courseId: String) : DownloadsViewActions + data class RemoveDownloads(val courseId: String) : DownloadsViewActions +} diff --git a/downloads/src/main/res/values/strings.xml b/downloads/src/main/res/values/strings.xml new file mode 100644 index 000000000..5a0503db1 --- /dev/null +++ b/downloads/src/main/res/values/strings.xml @@ -0,0 +1,13 @@ + + + Downloads + Download course + Remove course downloads + Cancel download + No Courses with Downloadable Content + You currently have no courses with downloadable content. + %1$s downloaded + %1$s available + Stop downloading course + Loading course structure… + \ No newline at end of file diff --git a/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt b/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt new file mode 100644 index 000000000..c57445b42 --- /dev/null +++ b/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt @@ -0,0 +1,396 @@ +package org.openedx.downloads + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.fragment.app.FragmentManager +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.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +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.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 +import org.openedx.core.domain.model.CoursewareAccess +import org.openedx.core.domain.model.DownloadCoursePreview +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.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.DownloadsAnalytics +import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.system.notifier.CourseNotifier +import org.openedx.core.system.notifier.DiscoveryNotifier +import org.openedx.downloads.domain.interactor.DownloadInteractor +import org.openedx.downloads.presentation.DownloadsRouter +import org.openedx.downloads.presentation.download.DownloadsViewModel +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 + +class DownloadsViewModelTest { + + @get:Rule + val testInstantTaskExecutorRule: TestRule = InstantTaskExecutorRule() + + private val dispatcher = StandardTestDispatcher() + + // Mocks for all dependencies + private val downloadsRouter = mockk(relaxed = true) + private val networkConnection = mockk(relaxed = true) + private val interactor = mockk(relaxed = true) + private val downloadDialogManager = mockk(relaxed = true) + private val resourceManager = mockk(relaxed = true) + private val fileUtil = mockk(relaxed = true) + private val config = mockk(relaxed = true) + private val analytics = mockk(relaxed = true) + private val preferencesManager = mockk(relaxed = true) + private val coreAnalytics = mockk(relaxed = true) + private val downloadDao = mockk(relaxed = true) + private val workerController = mockk(relaxed = true) + private val downloadHelper = mockk(relaxed = true) + private val router = mockk(relaxed = true) + private val discoveryNotifier = mockk(relaxed = true) + private val courseNotifier = mockk(relaxed = true) + + private val noInternet = "No connection" + private val unknownError = "Unknown error" + + private val downloadCoursePreview = + DownloadCoursePreview( + id = "course1", + name = "", + image = "", + totalSize = DownloadDialogManager.MAX_CELLULAR_SIZE.toLong() + ) + private val assignmentProgress = AssignmentProgress( + assignmentType = "Homework", + numPointsEarned = 1f, + numPointsPossible = 3f, + shortLabel = "HW1", + ) + private val blocks = listOf( + Block( + id = "id", + blockId = "blockId", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.CHAPTER, + displayName = "Block", + graded = false, + studentViewData = null, + studentViewMultiDevice = false, + blockCounts = BlockCounts(0), + descendants = listOf("1", "id1"), + descendantsType = BlockType.HTML, + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date(), + offlineDownload = null, + ), + Block( + id = "id1", + blockId = "blockId", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.HTML, + displayName = "Block", + graded = false, + studentViewData = null, + studentViewMultiDevice = false, + blockCounts = BlockCounts(0), + descendants = listOf("id2"), + descendantsType = BlockType.HTML, + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date(), + offlineDownload = null, + ), + Block( + id = "id2", + blockId = "blockId", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.HTML, + displayName = "Block", + graded = false, + studentViewData = null, + studentViewMultiDevice = false, + blockCounts = BlockCounts(0), + descendants = emptyList(), + descendantsType = BlockType.HTML, + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date(), + offlineDownload = null, + ) + ) + + private val downloadModel = DownloadModel( + "id", + "title", + "", + 0, + "", + "url", + FileType.VIDEO, + DownloadedState.NOT_DOWNLOADED, + null + ) + + private val courseStructure = CourseStructure( + root = "", + blockData = blocks, + id = "id", + name = "Course name", + number = "", + org = "Org", + start = Date(), + startDisplay = "", + startType = "", + end = Date(), + coursewareAccess = CoursewareAccess( + true, + "", + "", + "", + "", + "" + ), + media = null, + certificate = null, + isSelfPaced = false, + progress = null + ) + + @OptIn(ExperimentalCoroutinesApi::class) + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + every { config.getApiHostURL() } returns "http://localhost:8000" + every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet + every { resourceManager.getString(R.string.core_error_unknown_error) } returns unknownError + every { networkConnection.isOnline() } returns true + + coEvery { interactor.getDownloadCoursesPreview(any()) } returns flow { + emit(listOf(downloadCoursePreview)) + } + coEvery { interactor.getCourseStructureFromCache("course1") } returns courseStructure + coEvery { interactor.getCourseStructure("course1") } returns courseStructure + coEvery { interactor.getDownloadModelsByCourseIds(any()) } returns emptyList() + coEvery { downloadDao.getAllDataFlow() } returns flowOf( + listOf( + DownloadModelEntity.createFrom( + downloadModel + ) + ) + ) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `onSettingsClick should navigate to settings`() = runTest { + val viewModel = DownloadsViewModel( + downloadsRouter, + networkConnection, + interactor, + downloadDialogManager, + resourceManager, + fileUtil, + config, + analytics, + discoveryNotifier, + courseNotifier, + router, + preferencesManager, + coreAnalytics, + downloadDao, + workerController, + downloadHelper + ) + advanceUntilIdle() + + val fragmentManager = mockk(relaxed = true) + viewModel.onSettingsClick(fragmentManager) + verify(exactly = 1) { downloadsRouter.navigateToSettings(fragmentManager) } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `downloadCourse should show download dialog`() = runTest { + val viewModel = DownloadsViewModel( + downloadsRouter, + networkConnection, + interactor, + downloadDialogManager, + resourceManager, + fileUtil, + config, + analytics, + discoveryNotifier, + courseNotifier, + router, + preferencesManager, + coreAnalytics, + downloadDao, + workerController, + downloadHelper + ) + advanceUntilIdle() + val fragmentManager = mockk(relaxed = true) + viewModel.downloadCourse(fragmentManager, "course1") + advanceUntilIdle() + + verify(exactly = 1) { analytics.logEvent(any(), any()) } + + coVerify(exactly = 1) { + downloadDialogManager.showPopup( + coursePreview = any(), + isBlocksDownloaded = any(), + fragmentManager = any(), + removeDownloadModels = any(), + saveDownloadModels = any(), + onDismissClick = any(), + onConfirmClick = any() + ) + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `cancelDownloading should update courseDownloadState to NOT_DOWNLOADED and cancel download job`() = + runTest { + val viewModel = DownloadsViewModel( + downloadsRouter, + networkConnection, + interactor, + downloadDialogManager, + resourceManager, + fileUtil, + config, + analytics, + discoveryNotifier, + courseNotifier, + router, + preferencesManager, + coreAnalytics, + downloadDao, + workerController, + downloadHelper + ) + advanceUntilIdle() + + val fragmentManager = mockk(relaxed = true) + viewModel.downloadCourse(fragmentManager, "course1") + advanceUntilIdle() + + viewModel.cancelDownloading("course1") + advanceUntilIdle() + + coVerify { interactor.getDownloadModelsByCourseIds(any()) } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `removeDownloads should show remove popup with correct parameters`() = runTest { + coEvery { interactor.getDownloadModelsByCourseIds(any()) } returns listOf(downloadModel) + + val viewModel = DownloadsViewModel( + downloadsRouter, + networkConnection, + interactor, + downloadDialogManager, + resourceManager, + fileUtil, + config, + analytics, + discoveryNotifier, + courseNotifier, + router, + preferencesManager, + coreAnalytics, + downloadDao, + workerController, + downloadHelper + ) + advanceUntilIdle() + + val fragmentManager = mockk(relaxed = true) + viewModel.removeDownloads(fragmentManager, "course1") + advanceUntilIdle() + + coVerify { + downloadDialogManager.showRemoveDownloadModelPopup( + any(), + any(), + any() + ) + } + + verify(exactly = 1) { analytics.logEvent(any(), any()) } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `refreshData no internet error should emit snack bar message`() = runTest { + every { networkConnection.isOnline() } returns true + coEvery { interactor.getDownloadCoursesPreview(any()) } returns flow { throw UnknownHostException() } + + val viewModel = DownloadsViewModel( + downloadsRouter, + networkConnection, + interactor, + downloadDialogManager, + resourceManager, + fileUtil, + config, + analytics, + discoveryNotifier, + courseNotifier, + router, + preferencesManager, + coreAnalytics, + downloadDao, + workerController, + downloadHelper + ) + val deferred = async { viewModel.uiMessage.first() } + advanceUntilIdle() + + viewModel.refreshData() + advanceUntilIdle() + + assertEquals(noInternet, (deferred.await() as? UIMessage.SnackBarMessage)?.message) + assertFalse(viewModel.uiState.value.isRefreshing) + } +} 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..0f37100ea 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 +#Mon Aug 11 14:17:42 EEST 2025 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.13-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..8f4ed5783 100644 --- a/profile/build.gradle +++ b/profile/build.gradle @@ -2,15 +2,16 @@ plugins { id 'com.android.library' id 'org.jetbrains.kotlin.android' id 'kotlin-parcelize' + id "org.jetbrains.kotlin.plugin.compose" } android { namespace 'org.openedx.profile' - compileSdk 34 + compileSdkVersion compile_sdk_version defaultConfig { - minSdk 24 - targetSdk 34 + minSdk min_sdk_version + targetSdk target_sdk_version testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles "consumer-rules.pro" @@ -18,26 +19,25 @@ android { buildTypes { release { - minifyEnabled true + minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } - compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 + sourceCompatibility java_version + targetCompatibility java_version } - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17 + kotlin { + compilerOptions { + jvmTarget = jvm_target_version + freeCompilerArgs = ['-XXLanguage:+PropertyParamAnnotationDefaultTargetMode'] + } } buildFeatures { viewBinding true compose true } - composeOptions { - kotlinCompilerExtensionVersion = "$compose_compiler_version" - } flavorDimensions += "env" productFlavors { @@ -56,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' 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" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlinx_coroutines_test_version" + androidTestImplementation "androidx.test.ext:junit:$test_ext_version" + androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_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/androidTest/java/org/openedx/profile/ExampleInstrumentedTest.kt b/profile/src/androidTest/java/org/openedx/profile/ExampleInstrumentedTest.kt index 12ab3a4c1..814b1bed3 100644 --- a/profile/src/androidTest/java/org/openedx/profile/ExampleInstrumentedTest.kt +++ b/profile/src/androidTest/java/org/openedx/profile/ExampleInstrumentedTest.kt @@ -1,13 +1,11 @@ package org.openedx.profile -import androidx.test.platform.app.InstrumentationRegistry import androidx.test.ext.junit.runners.AndroidJUnit4 - +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith -import org.junit.Assert.* - /** * Instrumented test, which will execute on an Android device. * @@ -21,4 +19,4 @@ class ExampleInstrumentedTest { val appContext = InstrumentationRegistry.getInstrumentation().targetContext assertEquals("org.openedx.profile.test", appContext.packageName) } -} \ No newline at end of file +} diff --git a/profile/src/main/java/org/openedx/profile/data/api/ProfileApi.kt b/profile/src/main/java/org/openedx/profile/data/api/ProfileApi.kt index 1b9bb6750..1f206ed77 100644 --- a/profile/src/main/java/org/openedx/profile/data/api/ProfileApi.kt +++ b/profile/src/main/java/org/openedx/profile/data/api/ProfileApi.kt @@ -1,11 +1,21 @@ package org.openedx.profile.data.api -import org.openedx.core.ApiConstants -import org.openedx.profile.data.model.Account import okhttp3.RequestBody import okhttp3.ResponseBody +import org.openedx.core.ApiConstants +import org.openedx.profile.data.model.Account import retrofit2.Response -import retrofit2.http.* +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.Field +import retrofit2.http.FormUrlEncoded +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.Headers +import retrofit2.http.PATCH +import retrofit2.http.POST +import retrofit2.http.Path +import retrofit2.http.Query interface ProfileApi { @@ -26,7 +36,7 @@ interface ProfileApi { suspend fun updateAccount( @Path("username") username: String, @Body fields: Map - ) : Account + ): Account @Headers("Cache-Control: no-cache") @POST("/api/user/v1/accounts/{username}/image") @@ -35,7 +45,7 @@ interface ProfileApi { @Header("Content-Disposition") contentDisposition: String?, @Query("mobile") mobile: Boolean = true, @Body file: RequestBody? - ) : Response + ): Response @Headers("Cache-Control: no-cache") @DELETE("/api/user/v1/accounts/{username}/image") @@ -46,5 +56,4 @@ interface ProfileApi { suspend fun deactivateAccount( @Field("password") password: String ): Response - -} \ No newline at end of file +} diff --git a/profile/src/main/java/org/openedx/profile/data/model/Account.kt b/profile/src/main/java/org/openedx/profile/data/model/Account.kt index ff069376a..bea2468bf 100644 --- a/profile/src/main/java/org/openedx/profile/data/model/Account.kt +++ b/profile/src/main/java/org/openedx/profile/data/model/Account.kt @@ -3,7 +3,7 @@ package org.openedx.profile.data.model import com.google.gson.annotations.SerializedName import org.openedx.core.data.model.ProfileImage import org.openedx.profile.domain.model.Account -import java.util.* +import java.util.Date import org.openedx.profile.domain.model.Account as DomainAccount data class Account( @@ -44,6 +44,7 @@ data class Account( enum class Privacy { @SerializedName("private") PRIVATE, + @SerializedName("all_users") ALL_USERS } @@ -51,7 +52,7 @@ data class Account( fun mapToDomain(): Account { return Account( username = username ?: "", - bio = bio?:"", + bio = bio ?: "", requiresParentalConsent = requiresParentalConsent ?: false, name = name ?: "", country = country ?: "", @@ -67,9 +68,11 @@ data class Account( mailingAddress = mailingAddress ?: "", email = email, dateJoined = dateJoined, - accountPrivacy = if (accountPrivacy == Privacy.PRIVATE) DomainAccount.Privacy.PRIVATE else DomainAccount.Privacy.ALL_USERS + accountPrivacy = if (accountPrivacy == Privacy.PRIVATE) { + DomainAccount.Privacy.PRIVATE + } else { + DomainAccount.Privacy.ALL_USERS + } ) } } - - diff --git a/profile/src/main/java/org/openedx/profile/data/model/LanguageProficiency.kt b/profile/src/main/java/org/openedx/profile/data/model/LanguageProficiency.kt index 5a88b03e3..181c03348 100644 --- a/profile/src/main/java/org/openedx/profile/data/model/LanguageProficiency.kt +++ b/profile/src/main/java/org/openedx/profile/data/model/LanguageProficiency.kt @@ -12,4 +12,4 @@ data class LanguageProficiency( code = code ?: "" ) } -} \ No newline at end of file +} 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/data/storage/ProfilePreferences.kt b/profile/src/main/java/org/openedx/profile/data/storage/ProfilePreferences.kt index aba477e0a..34da01e43 100644 --- a/profile/src/main/java/org/openedx/profile/data/storage/ProfilePreferences.kt +++ b/profile/src/main/java/org/openedx/profile/data/storage/ProfilePreferences.kt @@ -4,4 +4,4 @@ import org.openedx.profile.data.model.Account interface ProfilePreferences { var profile: Account? -} \ No newline at end of file +} diff --git a/profile/src/main/java/org/openedx/profile/domain/interactor/ProfileInteractor.kt b/profile/src/main/java/org/openedx/profile/domain/interactor/ProfileInteractor.kt index cbad3b4fe..c07a8bee6 100644 --- a/profile/src/main/java/org/openedx/profile/domain/interactor/ProfileInteractor.kt +++ b/profile/src/main/java/org/openedx/profile/domain/interactor/ProfileInteractor.kt @@ -22,4 +22,4 @@ class ProfileInteractor(private val repository: ProfileRepository) { suspend fun logout() { repository.logout() } -} \ No newline at end of file +} diff --git a/profile/src/main/java/org/openedx/profile/domain/model/Account.kt b/profile/src/main/java/org/openedx/profile/domain/model/Account.kt index f338fc452..0031807b6 100644 --- a/profile/src/main/java/org/openedx/profile/domain/model/Account.kt +++ b/profile/src/main/java/org/openedx/profile/domain/model/Account.kt @@ -35,9 +35,8 @@ data class Account( fun isLimited() = accountPrivacy == Privacy.PRIVATE - fun isOlderThanMinAge() : Boolean { + fun isOlderThanMinAge(): Boolean { val currentYear = Calendar.getInstance().get(Calendar.YEAR) return yearOfBirth != null && currentYear - yearOfBirth > USER_MIN_YEAR } - } 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..6a5061723 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 @@ -226,7 +226,6 @@ private fun ProfileScreenPreview() { } } - @Preview(name = "NEXUS_9_Light", device = Devices.NEXUS_9, uiMode = UI_MODE_NIGHT_NO) @Preview(name = "NEXUS_9_Dark", device = Devices.NEXUS_9, uiMode = UI_MODE_NIGHT_YES) @Composable @@ -243,7 +242,8 @@ private fun ProfileScreenTabletPreview() { private val mockAccount = Account( username = "thom84", - bio = "He as compliment unreserved projecting. Between had observe pretend delight for believe. Do newspaper questions consulted sweetness do. Our sportsman his unwilling fulfilled departure law.", + bio = "He as compliment unreserved projecting. Between had observe pretend delight for believe. Do newspaper " + + "questions consulted sweetness do. Our sportsman his unwilling fulfilled departure law.", requiresParentalConsent = true, name = "Thomas", country = "Ukraine", diff --git a/profile/src/main/java/org/openedx/profile/presentation/anothersaccount/AnothersProfileUIState.kt b/profile/src/main/java/org/openedx/profile/presentation/anothersaccount/AnothersProfileUIState.kt index 29cac7c1a..dc13fa814 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/anothersaccount/AnothersProfileUIState.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/anothersaccount/AnothersProfileUIState.kt @@ -4,5 +4,5 @@ import org.openedx.profile.domain.model.Account sealed class AnothersProfileUIState { data class Data(val account: Account) : AnothersProfileUIState() - object Loading : AnothersProfileUIState() -} \ No newline at end of file + data object Loading : AnothersProfileUIState() +} 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..90559aa9b 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( @@ -46,4 +46,4 @@ class AnothersProfileViewModel( } } } -} \ No newline at end of file +} 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..b4d3bb1c9 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarColor.kt @@ -0,0 +1,28 @@ +package org.openedx.profile.presentation.calendar + +import androidx.annotation.StringRes +import org.openedx.profile.R + +private const val ACCENT_COLOR = 0xFFD13329L +private const val RED_COLOR = 0xFFFF2967L +private const val ORANGE_COLOR = 0xFFFF9501L +private const val YELLOW_COLOR = 0xFFFFCC01L +private const val GREEN_COLOR = 0xFF64DA38L +private const val BLUE_COLOR = 0xFF1AAEF8L +private const val PURPLE_COLOR = 0xFFCC73E1L +private const val BROWN_COLOR = 0xFFA2845EL + +enum class CalendarColor( + @StringRes + val title: Int, + val color: Long +) { + ACCENT(R.string.calendar_color_accent, ACCENT_COLOR), + RED(R.string.calendar_color_red, RED_COLOR), + ORANGE(R.string.calendar_color_orange, ORANGE_COLOR), + YELLOW(R.string.calendar_color_yellow, YELLOW_COLOR), + GREEN(R.string.calendar_color_green, GREEN_COLOR), + BLUE(R.string.calendar_color_blue, BLUE_COLOR), + PURPLE(R.string.calendar_color_purple, PURPLE_COLOR), + BROWN(R.string.calendar_color_brown, BROWN_COLOR) +} 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..2011d065c --- /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(fraction = 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..45ca74658 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarViewModel.kt @@ -0,0 +1,152 @@ +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) { + e.printStackTrace() + 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..6eb27762e --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CoursesToSyncFragment.kt @@ -0,0 +1,442 @@ +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..015df8e2b --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CoursesToSyncViewModel.kt @@ -0,0 +1,107 @@ +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) { + e.printStackTrace() + _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..857af17d0 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogFragment.kt @@ -0,0 +1,435 @@ +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 org.openedx.profile.presentation.calendar.NewCalendarDialogFragment.Companion.MAX_CALENDAR_TITLE_LENGTH +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" + const val MAX_CALENDAR_TITLE_LENGTH = 40 + + 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 + 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 <= MAX_CALENDAR_TITLE_LENGTH) textFieldValue = it + onValueChanged(it.text.trim()) + }, + colors = TextFieldDefaults.outlinedTextFieldColors( + unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, + textColor = MaterialTheme.appColors.textPrimary + ), + 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..770e67b40 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 @@ -124,7 +124,6 @@ class DeleteProfileFragment : Fragment() { } } } - } @OptIn(ExperimentalComposeUiApi::class) @@ -291,7 +290,6 @@ fun DeleteProfileScreen( } } - @Preview( name = "PIXEL_3A_Light", device = Devices.PIXEL_3A, diff --git a/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileFragmentUIState.kt b/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileFragmentUIState.kt index 128642077..b2bb74e9e 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileFragmentUIState.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileFragmentUIState.kt @@ -1,8 +1,8 @@ package org.openedx.profile.presentation.delete sealed class DeleteProfileFragmentUIState { - object Initial: DeleteProfileFragmentUIState() - object Loading: DeleteProfileFragmentUIState() - data class Error(val message: String): DeleteProfileFragmentUIState() - object Success: DeleteProfileFragmentUIState() -} \ No newline at end of file + data object Initial : DeleteProfileFragmentUIState() + data object Loading : DeleteProfileFragmentUIState() + data class Error(val message: String) : DeleteProfileFragmentUIState() + data object Success : DeleteProfileFragmentUIState() +} 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..8ab22c87e 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, @@ -38,7 +38,9 @@ class DeleteProfileViewModel( logDeleteProfileClickedEvent() if (!validator.isPasswordValid(password)) { _uiState.value = - DeleteProfileFragmentUIState.Error(resourceManager.getString(org.openedx.profile.R.string.profile_invalid_password)) + DeleteProfileFragmentUIState.Error( + resourceManager.getString(org.openedx.profile.R.string.profile_invalid_password) + ) return } viewModelScope.launch { @@ -59,7 +61,9 @@ class DeleteProfileViewModel( _uiState.value = DeleteProfileFragmentUIState.Initial } else { _uiState.value = - DeleteProfileFragmentUIState.Error(resourceManager.getString(org.openedx.profile.R.string.profile_password_is_incorrect)) + DeleteProfileFragmentUIState.Error( + resourceManager.getString(org.openedx.profile.R.string.profile_password_is_incorrect) + ) } logDeleteProfileEvent(false) } diff --git a/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileFields.kt b/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileFields.kt index e13b7ad4d..1aae60aa4 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileFields.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileFields.kt @@ -4,4 +4,4 @@ const val YEAR_OF_BIRTH = "year_of_birth" const val LANGUAGE = "language_proficiencies" const val COUNTRY = "country" const val BIO = "bio" -const val ACCOUNT_PRIVACY = "account_privacy" \ No newline at end of file +const val ACCOUNT_PRIVACY = "account_privacy" 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..8f9a3fd14 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 @@ -6,8 +6,6 @@ import android.content.res.Configuration import android.content.res.Configuration.UI_MODE_NIGHT_YES import android.graphics.Bitmap import android.graphics.ImageDecoder -import android.graphics.Matrix -import android.media.ExifInterface import android.net.Uri import android.os.Build import android.os.Bundle @@ -42,7 +40,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 +105,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,22 +115,27 @@ 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 org.openedx.profile.presentation.edit.EditProfileFragment.Companion.LEAVE_PROFILE_WIDTH_FACTOR import java.io.ByteArrayOutputStream import java.io.File import java.io.FileOutputStream @@ -236,8 +234,6 @@ class EditProfileFragment : Fragment() { @Suppress("DEPRECATION") private fun cropImage(uri: Uri): Uri { - val matrix = Matrix() - matrix.postRotate(getImageOrientation(uri).toFloat()) val originalBitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { ImageDecoder.decodeBitmap( ImageDecoder.createSource( @@ -248,66 +244,49 @@ class EditProfileFragment : Fragment() { } else { MediaStore.Images.Media.getBitmap(requireContext().contentResolver, uri) } - val rotatedBitmap = Bitmap.createBitmap( - originalBitmap, 0, 0, originalBitmap.width, originalBitmap.height, matrix, true - ) val newFile = File.createTempFile( - "Image_${System.currentTimeMillis()}", ".jpg", + "Image_${System.currentTimeMillis()}", + ".jpg", requireContext().getExternalFilesDir(Environment.DIRECTORY_PICTURES) ) - val ratio: Float = rotatedBitmap.width.toFloat() / 500 + val ratio: Float = originalBitmap.width.toFloat() / TARGET_IMAGE_WIDTH val newBitmap = Bitmap.createScaledBitmap( - rotatedBitmap, - 500, - (rotatedBitmap.height.toFloat() / ratio).toInt(), + originalBitmap, + TARGET_IMAGE_WIDTH, + (originalBitmap.height.toFloat() / ratio).toInt(), false ) val bos = ByteArrayOutputStream() - newBitmap.compress(Bitmap.CompressFormat.JPEG, 90, bos) + newBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, bos) val bitmapData = bos.toByteArray() val fos = FileOutputStream(newFile) fos.write(bitmapData) fos.flush() fos.close() - //TODO: get applicationId instead of packageName return FileProvider.getUriForFile( - requireContext(), requireContext().packageName + ".fileprovider", + requireContext(), + viewModel.config.getAppId() + ".fileprovider", newFile )!! } - private fun getImageOrientation(uri: Uri): Int { - var rotation = 0 - val exif = ExifInterface(requireActivity().contentResolver.openInputStream(uri)!!) - when (exif.getAttributeInt( - ExifInterface.TAG_ORIENTATION, - ExifInterface.ORIENTATION_NORMAL - )) { - ExifInterface.ORIENTATION_ROTATE_270 -> rotation = 270 - ExifInterface.ORIENTATION_ROTATE_180 -> rotation = 180 - ExifInterface.ORIENTATION_ROTATE_90 -> rotation = 90 - } - return rotation - } - - companion object { private const val ARG_ACCOUNT = "argAccount" + const val LEAVE_PROFILE_WIDTH_FACTOR = 0.7f + private const val IMAGE_QUALITY = 90 + private const val TARGET_IMAGE_WIDTH = 500 + fun newInstance(account: Account): EditProfileFragment { val fragment = EditProfileFragment() fragment.arguments = bundleOf(ARG_ACCOUNT to account) return fragment } } - } -@OptIn( - ExperimentalMaterialApi::class, - ExperimentalComposeUiApi::class -) +@OptIn(ExperimentalComposeUiApi::class) @Composable private fun EditProfileScreen( windowSize: WindowSize, @@ -362,13 +341,15 @@ private fun EditProfileScreen( ) } - val saveButtonEnabled = !(uiState.account.yearOfBirth.toString() == mapFields[YEAR_OF_BIRTH] - && uiState.account.languageProficiencies == mapFields[LANGUAGE] - && uiState.account.country == mapFields[COUNTRY] - && uiState.account.bio == mapFields[BIO] - && selectedImageUri == null - && !isImageDeleted - && uiState.isLimited == uiState.account.isLimited()) + val saveButtonEnabled = !( + uiState.account.yearOfBirth.toString() == mapFields[YEAR_OF_BIRTH] && + uiState.account.languageProficiencies == mapFields[LANGUAGE] && + uiState.account.country == mapFields[COUNTRY] && + uiState.account.bio == mapFields[BIO] && + selectedImageUri == null && + !isImageDeleted && + uiState.isLimited == uiState.account.isLimited() + ) onDataChanged(saveButtonEnabled) val serverFieldName = rememberSaveable { @@ -489,8 +470,8 @@ private fun EditProfileScreen( searchValue = TextFieldValue(it) } ) - }) { - + } + ) { HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) if (isOpenChangeImageDialogState && uiState.account.isOlderThanMinAge()) { @@ -576,7 +557,6 @@ private fun EditProfileScreen( onSaveClick(mapFields.toMap()) } ) - } } } @@ -599,7 +579,13 @@ private fun EditProfileScreen( ) { Text( modifier = Modifier.testTag("txt_edit_profile_type_label"), - text = stringResource(if (uiState.isLimited) R.string.profile_limited_profile else R.string.profile_full_profile), + text = stringResource( + if (uiState.isLimited) { + R.string.profile_limited_profile + } else { + R.string.profile_full_profile + } + ), color = MaterialTheme.appColors.textSecondary, style = MaterialTheme.appTypography.titleSmall ) @@ -626,7 +612,6 @@ private fun EditProfileScreen( .padding(2.dp) .size(100.dp) .clip(CircleShape) - .noRippleClickable { isOpenChangeImageDialogState = true if (!uiState.account.isOlderThanMinAge()) { @@ -648,7 +633,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 +660,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 +695,9 @@ 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] } @@ -738,7 +727,6 @@ private fun EditProfileScreen( } } } - } } } @@ -877,7 +865,6 @@ private fun ChangeImageDialog( Spacer(Modifier.height(20.dp)) } } - } } @@ -893,8 +880,12 @@ private fun ProfileFields( val languageProficiency = (mapFields[LANGUAGE] as List) val lang = if (languageProficiency.isNotEmpty()) { LocaleUtils.getLanguageByLanguageCode(languageProficiency[0].code) - } else "" - Column(verticalArrangement = Arrangement.spacedBy(20.dp)) { + } else { + "" + } + Column( + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { SelectableField( name = stringResource(id = R.string.profile_year), initialValue = mapFields[YEAR_OF_BIRTH].toString(), @@ -949,10 +940,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 +984,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 +1022,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 +1038,7 @@ private fun InputEditField( }, keyboardOptions = KeyboardOptions.Default.copy( keyboardType = keyboardType, - imeAction = ImeAction.Done + imeAction = ImeAction.Default ), keyboardActions = KeyboardActions { keyboardController?.hide() @@ -1116,14 +1111,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,13 +1126,14 @@ 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 ) } - }) + } + ) } @Composable @@ -1157,7 +1153,7 @@ private fun LeaveProfileLandscape( content = { Card( modifier = Modifier - .width(screenWidth * 0.7f) + .width(screenWidth * LEAVE_PROFILE_WIDTH_FACTOR) .clip(MaterialTheme.appShapes.courseImageShape) .semantics { testTagsAsResourceId = true }, backgroundColor = MaterialTheme.appColors.background, @@ -1208,20 +1204,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, @@ -1238,7 +1234,8 @@ private fun LeaveProfileLandscape( } } } - }) + } + ) } @Preview @@ -1326,7 +1323,6 @@ private fun EditProfileScreenTabletPreview() { } } - private val mockAccount = Account( username = "thom84", bio = "designer", diff --git a/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileUIState.kt b/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileUIState.kt index 5654800ca..841dae6a1 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileUIState.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileUIState.kt @@ -7,5 +7,3 @@ data class EditProfileUIState( val isUpdating: Boolean = false, val isLimited: Boolean ) - - 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..dd8781cf9 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,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.extension.isInternetError -import org.openedx.core.system.ResourceManager +import org.openedx.core.config.Config +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( @@ -24,6 +25,7 @@ class EditProfileViewModel( private val resourceManager: ResourceManager, private val notifier: ProfileNotifier, private val analytics: ProfileAnalytics, + val config: Config, account: Account, ) : BaseViewModel() { @@ -56,8 +58,11 @@ class EditProfileViewModel( buildMap { put( ProfileAnalyticsKey.ACTION.key, - if (isLimitedProfile) ProfileAnalyticsKey.LIMITED_PROFILE.key - else ProfileAnalyticsKey.FULL_PROFILE.key + if (isLimitedProfile) { + ProfileAnalyticsKey.LIMITED_PROFILE.key + } else { + ProfileAnalyticsKey.FULL_PROFILE.key + } ) } ) @@ -67,6 +72,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 +164,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..d8297d1bd 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, @@ -75,9 +75,17 @@ class ManageAccountViewModel( ) } catch (e: Exception) { if (e.isInternetError()) { - _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection))) + _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))) + _uiMessage.emit( + UIMessage.SnackBarMessage( + resourceManager.getString(R.string.core_error_unknown_error) + ) + ) } } finally { _isUpdating.value = false 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..3873f8c5c 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 @@ -71,7 +71,8 @@ internal fun ManageAccountView( val pullRefreshState = rememberPullRefreshState( refreshing = refreshing, - onRefresh = { onAction(ManageAccountViewAction.SwipeRefresh) }) + onRefresh = { onAction(ManageAccountViewAction.SwipeRefresh) } + ) Scaffold( modifier = Modifier @@ -174,7 +175,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)) @@ -185,7 +186,8 @@ internal fun ManageAccountView( color = MaterialTheme.appColors.error, onClick = { onAction(ManageAccountViewAction.DeleteAccount) - }) + } + ) Spacer(modifier = Modifier.height(12.dp)) } } @@ -219,7 +221,6 @@ private fun ManageAccountViewPreview() { } } - @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/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..e897b37c6 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 @@ -67,7 +67,8 @@ internal fun ProfileView( val pullRefreshState = rememberPullRefreshState( refreshing = refreshing, - onRefresh = { onAction(ProfileViewAction.SwipeRefresh) }) + onRefresh = { onAction(ProfileViewAction.SwipeRefresh) } + ) Scaffold( modifier = Modifier @@ -149,7 +150,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)) @@ -186,7 +187,6 @@ private fun ProfileScreenPreview() { } } - @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/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..f1eaf0aeb 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() { @@ -28,12 +28,10 @@ class SettingsFragment : Fragment() { val windowSize = rememberWindowSize() val uiState by viewModel.uiState.collectAsState() val logoutSuccess by viewModel.successLogout.collectAsState(false) - val appUpgradeEvent by viewModel.appUpgradeEvent.collectAsState(null) SettingsScreen( windowSize = windowSize, uiState = uiState, - appUpgradeEvent = appUpgradeEvent, onBackClick = { requireActivity().supportFragmentManager.popBackStack() }, @@ -83,11 +81,17 @@ class SettingsFragment : Fragment() { ) } - SettingsScreenAction.ManageAccount -> { + SettingsScreenAction.ManageAccountClick -> { viewModel.manageAccountClicked( requireActivity().supportFragmentManager ) } + + SettingsScreenAction.CalendarSettingsClick -> { + viewModel.calendarSettingsClicked( + requireActivity().supportFragmentManager + ) + } } } ) @@ -112,6 +116,6 @@ 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..6122775bf 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 @@ -51,14 +50,13 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog +import org.openedx.core.AppUpdateState 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 +64,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 @@ -75,7 +76,6 @@ import org.openedx.profile.R as profileR internal fun SettingsScreen( windowSize: WindowSize, uiState: SettingsUIState, - appUpgradeEvent: AppUpgradeEvent?, onBackClick: () -> Unit, onAction: (SettingsScreenAction) -> Unit, ) { @@ -170,21 +170,25 @@ 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)) SupportInfoSection( uiState = uiState, onAction = onAction, - appUpgradeEvent = appUpgradeEvent, ) Spacer(modifier = Modifier.height(24.dp)) @@ -205,7 +209,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 +232,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 + ) } } } @@ -251,7 +263,6 @@ private fun ManageAccountSection(onManageAccountClick: () -> Unit) { @Composable private fun SupportInfoSection( uiState: SettingsUIState.Data, - appUpgradeEvent: AppUpgradeEvent?, onAction: (SettingsScreenAction) -> Unit ) { Column { @@ -273,46 +284,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,14 +319,11 @@ 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, - appUpgradeEvent = appUpgradeEvent, + appUpgradeEvent = AppUpdateState.lastAppUpgradeEvent, ) { onAction(SettingsScreenAction.AppVersionClick) } @@ -344,16 +337,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( @@ -518,11 +512,11 @@ private fun AppVersionItemAppToDate(versionName: String) { ) { Icon( modifier = Modifier.size( - (MaterialTheme.appTypography.labelLarge.fontSize.value + 4).dp + size = (MaterialTheme.appTypography.labelLarge.fontSize.value + 4).dp ), 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"), @@ -601,7 +595,7 @@ fun AppVersionItemUpgradeRequired( ) { Image( modifier = Modifier - .size((MaterialTheme.appTypography.labelLarge.fontSize.value + 8).dp), + .size(size = (MaterialTheme.appTypography.labelLarge.fontSize.value + 8).dp), painter = painterResource(id = R.drawable.core_ic_warning), contentDescription = null ) @@ -629,7 +623,9 @@ fun AppVersionItemUpgradeRequired( } private val mockAppData = AppData( + appName = "openedx", versionName = "1.0.0", + applicationId = "org.example.com" ) private val mockConfiguration = Configuration( @@ -643,7 +639,6 @@ private val mockUiState = SettingsUIState.Data( configuration = mockConfiguration ) - @Preview @Composable private fun AppVersionItemAppToDatePreview() { @@ -695,7 +690,6 @@ private fun SettingsScreenPreview() { windowSize = WindowSize(WindowType.Medium, WindowType.Medium), uiState = mockUiState, onAction = {}, - appUpgradeEvent = null, ) } } 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..c21f72df3 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,27 @@ 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.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 +44,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() { @@ -59,10 +61,6 @@ class SettingsViewModel( val uiMessage: SharedFlow get() = _uiMessage.asSharedFlow() - private val _appUpgradeEvent = MutableStateFlow(null) - val appUpgradeEvent: StateFlow - get() = _appUpgradeEvent.asStateFlow() - val isLogistrationEnabled get() = config.isPreLoginExperienceEnabled() private val configuration @@ -74,7 +72,6 @@ class SettingsViewModel( ) init { - collectAppUpgradeEvent() collectProfileEvent() } @@ -94,25 +91,26 @@ class SettingsViewModel( ) } catch (e: Exception) { if (e.isInternetError()) { - _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection))) + _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))) + _uiMessage.emit( + UIMessage.SnackBarMessage( + resourceManager.getString(R.string.core_error_unknown_error) + ) + ) } } finally { cookieManager.clearWebViewCookie() + appNotifier.send(LogoutEvent(false)) _successLogout.emit(true) } } } - private fun collectAppUpgradeEvent() { - viewModelScope.launch { - appUpgradeNotifier.notifier.collect { event -> - _appUpgradeEvent.value = event - } - } - } - private fun collectProfileEvent() { viewModelScope.launch { profileNotifier.notifier.collect { @@ -124,12 +122,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 +136,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 +145,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 +158,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 +180,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/ProfileUI.kt b/profile/src/main/java/org/openedx/profile/presentation/ui/ProfileUI.kt index d0004256f..c87afd492 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/ui/ProfileUI.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/ui/ProfileUI.kt @@ -118,7 +118,8 @@ fun ProfileInfoSection(account: Account) { val mockAccount = Account( username = "thom84", - bio = "He as compliment unreserved projecting. Between had observe pretend delight for believe. Do newspaper questions consulted sweetness do. Our sportsman his unwilling fulfilled departure law.", + bio = "He as compliment unreserved projecting. Between had observe pretend delight for believe. Do newspaper " + + "questions consulted sweetness do. Our sportsman his unwilling fulfilled departure law.", requiresParentalConsent = true, name = "Thomas", country = "Ukraine", 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..7a41a916e 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,11 +6,12 @@ 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 import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -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( @@ -31,14 +32,17 @@ fun SettingsItem( val icon = if (external) { Icons.AutoMirrored.Filled.OpenInNew } else { - Icons.AutoMirrored.Filled.ArrowForwardIos + Icons.AutoMirrored.Filled.KeyboardArrowRight } Row( Modifier .testTag("btn_${text.tagId()}") .fillMaxWidth() .clickable { onClick() } - .padding(20.dp), + .padding( + vertical = 24.dp, + horizontal = 20.dp + ), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { @@ -53,9 +57,20 @@ fun SettingsItem( color = MaterialTheme.appColors.textPrimary ) Icon( - modifier = Modifier.size(16.dp), + modifier = Modifier.size(22.dp), imageVector = icon, contentDescription = null ) } } + +@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..5cbfc0635 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 @@ -25,7 +25,7 @@ import androidx.compose.material.Switch import androidx.compose.material.SwitchDefaults import androidx.compose.material.Text import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -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 @@ -106,7 +106,6 @@ class VideoSettingsFragment : Fragment() { } } } - } @OptIn(ExperimentalComposeUiApi::class) @@ -254,7 +253,7 @@ private fun VideoSettingsScreen( ) } Icon( - imageVector = Icons.Filled.ChevronRight, + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, tint = MaterialTheme.appColors.onSurface, contentDescription = stringResource(CoreR.string.core_accessibility_expandable_arrow) ) @@ -285,7 +284,7 @@ private fun VideoSettingsScreen( ) } Icon( - imageVector = Icons.Filled.ChevronRight, + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, tint = MaterialTheme.appColors.onSurface, contentDescription = stringResource(CoreR.string.core_accessibility_expandable_arrow) ) 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..670447ddb 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 @@ -61,13 +61,15 @@ class VideoSettingsViewModel( fun navigateToVideoStreamingQuality(fragmentManager: FragmentManager) { router.navigateToVideoQuality( - fragmentManager, VideoQualityType.Streaming + fragmentManager, + VideoQualityType.Streaming ) } fun navigateToVideoDownloadQuality(fragmentManager: FragmentManager) { router.navigateToVideoQuality( - fragmentManager, VideoQualityType.Download + fragmentManager, + VideoQualityType.Download ) } 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..93d98f316 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 { @@ -13,5 +14,4 @@ class ProfileNotifier { suspend fun send(event: AccountUpdated) = channel.emit(event) suspend fun send(event: AccountDeactivated) = channel.emit(event) - } 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..1de55c683 100644 --- a/profile/src/main/res/values/strings.xml +++ b/profile/src/main/res/values/strings.xml @@ -1,13 +1,8 @@ - Profile info - Bio: %1$s - Year of Birth: %1$s Full profile Limited profile Edit Profile - Edit - Save Delete Account You must be over 13 years old to have a profile with full access to information. Year of Birth @@ -27,7 +22,6 @@ Change profile image Select from gallery Remove photo - Settings Leave without saving? Leave Keep editing @@ -37,7 +31,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..9ea2f1d5f 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,34 @@ 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.config.Config +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 @@ -35,10 +44,12 @@ class EditProfileViewModelTest { private val interactor = mockk() private val notifier = mockk() private val analytics = mockk() + private val config = mockk() private val account = Account( username = "thom84", - bio = "He as compliment unreserved projecting. Between had observe pretend delight for believe. Do newspaper questions consulted sweetness do. Our sportsman his unwilling fulfilled departure law.", + bio = "He as compliment unreserved projecting. Between had observe pretend delight for believe. Do newspaper " + + "questions consulted sweetness do. Our sportsman his unwilling fulfilled departure law.", requiresParentalConsent = true, name = "Thomas", country = "Ukraine", @@ -64,6 +75,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 @@ -74,7 +86,7 @@ class EditProfileViewModelTest { @Test fun `updateAccount no internet connection`() = runTest { val viewModel = - EditProfileViewModel(interactor, resourceManager, notifier, analytics, account) + EditProfileViewModel(interactor, resourceManager, notifier, analytics, config, account) coEvery { interactor.updateAccount(any()) } throws UnknownHostException() viewModel.updateAccount(emptyMap()) advanceUntilIdle() @@ -89,7 +101,7 @@ class EditProfileViewModelTest { @Test fun `updateAccount unknown exception`() = runTest { val viewModel = - EditProfileViewModel(interactor, resourceManager, notifier, analytics, account) + EditProfileViewModel(interactor, resourceManager, notifier, analytics, config, account) coEvery { interactor.updateAccount(any()) } throws Exception() viewModel.updateAccount(emptyMap()) @@ -105,7 +117,7 @@ class EditProfileViewModelTest { @Test fun `updateAccount success`() = runTest { val viewModel = - EditProfileViewModel(interactor, resourceManager, notifier, analytics, account) + EditProfileViewModel(interactor, resourceManager, notifier, analytics, config, account) coEvery { interactor.updateAccount(any()) } returns account coEvery { notifier.send(any()) } returns Unit every { analytics.logEvent(any(), any()) } returns Unit @@ -122,7 +134,7 @@ class EditProfileViewModelTest { @Test fun `updateAccountAndImage no internet connection`() = runTest { val viewModel = - EditProfileViewModel(interactor, resourceManager, notifier, analytics, account) + EditProfileViewModel(interactor, resourceManager, notifier, analytics, config, account) coEvery { interactor.setProfileImage(any(), any()) } throws UnknownHostException() coEvery { interactor.updateAccount(any()) } returns account coEvery { notifier.send(AccountUpdated()) } returns Unit @@ -142,7 +154,7 @@ class EditProfileViewModelTest { @Test fun `updateAccountAndImage unknown exception`() = runTest { val viewModel = - EditProfileViewModel(interactor, resourceManager, notifier, analytics, account) + EditProfileViewModel(interactor, resourceManager, notifier, analytics, config, account) coEvery { interactor.setProfileImage(any(), any()) } throws Exception() coEvery { interactor.updateAccount(any()) } returns account coEvery { notifier.send(AccountUpdated()) } returns Unit @@ -162,7 +174,7 @@ class EditProfileViewModelTest { @Test fun `updateAccountAndImage success`() = runTest { val viewModel = - EditProfileViewModel(interactor, resourceManager, notifier, analytics, account) + EditProfileViewModel(interactor, resourceManager, notifier, analytics, config, account) coEvery { interactor.setProfileImage(any(), any()) } returns Unit coEvery { interactor.updateAccount(any()) } returns account coEvery { notifier.send(any()) } returns Unit @@ -172,6 +184,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()) } @@ -183,10 +196,9 @@ class EditProfileViewModelTest { @Test fun `setImageUri set new value`() { val viewModel = - EditProfileViewModel(interactor, resourceManager, notifier, analytics, account) + EditProfileViewModel(interactor, resourceManager, notifier, analytics, config, account) viewModel.setImageUri(mockk()) assert(viewModel.selectedImageUri.value != null) } - } 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..8f7fdf53a 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 @@ -122,4 +122,4 @@ class AnothersProfileViewModelTest { assert(viewModel.uiState.value is AnothersProfileUIState.Data) assert(viewModel.uiMessage.value == null) } -} \ No newline at end of file +} 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..a58940420 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.12.14") } } } @@ -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 @@ -47,3 +46,4 @@ include ':discovery' include ':profile' include ':discussion' include ':whatsnew' +include ':downloads' diff --git a/whatsnew/build.gradle b/whatsnew/build.gradle index 4a400063e..679843623 100644 --- a/whatsnew/build.gradle +++ b/whatsnew/build.gradle @@ -2,15 +2,16 @@ plugins { id 'com.android.library' id 'org.jetbrains.kotlin.android' id 'kotlin-parcelize' + id "org.jetbrains.kotlin.plugin.compose" } android { namespace 'org.openedx.whatsnew' - compileSdk 34 + compileSdkVersion compile_sdk_version defaultConfig { - minSdk 24 - targetSdk 34 + minSdk min_sdk_version + targetSdk target_sdk_version testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles "consumer-rules.pro" @@ -18,18 +19,19 @@ android { buildTypes { release { - minifyEnabled true + minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } - compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 + sourceCompatibility java_version + targetCompatibility java_version } - - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17 + kotlin { + compilerOptions { + jvmTarget = jvm_target_version + freeCompilerArgs = ['-XXLanguage:+PropertyParamAnnotationDefaultTargetMode'] + } } buildFeatures { @@ -37,10 +39,6 @@ android { compose true } - composeOptions { - kotlinCompilerExtensionVersion = "$compose_compiler_version" - } - flavorDimensions += "env" productFlavors { prod { @@ -53,16 +51,15 @@ android { dimension 'env' } } -} -dependencies { - implementation project(path: ":core") + dependencies { + implementation project(path: ":core") - androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.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" + testImplementation "junit:junit:$junit_version" + testImplementation "io.mockk:mockk:$mockk_version" + testImplementation "androidx.arch.core:core-testing:$android_arch_version" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlinx_coroutines_test_version" + androidTestImplementation "androidx.test.ext:junit:$test_ext_version" + androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_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/androidTest/java/org/openedx/whatsnew/ExampleInstrumentedTest.kt b/whatsnew/src/androidTest/java/org/openedx/whatsnew/ExampleInstrumentedTest.kt index 5b65b0c9d..597e60f30 100644 --- a/whatsnew/src/androidTest/java/org/openedx/whatsnew/ExampleInstrumentedTest.kt +++ b/whatsnew/src/androidTest/java/org/openedx/whatsnew/ExampleInstrumentedTest.kt @@ -1,13 +1,11 @@ package org.openedx.whatsnew -import androidx.test.platform.app.InstrumentationRegistry import androidx.test.ext.junit.runners.AndroidJUnit4 - +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith -import org.junit.Assert.* - /** * Instrumented test, which will execute on an Android device. * @@ -21,4 +19,4 @@ class ExampleInstrumentedTest { val appContext = InstrumentationRegistry.getInstrumentation().targetContext assertEquals("org.openedx.whatsnew.test", appContext.packageName) } -} \ No newline at end of file +} diff --git a/whatsnew/src/main/java/org/openedx/whatsnew/WhatsNewManager.kt b/whatsnew/src/main/java/org/openedx/whatsnew/WhatsNewManager.kt index 71a51d3b6..011df85a1 100644 --- a/whatsnew/src/main/java/org/openedx/whatsnew/WhatsNewManager.kt +++ b/whatsnew/src/main/java/org/openedx/whatsnew/WhatsNewManager.kt @@ -24,8 +24,8 @@ class WhatsNewManager( override fun shouldShowWhatsNew(): Boolean { val dataVersion = getNewestData().version - return appData.versionName == dataVersion - && whatsNewPreferences.lastWhatsNewVersion != dataVersion - && config.isWhatsNewEnabled() + return appData.versionName == dataVersion && + whatsNewPreferences.lastWhatsNewVersion != dataVersion && + config.isWhatsNewEnabled() } } 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/data/model/WhatsNewItem.kt b/whatsnew/src/main/java/org/openedx/whatsnew/data/model/WhatsNewItem.kt index e0c029d53..ef897bbde 100644 --- a/whatsnew/src/main/java/org/openedx/whatsnew/data/model/WhatsNewItem.kt +++ b/whatsnew/src/main/java/org/openedx/whatsnew/data/model/WhatsNewItem.kt @@ -13,4 +13,4 @@ data class WhatsNewItem( version = version, messages = messages.map { it.mapToDomain(context) } ) -} \ No newline at end of file +} diff --git a/whatsnew/src/main/java/org/openedx/whatsnew/data/storage/WhatsNewPreferences.kt b/whatsnew/src/main/java/org/openedx/whatsnew/data/storage/WhatsNewPreferences.kt index 6270f809e..237ef95bb 100644 --- a/whatsnew/src/main/java/org/openedx/whatsnew/data/storage/WhatsNewPreferences.kt +++ b/whatsnew/src/main/java/org/openedx/whatsnew/data/storage/WhatsNewPreferences.kt @@ -2,4 +2,4 @@ package org.openedx.whatsnew.data.storage interface WhatsNewPreferences { var lastWhatsNewVersion: String -} \ No newline at end of file +} 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..2c2765619 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 @@ -1,21 +1,17 @@ package org.openedx.whatsnew.presentation.ui import androidx.compose.animation.AnimatedContent -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.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Arrangement 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.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults @@ -23,20 +19,18 @@ import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.OutlinedButton import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha -import androidx.compose.ui.geometry.CornerRadius -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag 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.compose.ui.unit.dp import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors @@ -44,96 +38,6 @@ import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography import org.openedx.whatsnew.R -@Composable -fun PageIndicator( - numberOfPages: Int, - modifier: Modifier = Modifier, - selectedPage: Int = 0, - selectedColor: Color = MaterialTheme.appColors.info, - previousUnselectedColor: Color = MaterialTheme.appColors.cardViewBorder, - nextUnselectedColor: Color = MaterialTheme.appColors.textFieldBorder, - defaultRadius: Dp = 20.dp, - selectedLength: Dp = 60.dp, - space: Dp = 30.dp, - animationDurationInMillis: Int = 300, -) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(space), - modifier = modifier, - ) { - for (i in 0 until numberOfPages) { - val isSelected = i == selectedPage - val unselectedColor = - if (i < selectedPage) previousUnselectedColor else nextUnselectedColor - PageIndicatorView( - isSelected = isSelected, - selectedColor = selectedColor, - defaultColor = unselectedColor, - defaultRadius = defaultRadius, - selectedLength = selectedLength, - animationDurationInMillis = animationDurationInMillis, - ) - } - } -} - -@Composable -fun PageIndicatorView( - isSelected: Boolean, - selectedColor: Color, - defaultColor: Color, - defaultRadius: Dp, - selectedLength: Dp, - animationDurationInMillis: Int, - modifier: Modifier = Modifier, -) { - - val color: Color by animateColorAsState( - targetValue = if (isSelected) { - selectedColor - } else { - defaultColor - }, - animationSpec = tween( - durationMillis = animationDurationInMillis, - ), - label = "" - ) - val width: Dp by animateDpAsState( - targetValue = if (isSelected) { - selectedLength - } else { - defaultRadius - }, - animationSpec = tween( - durationMillis = animationDurationInMillis, - ), - label = "" - ) - - Canvas( - modifier = modifier - .size( - width = width, - height = defaultRadius, - ), - ) { - drawRoundRect( - color = color, - topLeft = Offset.Zero, - size = Size( - width = width.toPx(), - height = defaultRadius.toPx(), - ), - cornerRadius = CornerRadius( - x = defaultRadius.toPx(), - y = defaultRadius.toPx(), - ), - ) - } -} - @Composable fun NavigationUnitsButtons( hasPrevPage: Boolean, @@ -164,7 +68,7 @@ fun PrevButton( ) { val prevButtonAnimationFactor by animateFloatAsState( targetValue = if (hasPrevPage) 1f else 0f, - animationSpec = tween(300), + animationSpec = tween(durationMillis = 300), label = "" ) @@ -186,7 +90,7 @@ fun PrevButton( horizontalArrangement = Arrangement.Center ) { Icon( - painter = painterResource(id = org.openedx.core.R.drawable.core_ic_back), + imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null, tint = MaterialTheme.appColors.primary ) @@ -210,7 +114,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 +135,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), + imageVector = Icons.AutoMirrored.Filled.ArrowForward, contentDescription = null, - tint = MaterialTheme.appColors.buttonText + tint = MaterialTheme.appColors.primaryButtonText ) } } else { @@ -249,14 +153,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 ) } } @@ -302,13 +206,3 @@ private fun NavigationUnitsButtonsPrevInTheEnd() { ) } } - -@Preview -@Composable -private fun PageIndicatorViewPreview() { - OpenEdXTheme { - PageIndicator( - numberOfPages = 4, selectedPage = 2 - ) - } -} \ No newline at end of file diff --git a/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewFragment.kt b/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewFragment.kt index da0458054..22dc96737 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 @@ -5,7 +5,6 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup import androidx.compose.animation.Crossfade -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -15,6 +14,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,18 +56,19 @@ import androidx.fragment.app.Fragment import kotlinx.coroutines.launch import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf -import org.openedx.core.ui.WindowSize +import org.openedx.core.ui.PageIndicator 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 -import org.openedx.whatsnew.presentation.ui.PageIndicator +import org.openedx.whatsnew.presentation.whatsnew.WhatsNewFragment.Companion.BASE_ALPHA_VALUE class WhatsNewFragment : Fragment() { @@ -108,6 +109,7 @@ class WhatsNewFragment : Fragment() { companion object { private const val ARG_COURSE_ID = "courseId" private const val ARG_INFO_TYPE = "info_type" + const val BASE_ALPHA_VALUE = 0.2f fun newInstance(courseId: String? = null, infoType: String? = null): WhatsNewFragment { val fragment = WhatsNewFragment() @@ -120,7 +122,7 @@ class WhatsNewFragment : Fragment() { } } -@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class) +@OptIn(ExperimentalComposeUiApi::class) @Composable fun WhatsNewScreen( windowSize: WindowSize, @@ -140,6 +142,7 @@ fun WhatsNewScreen( .semantics { testTagsAsResourceId = true } + .navigationBarsPadding() .fillMaxSize(), scaffoldState = scaffoldState, topBar = { @@ -174,7 +177,6 @@ fun WhatsNewScreen( } } -@OptIn(ExperimentalFoundationApi::class) @Composable private fun WhatsNewTopBar( windowSize: WindowSize, @@ -229,7 +231,6 @@ private fun WhatsNewTopBar( } } -@OptIn(ExperimentalFoundationApi::class) @Composable private fun WhatsNewScreenPortrait( modifier: Modifier = Modifier, @@ -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), @@ -338,7 +339,6 @@ private fun WhatsNewScreenPortrait( } } -@OptIn(ExperimentalFoundationApi::class) @Composable private fun WhatsNewScreenLandscape( modifier: Modifier = Modifier, @@ -365,7 +365,7 @@ private fun WhatsNewScreenLandscape( state = pagerState ) { page -> val image = whatsNewItem.messages[page].image - val alpha = (0.2f + pagerState.calculateCurrentOffsetForPage(page)) * 10 + val alpha = (BASE_ALPHA_VALUE + pagerState.calculateCurrentOffsetForPage(page)) * 10 Image( modifier = Modifier .alpha(alpha) @@ -442,7 +442,7 @@ private fun WhatsNewScreenLandscape( } PageIndicator( - modifier = Modifier.weight(0.25f), + modifier = Modifier.weight(weight = 0.25f), numberOfPages = pagerState.pageCount, selectedPage = pagerState.currentPage, defaultRadius = 12.dp, @@ -464,7 +464,6 @@ val whatsNewItemPreview = WhatsNewItem( messages = listOf(whatsNewMessagePreview, whatsNewMessagePreview, whatsNewMessagePreview) ) -@OptIn(ExperimentalFoundationApi::class) @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable @@ -473,12 +472,13 @@ private fun WhatsNewPortraitPreview() { WhatsNewScreenPortrait( whatsNewItem = whatsNewItemPreview, onDoneClick = {}, - pagerState = rememberPagerState { 4 } + pagerState = rememberPagerState( + pageCount = { 4 } + ) ) } } -@OptIn(ExperimentalFoundationApi::class) @Preview( uiMode = Configuration.UI_MODE_NIGHT_NO, device = Devices.AUTOMOTIVE_1024p, @@ -497,7 +497,9 @@ private fun WhatsNewLandscapePreview() { WhatsNewScreenLandscape( whatsNewItem = whatsNewItemPreview, onDoneClick = {}, - pagerState = rememberPagerState { 4 } + pagerState = rememberPagerState( + pageCount = { 4 } + ) ) } } 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 diff --git a/whatsnew/src/test/java/org/openedx/whatsnew/WhatsNewViewModelTest.kt b/whatsnew/src/test/java/org/openedx/whatsnew/WhatsNewViewModelTest.kt index c6cbe3573..d99555c49 100644 --- a/whatsnew/src/test/java/org/openedx/whatsnew/WhatsNewViewModelTest.kt +++ b/whatsnew/src/test/java/org/openedx/whatsnew/WhatsNewViewModelTest.kt @@ -41,4 +41,4 @@ class WhatsNewViewModelTest { verify(exactly = 1) { whatsNewManager.getNewestData() } assert(viewModel.whatsNewItem.value == whatsNewItem) } -} \ No newline at end of file +}