diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index f51deef091a..99dad543c1b 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -3,6 +3,8 @@ *** For entries which are touching the Android Wear app's, start entry with `[WEAR]` too. 20.8 ----- +- [*] [Payments] Fix learn more link for Pay in Person option in payments settings [https://github.com/woocommerce/woocommerce-android/pull/12786] +- [*] Added objective selection in the blaze campaign creation flow [https://github.com/woocommerce/woocommerce-android/pull/12781] - [Internal] WordPress.com black-flagged websites can now be accessed from the app using site credentials [https://github.com/woocommerce/woocommerce-android/issues/12031] 20.7 diff --git a/WooCommerce-Wear/build.gradle b/WooCommerce-Wear/build.gradle index 9af1466b7fb..b82c097ea5a 100644 --- a/WooCommerce-Wear/build.gradle +++ b/WooCommerce-Wear/build.gradle @@ -1,12 +1,12 @@ import io.sentry.android.gradle.extensions.InstrumentationFeature plugins { - id 'com.android.application' - id 'org.jetbrains.kotlin.android' - id 'org.jetbrains.kotlin.plugin.parcelize' - id 'com.google.dagger.hilt.android' - id 'com.google.devtools.ksp' - id 'io.sentry.android.gradle' + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.parcelize) + alias(libs.plugins.google.dagger.hilt) + alias(libs.plugins.ksp) + alias(libs.plugins.sentry) } repositories { @@ -88,7 +88,7 @@ android { compose true } composeOptions { - kotlinCompilerExtensionVersion composeCompilerVersion + kotlinCompilerExtensionVersion libs.versions.androidx.compose.compiler.get() } packaging { resources { @@ -127,95 +127,95 @@ android { dependencies { // Project implementation project(":libs:commons") - implementation("${gradle.ext.fluxCBinaryPath}:$fluxCVersion") { + implementation("${gradle.ext.fluxCBinaryPath}:${libs.versions.wordpress.fluxc.get()}") { exclude group: "com.android.support" exclude group: "org.wordpress", module: "utils" } - implementation("${gradle.ext.fluxCWooCommercePluginBinaryPath}:$fluxCVersion") { + implementation("${gradle.ext.fluxCWooCommercePluginBinaryPath}:${libs.versions.wordpress.fluxc.get()}") { exclude group: "com.android.support" exclude group: "org.wordpress", module: "utils" } - implementation("org.wordpress:utils:$wordPressUtilsVersion") { + implementation(libs.wordpress.utils) { exclude group: "com.mcxiaoke.volley" exclude group: "com.android.support" } - implementation "com.automattic:Automattic-Tracks-Android:$automatticTracksVersion" - implementation "com.automattic.tracks:crashlogging:$automatticTracksVersion" + implementation(libs.automattic.tracks.android) + implementation(libs.automattic.tracks.crashlogging) // WearOS - implementation "com.google.android.gms:play-services-wearable:$googlePlayWearableVersion" - implementation 'androidx.wear.tiles:tiles' - implementation 'androidx.wear.tiles:tiles-material' - implementation "com.google.android.horologist:horologist-compose-tools:$wearHorologistVersion" - implementation "com.google.android.horologist:horologist-tiles:$wearHorologistVersion" - implementation "com.google.android.horologist:horologist-compose-layout:$wearHorologistVersion" - implementation 'androidx.wear.watchface:watchface-complications-data-source-ktx:1.2.1' - implementation "androidx.wear.compose:compose-material:$wearComposeVersion" - implementation "androidx.wear.compose:compose-foundation:$wearComposeVersion" - implementation 'androidx.wear:wear-tooling-preview:1.0.0' + implementation(libs.google.play.services.wearable) + implementation(libs.androidx.wear.tiles.main) + implementation(libs.androidx.wear.tiles.material) + implementation(libs.google.horologist.compose.tools) + implementation(libs.google.horologist.tiles) + implementation(libs.google.horologist.compose.layout) + implementation(libs.androidx.wear.watchface.complications.data.source.ktx) + implementation(libs.androidx.wear.compose.material) + implementation(libs.androidx.wear.compose.foundation) + implementation(libs.androidx.wear.tooling.preview) // Compose - implementation platform("androidx.compose:compose-bom:$composeBOMVersion") - implementation 'androidx.compose.ui:ui' - implementation 'androidx.compose.ui:ui-tooling-preview' - implementation 'androidx.compose.material:material' - implementation 'androidx.compose.animation:animation' - implementation 'androidx.compose.ui:ui-tooling' - implementation 'androidx.compose.runtime:runtime-livedata' - implementation "androidx.compose.material:material-icons-extended" - implementation 'androidx.compose.ui:ui-text-google-fonts' + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui.main) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material.main) + implementation(libs.androidx.compose.animation.main) + implementation(libs.androidx.compose.ui.tooling.main) + implementation(libs.androidx.compose.runtime.livedata) + implementation(libs.androidx.compose.material.icons.extended) + implementation(libs.androidx.compose.ui.text.google.fonts) // Android Support - implementation "androidx.work:work-runtime-ktx:$workManagerVersion" - implementation "androidx.lifecycle:lifecycle-viewmodel-savedstate:$lifecycleVersion" - implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion" - implementation 'androidx.core:core-splashscreen:1.0.1' - implementation "androidx.navigation:navigation-compose:2.7.7" - implementation 'androidx.activity:activity-compose' - implementation "androidx.preference:preference-ktx:1.2.1" - implementation "androidx.datastore:datastore-preferences:1.1.0" - implementation "androidx.datastore:datastore:1.1.0" - implementation 'com.google.code.gson:gson:2.10.1' + implementation(libs.androidx.work.runtime.ktx) + implementation(libs.androidx.lifecycle.viewmodel.savedstate) + implementation(libs.androidx.lifecycle.livedata.ktx) + implementation(libs.androidx.core.splashscreen) + implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.preference.ktx) + implementation(libs.androidx.datastore.preferences) + implementation(libs.androidx.datastore.main) + implementation(libs.google.gson) // Coroutines - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-play-services:$coroutinesVersion" - testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion" + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.coroutines.play.services) + testImplementation(libs.kotlinx.coroutines.test) // Dagger & Hilt - implementation "com.google.dagger:hilt-android:$gradle.ext.daggerVersion" - implementation "androidx.hilt:hilt-navigation-fragment:$hiltJetpackVersion" - implementation "androidx.hilt:hilt-common:$hiltJetpackVersion" - implementation "androidx.hilt:hilt-work:$hiltJetpackVersion" - implementation "androidx.hilt:hilt-navigation-compose:1.2.0" - ksp "androidx.hilt:hilt-compiler:$hiltJetpackVersion" - ksp "com.google.dagger:hilt-compiler:$gradle.ext.daggerVersion" - implementation "com.google.dagger:dagger-android-support:$gradle.ext.daggerVersion" - ksp "com.google.dagger:dagger-android-processor:$gradle.ext.daggerVersion" + implementation(libs.google.dagger.hilt.android.main) + implementation(libs.androidx.hilt.navigation.fragment) + implementation(libs.androidx.hilt.common) + implementation(libs.androidx.hilt.work) + implementation(libs.androidx.hilt.navigation.compose) + ksp(libs.androidx.hilt.compiler) + ksp(libs.google.dagger.hilt.compiler) + implementation(libs.google.dagger.android.support) + ksp(libs.google.dagger.android.processor) // Testing - testImplementation "junit:junit:$jUnitVersion" - testImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion" - testImplementation "org.mockito:mockito-inline:$mockitoVersion" - testImplementation "org.jetbrains.kotlin:kotlin-test-junit:$gradle.ext.kotlinVersion" - testImplementation "org.assertj:assertj-core:$assertjVersion" - testImplementation("androidx.arch.core:core-testing:2.1.0", { + testImplementation(libs.junit) + testImplementation(libs.mockito.kotlin) + testImplementation(libs.mockito.inline) + testImplementation(libs.kotlin.test.junit) + testImplementation(libs.assertj.core) + testImplementation(libs.androidx.arch.core.testing) { exclude group: 'com.android.support', module: 'support-compat' exclude group: 'com.android.support', module: 'support-annotations' exclude group: 'com.android.support', module: 'support-core-utils' - }) + } // Debug dependencies - debugImplementation "com.facebook.flipper:flipper:$flipperVersion" - debugImplementation "com.facebook.soloader:soloader:0.10.4" - debugImplementation("com.facebook.flipper:flipper-network-plugin:$flipperVersion") { + debugImplementation(libs.facebook.flipper.main) + debugImplementation(libs.facebook.soloader) + debugImplementation(libs.facebook.flipper.network.plugin) { // Force Flipper to use the okhttp version defined in the fluxc module // okhttp versions higher than 3.9.0 break handling for self-signed SSL sites // See https://github.com/wordpress-mobile/WordPress-FluxC-Android/issues/919 exclude group: 'com.squareup.okhttp3' } - lintChecks "com.android.security.lint:lint:$securityLintVersion" + lintChecks(libs.android.security.lint) } def checkGradlePropertiesFile() { diff --git a/WooCommerce/build.gradle b/WooCommerce/build.gradle index 53282e4f365..2018e455434 100644 --- a/WooCommerce/build.gradle +++ b/WooCommerce/build.gradle @@ -1,16 +1,16 @@ import io.sentry.android.gradle.extensions.InstrumentationFeature plugins { - id 'com.android.application' - id 'org.jetbrains.kotlin.android' - id 'org.jetbrains.kotlin.plugin.parcelize' - id 'com.google.dagger.hilt.android' - id 'io.sentry.android.gradle' - id 'androidx.navigation.safeargs.kotlin' - id 'com.google.gms.google-services' - id 'com.google.devtools.ksp' - id "com.google.protobuf" - id "com.osacky.fladle" + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.parcelize) + alias(libs.plugins.google.dagger.hilt) + alias(libs.plugins.sentry) + alias(libs.plugins.androidx.navigation.safeargs) + alias(libs.plugins.google.services) + alias(libs.plugins.ksp) + alias(libs.plugins.google.protobuf) + alias(libs.plugins.fladle) } fladle { @@ -151,7 +151,7 @@ android { } composeOptions { - kotlinCompilerExtensionVersion composeCompilerVersion + kotlinCompilerExtensionVersion libs.versions.androidx.compose.compiler.get() } flavorDimensions "buildType" @@ -221,126 +221,126 @@ android { } dependencies { - implementation("org.wordpress:libaddressinput.common:$libaddressinputVersion") { + implementation(libs.wordpress.libaddressinput.common) { exclude group: "org.json", module: "json" exclude group: "com.google.guava", module: "guava" } - implementation platform('com.google.firebase:firebase-bom:32.7.1') - implementation 'com.google.firebase:firebase-messaging' - implementation 'com.google.firebase:firebase-config' - implementation 'com.google.firebase:firebase-analytics' + implementation(platform(libs.google.firebase.bom)) + implementation(libs.google.firebase.messaging) + implementation(libs.google.firebase.config) + implementation(libs.google.firebase.analytics) - implementation 'com.google.android.gms:play-services-auth:20.2.0' + implementation(libs.google.play.services.auth) // Support library - implementation "androidx.core:core-ktx:$coreKtxVersion" - implementation "androidx.constraintlayout:constraintlayout:2.1.4" - implementation "androidx.recyclerview:recyclerview:1.3.2" - implementation "androidx.recyclerview:recyclerview-selection:1.1.0" - implementation "androidx.appcompat:appcompat:$appCompatVersion" - implementation "com.google.android.material:material:$materialVersion" - implementation "androidx.transition:transition:$transitionVersion" - implementation "androidx.cardview:cardview:1.0.0" - implementation("androidx.browser:browser:1.5.0") { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.constraintlayout.main) + implementation(libs.androidx.recyclerview.main) + implementation(libs.androidx.recyclerview.selection) + implementation(libs.androidx.appcompat) + implementation(libs.google.material) + implementation(libs.androidx.transition) + implementation(libs.androidx.cardview) + implementation(libs.androidx.browser) { exclude group: 'com.google.guava', module: 'listenablefuture' } - implementation "androidx.preference:preference:1.2.0" - implementation "androidx.datastore:datastore-preferences:1.0.0" - implementation "androidx.datastore:datastore:1.0.0" + implementation(libs.androidx.preference.main) + implementation(libs.androidx.datastore.preferences) + implementation(libs.androidx.datastore.main) - implementation "androidx.navigation:navigation-common:$gradle.ext.navigationVersion" - implementation "androidx.navigation:navigation-fragment:$gradle.ext.navigationVersion" - implementation "androidx.navigation:navigation-runtime:$gradle.ext.navigationVersion" - implementation "androidx.navigation:navigation-ui:$gradle.ext.navigationVersion" + implementation(libs.androidx.navigation.common) + implementation(libs.androidx.navigation.fragment) + implementation(libs.androidx.navigation.runtime) + implementation(libs.androidx.navigation.ui) - implementation "androidx.work:work-runtime-ktx:$workManagerVersion" + implementation(libs.androidx.work.runtime.ktx) - implementation 'androidx.core:core-splashscreen:1.0.0' + implementation(libs.androidx.core.splashscreen) - implementation("org.wordpress:utils:$wordPressUtilsVersion") { + implementation(libs.wordpress.utils) { exclude group: "com.mcxiaoke.volley" exclude group: "com.android.support" } - implementation("com.automattic.tracks:experimentation:$automatticTracksVersion") { + implementation(libs.automattic.tracks.experimentation) { exclude group: "org.wordpress", module: "fluxc" } - implementation "com.automattic:Automattic-Tracks-Android:$automatticTracksVersion" - implementation "com.automattic.tracks:crashlogging:$automatticTracksVersion" + implementation(libs.automattic.tracks.android) + implementation(libs.automattic.tracks.crashlogging) - implementation("${gradle.ext.fluxCBinaryPath}:$fluxCVersion") { + implementation("${gradle.ext.fluxCBinaryPath}:${libs.versions.wordpress.fluxc.get()}") { exclude group: "com.android.support" exclude group: "org.wordpress", module: "utils" } - implementation("${gradle.ext.fluxCWooCommercePluginBinaryPath}:$fluxCVersion") { + implementation("${gradle.ext.fluxCWooCommercePluginBinaryPath}:${libs.versions.wordpress.fluxc.get()}") { exclude group: "com.android.support" exclude group: "org.wordpress", module: "utils" } - implementation("$gradle.ext.loginFlowBinaryPath:$wordPressLoginVersion") { + implementation("$gradle.ext.loginFlowBinaryPath:${libs.versions.wordpress.login.get()}") { exclude group: "org.wordpress", module: "utils" exclude group: "org.wordpress", module: "fluxc" } - implementation("org.wordpress:aztec:$aztecVersion") { + implementation(libs.wordpress.aztec.main) { exclude group: "com.android.volley" exclude group: "com.android.support" exclude group: "org.wordpress", module: "utils" } - implementation("org.wordpress.aztec:glide-loader:$aztecVersion") { + implementation(libs.wordpress.aztec.glide.loader) { exclude group: "com.android.volley" exclude group: "com.android.support" exclude group: "org.wordpress", module: "utils" } - implementation("com.gravatar:gravatar:$gravatarVersion") + implementation(libs.gravatar) implementation project(":libs:commons") implementation project(":libs:cardreader") debugImplementation project(":libs:iap") - implementation 'com.facebook.shimmer:shimmer:0.5.0' - implementation 'com.github.chrisbanes:PhotoView:2.3.0' + implementation(libs.facebook.shimmer) + implementation(libs.photoview) - implementation "com.automattic:about:$aboutAutomatticVersion" + implementation(libs.automattic.about) // Dagger - implementation "com.google.dagger:hilt-android:$gradle.ext.daggerVersion" - implementation "androidx.hilt:hilt-navigation-fragment:$hiltJetpackVersion" - implementation "androidx.hilt:hilt-common:$hiltJetpackVersion" - implementation "androidx.hilt:hilt-work:$hiltJetpackVersion" + implementation(libs.google.dagger.hilt.android.main) + implementation(libs.androidx.hilt.navigation.fragment) + implementation(libs.androidx.hilt.common) + implementation(libs.androidx.hilt.work) - ksp "androidx.hilt:hilt-compiler:$hiltJetpackVersion" - ksp "com.google.dagger:hilt-compiler:$gradle.ext.daggerVersion" - implementation "com.google.dagger:dagger-android-support:$gradle.ext.daggerVersion" - ksp "com.google.dagger:dagger-android-processor:$gradle.ext.daggerVersion" + ksp(libs.androidx.hilt.compiler) + ksp(libs.google.dagger.hilt.compiler) + implementation(libs.google.dagger.android.support) + ksp(libs.google.dagger.android.processor) - implementation 'com.github.PhilJay:MPAndroidChart:v3.1.0' + implementation(libs.mpandroidchart) - implementation "com.github.bumptech.glide:glide:$glideVersion" - ksp "com.github.bumptech.glide:compiler:$glideVersion" - implementation "com.github.bumptech.glide:volley-integration:$glideVersion@aar" - implementation 'com.google.android.play:app-update:2.1.0' - implementation 'com.google.android.play:review:2.0.1' + implementation(libs.bumptech.glide.main) + ksp(libs.bumptech.glide.compiler) + implementation(libs.bumptech.glide.volley.integration) + implementation(libs.google.play.app.update) + implementation(libs.google.play.review) - implementation 'com.google.android.gms:play-services-code-scanner:16.1.0' + implementation(libs.google.play.services.code.scanner) - implementation "com.google.mlkit:text-recognition:$mlkitTextRecognitionVersion" - implementation "com.google.android.gms:play-services-mlkit-text-recognition-japanese:$mlkitTextRecognitionVersion" - implementation "com.google.android.gms:play-services-mlkit-text-recognition-chinese:$mlkitTextRecognitionVersion" - implementation "com.google.android.gms:play-services-mlkit-text-recognition-korean:$mlkitTextRecognitionVersion" + implementation(libs.google.mlkit.text.recognition.main) + implementation(libs.google.mlkit.text.recognition.japanese) + implementation(libs.google.mlkit.text.recognition.chinese) + implementation(libs.google.mlkit.text.recognition.korean) - implementation "com.google.mlkit:barcode-scanning:$mlkitBarcodeScanningVersion" + implementation(libs.google.mlkit.barcode.scanning) - implementation "com.google.zxing:core:3.5.3" - implementation "com.google.android.gms:play-services-wearable:$googlePlayWearableVersion" + implementation(libs.google.zxing.core) + implementation(libs.google.play.services.wearable) // Debug dependencies - debugImplementation "com.facebook.flipper:flipper:$flipperVersion" - debugImplementation "com.facebook.soloader:soloader:0.10.4" - debugImplementation("com.facebook.flipper:flipper-network-plugin:$flipperVersion") { + debugImplementation(libs.facebook.flipper.main) + debugImplementation(libs.facebook.soloader) + debugImplementation(libs.facebook.flipper.network.plugin) { // Force Flipper to use the okhttp version defined in the fluxc module // okhttp versions higher than 3.9.0 break handling for self-signed SSL sites // See https://github.com/wordpress-mobile/WordPress-FluxC-Android/issues/919 @@ -348,130 +348,131 @@ dependencies { } if (isLeakCanaryEnabled()) { - debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.14' + debugImplementation(libs.squareup.leakcanary.android) } // Dependencies for local unit tests - testImplementation "junit:junit:$jUnitVersion" - testImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion" - testImplementation "org.mockito:mockito-inline:$mockitoVersion" - testImplementation "org.jetbrains.kotlin:kotlin-test-junit:$gradle.ext.kotlinVersion" - testImplementation "org.assertj:assertj-core:$assertjVersion" - testImplementation("androidx.arch.core:core-testing:2.1.0", { + testImplementation(libs.junit) + testImplementation(libs.mockito.kotlin) + testImplementation(libs.mockito.inline) + testImplementation(libs.kotlin.test.junit) + testImplementation(libs.assertj.core) + testImplementation(libs.androidx.arch.core.testing) { exclude group: 'com.android.support', module: 'support-compat' exclude group: 'com.android.support', module: 'support-annotations' exclude group: 'com.android.support', module: 'support-core-utils' - }) + } // Dependencies for Espresso UI tests - androidTestImplementation "androidx.test.ext:junit:$jUnitExtVersion" - androidTestImplementation "androidx.test:rules:$androidxTestCoreVersion" - androidTestImplementation "org.assertj:assertj-core:$assertjVersion" - androidTestImplementation "androidx.test.espresso:espresso-core:$espressoVersion" - androidTestImplementation("androidx.test.espresso:espresso-contrib:$espressoVersion") { + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.androidx.test.main.rules) + androidTestImplementation(libs.assertj.core) + androidTestImplementation(libs.androidx.test.espresso.core) + androidTestImplementation(libs.androidx.test.espresso.contrib) { exclude group: "com.google.protobuf", module: "protobuf-lite" } - androidTestImplementation "com.google.dagger:hilt-android-testing:$gradle.ext.daggerVersion" - kspAndroidTest "com.google.dagger:hilt-android-compiler:$gradle.ext.daggerVersion" - androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.2.0' + androidTestImplementation(libs.google.dagger.hilt.android.testing) + kspAndroidTest(libs.google.dagger.hilt.android.compiler) + androidTestImplementation(libs.androidx.test.uiautomator) // Dependencies for screenshots - androidTestImplementation 'tools.fastlane:screengrab:2.1.1' - androidTestImplementation('com.github.tomakehurst:wiremock') { + androidTestImplementation(libs.fastlane.screengrab) + androidTestImplementation(libs.wiremock.get().module.toString()) { exclude group: 'org.apache.httpcomponents', module: 'httpclient' exclude group: 'org.apache.commons', module: 'commons-lang3' exclude group: 'asm', module: 'asm' exclude group: 'org.json', module: 'json' } constraints { - androidTestImplementation("com.github.tomakehurst:wiremock:2.26.3") { + androidTestImplementation(libs.wiremock) { because("newer versions of WireMock use Java APIs not available on Android") } - androidTestImplementation('org.eclipse.jetty:jetty-webapp:9.4.51.v20230217') { + androidTestImplementation(libs.jetty.webapp) { because("version shipped with WireMock 2.26.3 contains security vulnerabilities") } - androidTestImplementation('com.fasterxml.jackson.core:jackson-databind:2.12.7.1') { + androidTestImplementation(libs.jackson.databind) { because("version shipped with WireMock 2.26.3 contains security vulnerabilities") } - androidTestImplementation('com.jayway.jsonpath:json-path:2.9.0') { + androidTestImplementation(libs.json.path) { because("version shipped with WireMock 2.26.3 contains security vulnerabilities") } - androidTestImplementation('commons-fileupload:commons-fileupload:1.5') { + androidTestImplementation(libs.commons.fileupload) { because("version shipped with WireMock 2.26.3 contains security vulnerabilities") } } - androidTestImplementation "org.apache.httpcomponents:httpclient-android:$httpClientAndroidVersion" + androidTestImplementation(libs.apache.http.client.android) - implementation("com.zendesk:support:5.0.8") { + implementation(libs.zendesk.support) { exclude group: 'com.android.support', module: 'support-annotations' } // ViewModel and LiveData - implementation "androidx.fragment:fragment-ktx:1.8.2" - implementation "androidx.activity:activity-ktx:1.8.0" - implementation "androidx.lifecycle:lifecycle-viewmodel-savedstate:$lifecycleVersion" - implementation "androidx.lifecycle:lifecycle-process:$lifecycleVersion" + implementation(libs.androidx.fragment.ktx) + implementation(libs.androidx.activity.ktx) + implementation(libs.androidx.lifecycle.viewmodel.savedstate) + implementation(libs.androidx.lifecycle.process) // Coroutines - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-play-services:$coroutinesVersion" - testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion" + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.coroutines.play.services) + testImplementation(libs.kotlinx.coroutines.test) - testImplementation 'app.cash.turbine:turbine:1.0.0' + testImplementation(libs.cashapp.turbine) - implementation "org.apache.commons:commons-text:$commonsText" - implementation "commons-io:commons-io:$commonsIO" + implementation(libs.apache.commons.text) + implementation(libs.commons.io) - implementation "com.tinder.statemachine:statemachine:$stateMachineVersion" + implementation(libs.tinder.statemachine) - implementation("${gradle.ext.mediaPickerBinaryPath}:$mediapickerVersion") { + implementation("${gradle.ext.mediaPickerBinaryPath}:${libs.versions.wordpress.mediapicker.get()}") { exclude group: "org.wordpress", module: "utils" } - implementation("${gradle.ext.mediaPickerSourceCameraBinaryPath}:$mediapickerVersion") - implementation("${gradle.ext.mediaPickerSourceWordPressBinaryPath}:$mediapickerVersion") { + implementation("${gradle.ext.mediaPickerSourceCameraBinaryPath}:${libs.versions.wordpress.mediapicker.get()}") + implementation("${gradle.ext.mediaPickerSourceWordPressBinaryPath}:${libs.versions.wordpress.mediapicker.get()}") { exclude group: "org.wordpress", module: "utils" exclude group: "org.wordpress", module: "fluxc" } // Jetpack Compose - implementation platform("androidx.compose:compose-bom:$composeBOMVersion") + implementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(platform(libs.androidx.compose.bom)) // Dependencies managed by BOM - implementation 'androidx.activity:activity-compose' - implementation 'androidx.compose.material:material' - implementation 'androidx.compose.animation:animation' - implementation 'androidx.compose.ui:ui-tooling' - implementation 'androidx.compose.runtime:runtime-livedata' - implementation "androidx.compose.material:material-icons-extended" - implementation 'androidx.compose.ui:ui-text-google-fonts' - implementation 'androidx.navigation:navigation-compose' - - implementation 'androidx.hilt:hilt-navigation-compose:1.1.0' - implementation 'androidx.constraintlayout:constraintlayout-compose:1.0.1' - implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2' - implementation "io.coil-kt:coil-compose:$coilVersion" - implementation "io.coil-kt:coil-svg:$coilVersion" - - androidTestImplementation 'androidx.compose.ui:ui-test-junit4:1.3.2' - debugImplementation 'androidx.compose.ui:ui-test-manifest' - - coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4' + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.compose.material.main) + implementation(libs.androidx.compose.animation.main) + implementation(libs.androidx.compose.ui.tooling.main) + implementation(libs.androidx.compose.runtime.livedata) + implementation(libs.androidx.compose.material.icons.extended) + implementation(libs.androidx.compose.ui.text.google.fonts) + implementation(libs.androidx.navigation.compose) + + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.androidx.constraintlayout.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.coil.compose) + implementation(libs.coil.svg) + + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + debugImplementation(libs.androidx.compose.ui.test.manifest) + + coreLibraryDesugaring(libs.android.desugar) // CameraX - implementation "androidx.camera:camera-camera2:$androidxCameraVersion" - implementation "androidx.camera:camera-lifecycle:$androidxCameraVersion" - implementation "androidx.camera:camera-view:$androidxCameraVersion" + implementation(libs.androidx.camera.camera2) + implementation(libs.androidx.camera.lifecycle) + implementation(libs.androidx.camera.view) - implementation "com.google.guava:guava:$guavaVersion" + implementation(libs.google.guava) - implementation "com.google.protobuf:protobuf-javalite:$protobufVersion" + implementation(libs.google.protobuf.javalite) - lintChecks "com.android.security.lint:lint:$securityLintVersion" + lintChecks(libs.android.security.lint) } protobuf { protoc { - artifact = "com.google.protobuf:protoc:$protobufVersion" + artifact = libs.google.protobuf.protoc.get().toString() } // Generates the java Protobuf-lite code for the Protobufs in this project. See diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/AppPrefs.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/AppPrefs.kt index d270fcf1c9c..0b0a4c61232 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/AppPrefs.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/AppPrefs.kt @@ -128,6 +128,7 @@ object AppPrefs { AI_PRODUCT_CREATION_SURVEY_DISMISSED, CUSTOM_FIELDS_TOP_BANNER_DISMISSED, BLAZE_CAMPAIGN_SELECTED_OBJECTIVE, + BLAZE_CAMPAIGN_OBJECTIVE_SWITCH_CHECKED, IS_SITE_WPCOM_SUSPENDED } @@ -278,6 +279,10 @@ object AppPrefs { get() = getString(DeletablePrefKey.BLAZE_CAMPAIGN_SELECTED_OBJECTIVE, "") set(value) = setString(DeletablePrefKey.BLAZE_CAMPAIGN_SELECTED_OBJECTIVE, value) + var blazeCampaignObjectiveSwitchChecked: Boolean + get() = getBoolean(DeletablePrefKey.BLAZE_CAMPAIGN_OBJECTIVE_SWITCH_CHECKED, true) + set(value) = setBoolean(DeletablePrefKey.BLAZE_CAMPAIGN_OBJECTIVE_SWITCH_CHECKED, value) + var isSiteWPComSuspended: Boolean get() = getBoolean(DeletablePrefKey.IS_SITE_WPCOM_SUSPENDED, false) set(value) = setBoolean(DeletablePrefKey.IS_SITE_WPCOM_SUSPENDED, value) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/AppPrefsWrapper.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/AppPrefsWrapper.kt index f0d1b64460d..792e871d6a0 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/AppPrefsWrapper.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/AppPrefsWrapper.kt @@ -38,6 +38,8 @@ class AppPrefsWrapper @Inject constructor() { var blazeCampaignSelectedObjective by AppPrefs::blazeCampaignSelectedObjective + var blazeCampaignObjectiveSwitchChecked by AppPrefs::blazeCampaignObjectiveSwitchChecked + var isSiteWPComSuspended by AppPrefs::isSiteWPComSuspended fun getAppInstallationDate() = AppPrefs.installationDate diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/AppUrls.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/AppUrls.kt index 224a9effb7f..db28fd289cf 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/AppUrls.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/AppUrls.kt @@ -82,10 +82,7 @@ object AppUrls { "https://woocommerce.com/document/woopayments/in-person-payments/tap-to-pay-android/" const val WOOCOMMERCE_LEARN_MORE_ABOUT_PAYMENTS_CASH_ON_DELIVERY = - "https://woocommerce.com/document/getting-started-with-in-person-payments-with-woocommerce-payments/" + - "#add-cod-payment-method" - const val STRIPE_LEARN_MORE_ABOUT_PAYMENTS_CASH_ON_DELIVERY = - "https://woocommerce.com/document/stripe/accept-in-person-payments-with-stripe/#section-8" + "https://woocommerce.com/document/cash-on-delivery/" const val WOOCOMMERCE_PURCHASE_CARD_READER_IN_COUNTRY = "https://woocommerce.com/products/hardware/" diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/analytics/AnalyticsEvent.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/analytics/AnalyticsEvent.kt index d2ef1ee5ab7..beea17d0815 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/analytics/AnalyticsEvent.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/analytics/AnalyticsEvent.kt @@ -1031,6 +1031,7 @@ enum class AnalyticsEvent(override val siteless: Boolean = false) : IAnalyticsEv BLAZE_CREATION_EDIT_LOCATION_SAVE_TAPPED, BLAZE_CREATION_EDIT_DESTINATION_SAVE_TAPPED, BLAZE_CAMPAIGN_CREATION_FEEDBACK, + BLAZE_CAMPAIGN_OBJECTIVE_SAVED, // Hazmat Shipping Declaration CONTAINS_HAZMAT_CHECKED, diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/analytics/AnalyticsTracker.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/analytics/AnalyticsTracker.kt index be8da851d0c..522e3d13f25 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/analytics/AnalyticsTracker.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/analytics/AnalyticsTracker.kt @@ -618,6 +618,7 @@ class AnalyticsTracker private constructor( const val KEY_BLAZE_IS_AI_CONTENT = "is_ai_suggested_ad_content" const val KEY_BLAZE_ERROR = "blaze_creation_error" const val KEY_BLAZE_CAMPAIGN_TYPE = "campaign_type" + const val KEY_BLAZE_OBJECTIVE = "objective" const val PRODUCT_TYPES = "product_types" const val HAS_ADDONS = "has_addons" diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/model/Product.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/model/Product.kt index c27fc2aac06..3fb096e1335 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/model/Product.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/model/Product.kt @@ -83,7 +83,6 @@ data class Product( override val width: Float, override val height: Float, override val weight: Float, - val subscription: SubscriptionDetails?, val isSampleProduct: Boolean, val specialStockStatus: ProductStockStatus? = null, val isConfigurable: Boolean = false, @@ -153,7 +152,6 @@ data class Product( downloadExpiry == product.downloadExpiry && isDownloadable == product.isDownloadable && attributes == product.attributes && - subscription == product.subscription && specialStockStatus == product.specialStockStatus && minAllowedQuantity == product.minAllowedQuantity && maxAllowedQuantity == product.maxAllowedQuantity && @@ -169,8 +167,7 @@ data class Product( get() { return weight > 0 || length > 0 || width > 0 || height > 0 || - shippingClass.isNotEmpty() || - subscription?.oneTimeShipping == true + shippingClass.isNotEmpty() } val productType get() = ProductType.fromString(type) val variationEnabledAttributes @@ -334,7 +331,6 @@ data class Product( downloads = updatedProduct.downloads, downloadLimit = updatedProduct.downloadLimit, downloadExpiry = updatedProduct.downloadExpiry, - subscription = updatedProduct.subscription, specialStockStatus = specialStockStatus, minAllowedQuantity = updatedProduct.minAllowedQuantity, maxAllowedQuantity = updatedProduct.maxAllowedQuantity, @@ -482,20 +478,10 @@ fun Product.toDataModel(storedProductModel: WCProductModel? = null): WCProductMo it.groupOfQuantity = groupOfQuantity ?: -1 it.combineVariationQuantities = combineVariationQuantities ?: false it.password = password - // Subscription details are currently the only editable metadata fields from the app. - it.metadata = subscription?.toMetadataJson().toString() } } fun WCProductModel.toAppModel(): Product { - val productType = ProductType.fromString(type) - val subscription = if ( - productType == ProductType.SUBSCRIPTION || productType == ProductType.VARIABLE_SUBSCRIPTION - ) { - SubscriptionDetailsMapper.toAppModel(this.metadata) - } else { - null - } return Product( remoteId = this.remoteProductId, parentId = this.parentId, @@ -580,7 +566,6 @@ fun WCProductModel.toAppModel(): Product { upsellProductIds = this.getUpsellProductIdList(), variationIds = this.getVariationIdList(), isPurchasable = this.purchasable, - subscription = subscription, isSampleProduct = isSampleProduct, specialStockStatus = if (this.specialStockStatus.isNotNullOrEmpty()) { ProductStockStatus.fromString(this.specialStockStatus) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/model/ProductAggregate.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/model/ProductAggregate.kt new file mode 100644 index 00000000000..96721b7b980 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/model/ProductAggregate.kt @@ -0,0 +1,35 @@ +package com.woocommerce.android.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +/** + * Container class for product and any additional details that are stored as product metadata. + * + * For now, the additional details include subscription details only. + * + * @param product The product. + * @param subscription The subscription details. + */ +@Parcelize +data class ProductAggregate( + val product: Product, + val subscription: SubscriptionDetails? = null +) : Parcelable { + val remoteId: Long + get() = product.remoteId + + val hasShipping: Boolean + get() = product.hasShipping || subscription?.oneTimeShipping == true + + fun isSame(other: ProductAggregate): Boolean { + return product.isSameProduct(other.product) && subscription == other.subscription + } + + fun merge(other: ProductAggregate): ProductAggregate { + return copy( + product = product.mergeProduct(other.product), + subscription = other.subscription + ) + } +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/model/SubscriptionDetails.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/model/SubscriptionDetails.kt index 93417854e08..b7ce2250183 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/model/SubscriptionDetails.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/model/SubscriptionDetails.kt @@ -6,6 +6,7 @@ import com.google.gson.JsonObject import kotlinx.parcelize.Parcelize import org.wordpress.android.fluxc.model.WCProductModel.SubscriptionMetadataKeys import org.wordpress.android.fluxc.model.metadata.WCMetaData +import org.wordpress.android.fluxc.model.metadata.WCMetaDataValue import java.math.BigDecimal @Parcelize @@ -50,3 +51,15 @@ fun SubscriptionDetails.toMetadataJson(): JsonArray { } return jsonArray } + +fun SubscriptionDetails.toMetaData() = mapOf( + SubscriptionMetadataKeys.SUBSCRIPTION_PRICE to WCMetaDataValue(price?.toString().orEmpty()), + SubscriptionMetadataKeys.SUBSCRIPTION_PERIOD to WCMetaDataValue(period.value), + SubscriptionMetadataKeys.SUBSCRIPTION_PERIOD_INTERVAL to WCMetaDataValue(periodInterval), + SubscriptionMetadataKeys.SUBSCRIPTION_LENGTH to WCMetaDataValue(length?.toString().orEmpty()), + SubscriptionMetadataKeys.SUBSCRIPTION_SIGN_UP_FEE to WCMetaDataValue(signUpFee?.toString().orEmpty()), + SubscriptionMetadataKeys.SUBSCRIPTION_TRIAL_PERIOD to + WCMetaDataValue((trialPeriod ?: SubscriptionPeriod.Day).value), + SubscriptionMetadataKeys.SUBSCRIPTION_TRIAL_LENGTH to WCMetaDataValue(trialLength?.toString().orEmpty()), + SubscriptionMetadataKeys.SUBSCRIPTION_ONE_TIME_SHIPPING to WCMetaDataValue(if (oneTimeShipping) "yes" else "no"), +) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/model/SubscriptionDetailsMapper.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/model/SubscriptionDetailsMapper.kt index fc976af7b4c..44e7f39361d 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/model/SubscriptionDetailsMapper.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/model/SubscriptionDetailsMapper.kt @@ -1,85 +1,81 @@ package com.woocommerce.android.model import com.google.gson.Gson -import com.google.gson.JsonArray -import com.google.gson.JsonElement -import com.google.gson.JsonObject +import com.google.gson.JsonParser import org.wordpress.android.fluxc.model.WCProductModel.SubscriptionMetadataKeys import org.wordpress.android.fluxc.model.metadata.WCMetaData +import org.wordpress.android.fluxc.model.metadata.get object SubscriptionDetailsMapper { private val gson by lazy { Gson() } - fun toAppModel(metadata: String): SubscriptionDetails? { - val jsonArray = gson.fromJson(metadata, JsonArray::class.java) ?: return null + fun toAppModel(metadata: List): SubscriptionDetails? { + if (metadata.none { it.key in SubscriptionMetadataKeys.ALL_KEYS }) { + return null + } - val subscriptionInformation = jsonArray - .mapNotNull { it as? JsonObject } - .filter { jsonObject -> jsonObject[WCMetaData.KEY].asString in SubscriptionMetadataKeys.ALL_KEYS } - .associate { jsonObject -> - jsonObject[WCMetaData.KEY].asString to jsonObject[WCMetaData.VALUE] - } + val price = metadata[SubscriptionMetadataKeys.SUBSCRIPTION_PRICE]?.valueAsString?.toBigDecimalOrNull() - return if (subscriptionInformation.isNotEmpty()) { - val price = subscriptionInformation[SubscriptionMetadataKeys.SUBSCRIPTION_PRICE]?.asString - ?.toBigDecimalOrNull() - - val periodString = subscriptionInformation[SubscriptionMetadataKeys.SUBSCRIPTION_PERIOD]?.asString ?: "" - val period = SubscriptionPeriod.fromValue(periodString) - - val periodIntervalString = subscriptionInformation[SubscriptionMetadataKeys.SUBSCRIPTION_PERIOD_INTERVAL] - ?.asString ?: "" - val periodInterval = periodIntervalString.toIntOrNull() ?: 0 - - val lengthInt = subscriptionInformation[SubscriptionMetadataKeys.SUBSCRIPTION_LENGTH]?.asString - ?.toIntOrNull() - val length = if (lengthInt != null && lengthInt > 0) lengthInt else null - - val signUpFee = subscriptionInformation[SubscriptionMetadataKeys.SUBSCRIPTION_SIGN_UP_FEE]?.asString - ?.toBigDecimalOrNull() - - val trialPeriodString = subscriptionInformation[SubscriptionMetadataKeys.SUBSCRIPTION_TRIAL_PERIOD] - ?.asString - val trialPeriod = trialPeriodString?.let { SubscriptionPeriod.fromValue(trialPeriodString) } - - val trialLengthInt = subscriptionInformation[SubscriptionMetadataKeys.SUBSCRIPTION_TRIAL_LENGTH] - ?.asString?.toIntOrNull() - val trialLength = if (trialLengthInt != null && trialLengthInt > 0) trialLengthInt else null - - val oneTimeShipping = subscriptionInformation[SubscriptionMetadataKeys.SUBSCRIPTION_ONE_TIME_SHIPPING] - ?.asString == "yes" - - val paymentsSyncDate = subscriptionInformation[SubscriptionMetadataKeys.SUBSCRIPTION_PAYMENT_SYNC_DATE] - ?.extractPaymentsSyncDate() - - SubscriptionDetails( - price = price, - period = period, - periodInterval = periodInterval, - length = length, - signUpFee = signUpFee, - trialPeriod = trialPeriod, - trialLength = trialLength, - oneTimeShipping = oneTimeShipping, - paymentsSyncDate = paymentsSyncDate - ) - } else { - null - } + val periodString = metadata[SubscriptionMetadataKeys.SUBSCRIPTION_PERIOD]?.valueAsString ?: "" + val period = SubscriptionPeriod.fromValue(periodString) + + val periodIntervalString = metadata[SubscriptionMetadataKeys.SUBSCRIPTION_PERIOD_INTERVAL] + ?.valueAsString ?: "" + val periodInterval = periodIntervalString.toIntOrNull() ?: 0 + + val lengthInt = metadata[SubscriptionMetadataKeys.SUBSCRIPTION_LENGTH]?.valueAsString + ?.toIntOrNull() + val length = if (lengthInt != null && lengthInt > 0) lengthInt else null + + val signUpFee = metadata[SubscriptionMetadataKeys.SUBSCRIPTION_SIGN_UP_FEE]?.valueAsString + ?.toBigDecimalOrNull() + + val trialPeriodString = metadata[SubscriptionMetadataKeys.SUBSCRIPTION_TRIAL_PERIOD] + ?.valueAsString + val trialPeriod = trialPeriodString?.let { SubscriptionPeriod.fromValue(trialPeriodString) } + + val trialLengthInt = metadata[SubscriptionMetadataKeys.SUBSCRIPTION_TRIAL_LENGTH] + ?.valueAsString?.toIntOrNull() + val trialLength = if (trialLengthInt != null && trialLengthInt > 0) trialLengthInt else null + + val oneTimeShipping = metadata[SubscriptionMetadataKeys.SUBSCRIPTION_ONE_TIME_SHIPPING] + ?.valueAsString == "yes" + + val paymentsSyncDate = metadata[SubscriptionMetadataKeys.SUBSCRIPTION_PAYMENT_SYNC_DATE] + ?.extractPaymentsSyncDate() + + return SubscriptionDetails( + price = price, + period = period, + periodInterval = periodInterval, + length = length, + signUpFee = signUpFee, + trialPeriod = trialPeriod, + trialLength = trialLength, + oneTimeShipping = oneTimeShipping, + paymentsSyncDate = paymentsSyncDate + ) + } + + fun toAppModel(metadata: String): SubscriptionDetails? { + val metadataList = gson.fromJson(metadata, Array::class.java)?.toList() ?: return null + + return toAppModel(metadataList) } - private fun JsonElement.extractPaymentsSyncDate(): SubscriptionPaymentSyncDate? { - return when { - isJsonObject -> asJsonObject.let { - val day = it["day"].asInt - val month = it["month"].asInt + private fun WCMetaData.extractPaymentsSyncDate(): SubscriptionPaymentSyncDate? { + return when (isJson) { + true -> value.stringValue.let { + val jsonObject = JsonParser.parseString(it).asJsonObject + val day = jsonObject["day"].asInt + val month = jsonObject["month"].asInt if (day == 0) { SubscriptionPaymentSyncDate.None } else { - SubscriptionPaymentSyncDate.MonthDay(day, month) + SubscriptionPaymentSyncDate.MonthDay(month = month, day = day) } } - else -> asString?.toIntOrNull()?.let { day -> + false -> valueAsString.toIntOrNull()?.let { day -> if (day == 0) { SubscriptionPaymentSyncDate.None } else { diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/BlazeRepository.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/BlazeRepository.kt index d6852a5523c..ea5ddd86748 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/BlazeRepository.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/BlazeRepository.kt @@ -379,6 +379,16 @@ class BlazeRepository @Inject constructor( } } + fun isCampaignObjectiveSwitchChecked() = appPrefsWrapper.blazeCampaignObjectiveSwitchChecked + + fun setCampaignObjectiveSwitchChecked(enabled: Boolean) { + appPrefsWrapper.blazeCampaignObjectiveSwitchChecked = enabled + } + + fun storeSelectedObjective(objectiveId: String) { + appPrefsWrapper.blazeCampaignSelectedObjective = objectiveId + } + @Parcelize data class CampaignDetails( val productId: Long, diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/BlazeCampaignCreationDispatcher.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/BlazeCampaignCreationDispatcher.kt index 817dc891260..6de3b8ec9aa 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/BlazeCampaignCreationDispatcher.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/BlazeCampaignCreationDispatcher.kt @@ -147,9 +147,6 @@ class BlazeCampaignCreationDispatcher @Inject constructor( } private fun BaseFragment.showProductSelector() { - val navGraph = findNavController().graph.findNode(R.id.nav_graph_blaze_campaign_creation) as NavGraph - navGraph.setStartDestination(R.id.nav_graph_product_selector) - findNavController().navigateToBlazeGraph( startDestination = R.id.nav_graph_product_selector, bundle = ProductSelectorFragmentArgs( @@ -166,7 +163,10 @@ class BlazeCampaignCreationDispatcher @Inject constructor( startDestination: Int, bundle: android.os.Bundle? = null, ) { - val navGraph = graph.findNode(R.id.nav_graph_blaze_campaign_creation) as NavGraph + // Retrieve the parent navgraph. + // currentDestination should never be null, but we're being cautious here by falling back to the main graph. + val parentGraph = currentDestination?.parent ?: graph + val navGraph = parentGraph.findNode(R.id.nav_graph_blaze_campaign_creation) as NavGraph navGraph.setStartDestination(startDestination) navigateSafely( diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/objective/BlazeCampaignObjectiveFragment.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/objective/BlazeCampaignObjectiveFragment.kt index f5224873a8a..9ca2717e665 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/objective/BlazeCampaignObjectiveFragment.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/objective/BlazeCampaignObjectiveFragment.kt @@ -4,13 +4,14 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.compose.material.Text import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController +import com.woocommerce.android.extensions.navigateBackWithResult import com.woocommerce.android.ui.base.BaseFragment import com.woocommerce.android.ui.compose.composeView import com.woocommerce.android.ui.main.AppBarStatus import com.woocommerce.android.viewmodel.MultiLiveEvent +import com.woocommerce.android.viewmodel.MultiLiveEvent.Event.ExitWithResult import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint @@ -22,7 +23,7 @@ class BlazeCampaignObjectiveFragment : BaseFragment() { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { return composeView { - Text(text = "Select Campaign Objective") + BlazeCampaignObjectiveScreen(viewModel) } } @@ -34,7 +35,12 @@ class BlazeCampaignObjectiveFragment : BaseFragment() { viewModel.event.observe(viewLifecycleOwner) { event -> when (event) { is MultiLiveEvent.Event.Exit -> findNavController().navigateUp() + is ExitWithResult<*> -> navigateBackWithResult(BLAZE_OBJECTIVE_SELECTION_RESULT, event.data) } } } + + companion object { + const val BLAZE_OBJECTIVE_SELECTION_RESULT = "blaze_objective_selection_result" + } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/objective/BlazeCampaignObjectiveScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/objective/BlazeCampaignObjectiveScreen.kt new file mode 100644 index 00000000000..216f462a25d --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/objective/BlazeCampaignObjectiveScreen.kt @@ -0,0 +1,238 @@ +package com.woocommerce.android.ui.blaze.creation.objective + +import androidx.compose.animation.Crossfade +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Divider +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.automirrored.filled.ArrowBack +import androidx.compose.runtime.Composable +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.woocommerce.android.R +import com.woocommerce.android.ui.blaze.creation.objective.BlazeCampaignObjectiveViewModel.ObjectiveItem +import com.woocommerce.android.ui.blaze.creation.objective.BlazeCampaignObjectiveViewModel.ObjectiveViewState +import com.woocommerce.android.ui.compose.annotatedStringRes +import com.woocommerce.android.ui.compose.component.BottomSheetSwitchColors +import com.woocommerce.android.ui.compose.component.Toolbar +import com.woocommerce.android.ui.compose.component.WCSwitch +import com.woocommerce.android.ui.compose.component.WCTextButton +import com.woocommerce.android.ui.compose.preview.LightDarkThemePreviews + +@Composable +fun BlazeCampaignObjectiveScreen(viewModel: BlazeCampaignObjectiveViewModel) { + viewModel.viewState.observeAsState().value?.let { state -> + ObjectiveScreen( + state = state, + onBackPressed = viewModel::onBackPressed, + onSaveTapped = viewModel::onSaveTapped, + onObjectiveTapped = viewModel::onItemToggled, + onStoreObjectiveSwitchChanged = viewModel::onStoreObjectiveSwitchChanged + ) + } +} + +@Composable +private fun ObjectiveScreen( + state: ObjectiveViewState, + onBackPressed: () -> Unit, + onSaveTapped: () -> Unit, + onObjectiveTapped: (ObjectiveItem) -> Unit, + onStoreObjectiveSwitchChanged: (Boolean) -> Unit +) { + Scaffold( + topBar = { + Toolbar( + title = stringResource(id = R.string.blaze_campaign_preview_details_objective), + onNavigationButtonClick = onBackPressed, + navigationIcon = Icons.AutoMirrored.Filled.ArrowBack, + actions = { + WCTextButton( + onClick = onSaveTapped, + enabled = state.isSaveButtonEnabled, + text = stringResource(R.string.save) + ) + } + ) + }, + modifier = Modifier.background(MaterialTheme.colors.surface) + ) { paddingValues -> + Column( + modifier = Modifier + .background(MaterialTheme.colors.surface) + .padding(paddingValues) + .fillMaxSize() + ) { + LazyColumn( + modifier = Modifier + .padding(vertical = 4.dp) + .weight(1f) + ) { + items(state.items) { + ObjectiveListItem( + title = it.title, + description = it.description, + suitableForDescription = it.suitableForDescription, + isSelected = it.id == state.selectedItemId, + onItemClick = { onObjectiveTapped(it) }, + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = 16.dp, + vertical = 4.dp + ) + ) + } + } + Divider() + WCSwitch( + text = stringResource(id = R.string.blaze_campaign_objective_save_selection_switch_label), + checked = state.isStoreSelectionButtonToggled, + onCheckedChange = { onStoreObjectiveSwitchChanged(it) }, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + colors = BottomSheetSwitchColors() + ) + } + } +} + +@Composable +fun ObjectiveListItem( + title: String, + description: String, + suitableForDescription: String, + isSelected: Boolean, + onItemClick: () -> Unit, + modifier: Modifier = Modifier +) { + val roundedShape = RoundedCornerShape(8.dp) + val borderColor = if (isSelected) R.color.woo_purple_60 else R.color.woo_gray_5 + val updatedModifier = modifier + .clip(roundedShape) + .clickable( + role = Role.Button, + onClick = onItemClick, + onClickLabel = stringResource(id = R.string.blaze_campaign_objective_select_objective_label, title) + ) + .border( + width = if (isSelected) 2.dp else 0.5.dp, + color = colorResource(id = borderColor), + shape = roundedShape + ) + + Row( + modifier = if (isSelected) { + updatedModifier.background(colorResource(id = R.color.blaze_campaign_objective_item_background)) + } else { + updatedModifier + }.padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + val selectionDrawable = if (isSelected) { + R.drawable.ic_rounded_chcekbox_checked + } else { + R.drawable.ic_rounded_chcekbox_unchecked + } + Crossfade( + targetState = selectionDrawable, + label = "itemSelection" + ) { icon -> + Image( + painter = painterResource(id = icon), + contentDescription = null + ) + } + + Column( + modifier = Modifier.animateContentSize(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.subtitle1, + fontWeight = FontWeight.W600, + color = MaterialTheme.colors.onSurface, + ) + Text( + text = description, + style = MaterialTheme.typography.subtitle1, + color = MaterialTheme.colors.onSurface, + ) + if (isSelected) { + Text( + text = annotatedStringRes( + stringResId = R.string.blaze_campaign_objective_good_for, + suitableForDescription + ), + style = MaterialTheme.typography.subtitle1, + color = MaterialTheme.colors.onSurface, + ) + } + } + } +} + +@LightDarkThemePreviews +@Composable +fun PreviewObjectiveScreen() { + ObjectiveScreen( + state = ObjectiveViewState( + items = listOf( + ObjectiveItem( + id = "traffic", + title = "Traffic", + description = "Aims to drive more visitors and increase page views.", + suitableForDescription = "E-commerce sites, content-driven websites, startups." + ), + ObjectiveItem( + id = "sales", + title = "Sales", + description = "Converts potential customers into buyers by encouraging purchase.", + suitableForDescription = "E-commerce, retailers, subscription services." + ), + ObjectiveItem( + id = "awareness", + title = "Awareness", + description = "Focuses on increasing brand recognition and visibility.", + suitableForDescription = "New businesses, brands launching new products." + ), + ObjectiveItem( + id = "engagement", + title = "Engagement", + description = "Encourages your audience to interact and connect with your brand.", + suitableForDescription = "Influencers and community builders looking for followers of the same" + + "interest." + ), + ), + selectedItemId = null + ), + onBackPressed = { }, + onSaveTapped = { }, + onObjectiveTapped = { }, + onStoreObjectiveSwitchChanged = { } + ) +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/objective/BlazeCampaignObjectiveViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/objective/BlazeCampaignObjectiveViewModel.kt index 2d617ba22c9..4a3b3d8396e 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/objective/BlazeCampaignObjectiveViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/objective/BlazeCampaignObjectiveViewModel.kt @@ -1,16 +1,106 @@ package com.woocommerce.android.ui.blaze.creation.objective +import android.os.Parcelable import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.asLiveData +import androidx.lifecycle.viewModelScope +import com.woocommerce.android.analytics.AnalyticsEvent.BLAZE_CAMPAIGN_OBJECTIVE_SAVED +import com.woocommerce.android.analytics.AnalyticsTracker +import com.woocommerce.android.analytics.AnalyticsTrackerWrapper +import com.woocommerce.android.ui.blaze.BlazeRepository import com.woocommerce.android.viewmodel.MultiLiveEvent.Event.Exit +import com.woocommerce.android.viewmodel.MultiLiveEvent.Event.ExitWithResult import com.woocommerce.android.viewmodel.ScopedViewModel +import com.woocommerce.android.viewmodel.getNullableStateFlow +import com.woocommerce.android.viewmodel.getStateFlow +import com.woocommerce.android.viewmodel.navArgs import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.parcelize.Parcelize import javax.inject.Inject @HiltViewModel class BlazeCampaignObjectiveViewModel @Inject constructor( - savedStateHandle: SavedStateHandle + private val blazeRepository: BlazeRepository, + savedStateHandle: SavedStateHandle, + private val analyticsTrackerWrapper: AnalyticsTrackerWrapper ) : ScopedViewModel(savedStateHandle) { - fun onDismissClick() { + private val navArgs: BlazeCampaignObjectiveFragmentArgs by savedStateHandle.navArgs() + + private val selectedId = savedStateHandle.getNullableStateFlow( + scope = viewModelScope, + initialValue = navArgs.selectedObjectiveId, + clazz = String::class.java, + key = "selectedId" + ) + private val storeObjectiveSwitchState = savedState.getStateFlow( + scope = this, + initialValue = blazeRepository.isCampaignObjectiveSwitchChecked(), + key = "storeObjectiveSwitchState" + ) + + private val items: Flow> = + blazeRepository.observeObjectives().map { objectives -> + objectives.map { objective -> + ObjectiveItem(objective.id, objective.title, objective.description, objective.suitableForDescription) + } + } + + val viewState = combine( + items, + selectedId, + storeObjectiveSwitchState + ) { items, selectedId, storeObjectiveSwitchState -> + ObjectiveViewState(items, selectedId, storeObjectiveSwitchState) + }.asLiveData() + + fun onItemToggled(item: ObjectiveItem) { + selectedId.update { item.id } + } + + fun onBackPressed() { triggerEvent(Exit) } + + fun onStoreObjectiveSwitchChanged(checked: Boolean) { + storeObjectiveSwitchState.update { checked } + } + + fun onSaveTapped() { + viewState.value?.isStoreSelectionButtonToggled?.let { + blazeRepository.setCampaignObjectiveSwitchChecked(it) + if (it) { + blazeRepository.storeSelectedObjective(selectedId.value.orEmpty()) + } + } + selectedId.value?.let { + triggerEvent(ExitWithResult(ObjectiveResult(it))) + analyticsTrackerWrapper.track( + stat = BLAZE_CAMPAIGN_OBJECTIVE_SAVED, + properties = mapOf(AnalyticsTracker.KEY_BLAZE_OBJECTIVE to it) + ) + } + } + + data class ObjectiveViewState( + val items: List, + val selectedItemId: String? = null, + val isStoreSelectionButtonToggled: Boolean = true, + ) { + val isSaveButtonEnabled: Boolean + get() = !selectedItemId.isNullOrEmpty() + } + + data class ObjectiveItem( + val id: String, + val title: String, + val description: String, + val suitableForDescription: String, + ) + + @Parcelize + data class ObjectiveResult(val objectiveId: String) : Parcelable } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/preview/BlazeCampaignCreationPreviewFragment.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/preview/BlazeCampaignCreationPreviewFragment.kt index 2ce7f29ade2..29b55aad071 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/preview/BlazeCampaignCreationPreviewFragment.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/preview/BlazeCampaignCreationPreviewFragment.kt @@ -16,6 +16,8 @@ import com.woocommerce.android.ui.blaze.creation.ad.BlazeCampaignCreationEditAdF import com.woocommerce.android.ui.blaze.creation.ad.BlazeCampaignCreationEditAdViewModel.EditAdResult import com.woocommerce.android.ui.blaze.creation.budget.BlazeCampaignBudgetFragment import com.woocommerce.android.ui.blaze.creation.destination.BlazeCampaignCreationAdDestinationFragment +import com.woocommerce.android.ui.blaze.creation.objective.BlazeCampaignObjectiveFragment +import com.woocommerce.android.ui.blaze.creation.objective.BlazeCampaignObjectiveViewModel.ObjectiveResult import com.woocommerce.android.ui.blaze.creation.preview.BlazeCampaignCreationPreviewViewModel.NavigateToAdDestinationScreen import com.woocommerce.android.ui.blaze.creation.preview.BlazeCampaignCreationPreviewViewModel.NavigateToBudgetScreen import com.woocommerce.android.ui.blaze.creation.preview.BlazeCampaignCreationPreviewViewModel.NavigateToEditAdScreen @@ -78,6 +80,11 @@ class BlazeCampaignCreationPreviewFragment : BaseFragment() { ) ) + is NavigateToObjectiveSelectionScreen -> findNavController().navigateSafely( + BlazeCampaignCreationPreviewFragmentDirections + .actionBlazeCampaignCreationPreviewFragmentToBlazeCampaignObjectiveFragment(event.selectedId) + ) + is NavigateToTargetSelectionScreen -> findNavController().navigateSafely( BlazeCampaignCreationPreviewFragmentDirections .actionBlazeCampaignCreationPreviewFragmentToBlazeCampaignTargetSelectionFragment( @@ -107,11 +114,6 @@ class BlazeCampaignCreationPreviewFragment : BaseFragment() { event.campaignDetails ) ) - - is NavigateToObjectiveSelectionScreen -> findNavController().navigateSafely( - BlazeCampaignCreationPreviewFragmentDirections - .actionBlazeCampaignCreationPreviewFragmentToBlazeCampaignObjectiveFragment() - ) } } } @@ -120,6 +122,9 @@ class BlazeCampaignCreationPreviewFragment : BaseFragment() { handleResult(BlazeCampaignCreationEditAdFragment.EDIT_AD_RESULT) { viewModel.onAdUpdated(it.tagline, it.description, it.campaignImage) } + handleResult(BlazeCampaignObjectiveFragment.BLAZE_OBJECTIVE_SELECTION_RESULT) { + viewModel.onObjectiveUpdated(it.objectiveId) + } handleResult(BlazeCampaignBudgetFragment.EDIT_BUDGET_AND_DURATION_RESULT) { viewModel.onBudgetAndDurationUpdated(it) } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/preview/BlazeCampaignCreationPreviewViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/preview/BlazeCampaignCreationPreviewViewModel.kt index 9b68fdb5393..70530bcceb0 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/preview/BlazeCampaignCreationPreviewViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/preview/BlazeCampaignCreationPreviewViewModel.kt @@ -125,6 +125,10 @@ class BlazeCampaignCreationPreviewViewModel @Inject constructor( } } + fun onObjectiveUpdated(objectiveId: String) { + campaignDetails.update { it?.copy(objectiveId = objectiveId) } + } + fun onBudgetAndDurationUpdated(updatedBudget: BlazeRepository.Budget) { campaignDetails.update { it?.copy(budget = updatedBudget) } } @@ -178,7 +182,8 @@ class BlazeCampaignCreationPreviewViewModel @Inject constructor( AnalyticsTracker.VALUE_EVERGREEN_CAMPAIGN else -> AnalyticsTracker.VALUE_START_END_CAMPAIGN - } + }, + AnalyticsTracker.KEY_BLAZE_OBJECTIVE to campaignDetails.value?.objectiveId, ) ) campaignDetails.value?.let { @@ -202,7 +207,7 @@ class BlazeCampaignCreationPreviewViewModel @Inject constructor( isObjectiveMissing && FeatureFlag.OBJECTIVE_SECTION.isEnabled() -> buildMissingRequiredDataDialog( message = R.string.blaze_campaign_preview_missing_objective_dialog_text, positiveButtonText = R.string.blaze_campaign_preview_missing_objective_dialog_positive_button, - positiveButtonOnClick = { triggerEvent(NavigateToObjectiveSelectionScreen) } + positiveButtonOnClick = { triggerEvent(NavigateToObjectiveSelectionScreen(selectedId = null)) } ) else -> triggerEvent(NavigateToPaymentSummary(it)) @@ -283,7 +288,7 @@ class BlazeCampaignCreationPreviewViewModel @Inject constructor( return CampaignDetailItemUi( displayTitle = resourceProvider.getString(R.string.blaze_campaign_preview_details_objective), displayValue = selectedObjectiveDisplayValue, - onItemSelected = { triggerEvent(NavigateToObjectiveSelectionScreen) } + onItemSelected = { triggerEvent(NavigateToObjectiveSelectionScreen(campaignDetails.value?.objectiveId)) } ) } @@ -441,9 +446,9 @@ class BlazeCampaignCreationPreviewViewModel @Inject constructor( val aiSuggestions: List ) : MultiLiveEvent.Event() + data class NavigateToObjectiveSelectionScreen(val selectedId: String? = null) : MultiLiveEvent.Event() + data class NavigateToPaymentSummary( val campaignDetails: CampaignDetails ) : MultiLiveEvent.Event() - - data object NavigateToObjectiveSelectionScreen : MultiLiveEvent.Event() } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/compose/ViewModelFactory.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/compose/ViewModelFactory.kt deleted file mode 100644 index ce70220080f..00000000000 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/compose/ViewModelFactory.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.woocommerce.android.ui.compose - -import androidx.compose.runtime.Composable -import androidx.lifecycle.HasDefaultViewModelProviderFactory -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelStoreOwner -import androidx.lifecycle.viewmodel.CreationExtras -import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner -import androidx.lifecycle.viewmodel.compose.viewModel -import dagger.hilt.android.lifecycle.withCreationCallback - -/** - * Based on the androidx "hiltViewModel" implementation: - * hilt/hilt-navigation-compose/src/main/java/androidx/hilt/navigation/compose/HiltViewModel.kt - */ -@Composable -inline fun viewModelWithFactory( - viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) { - "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner" - }, - key: String? = null, - noinline creationCallback: (VMF) -> VM -): VM { - return viewModel( - viewModelStoreOwner = viewModelStoreOwner, - key = key, - extras = viewModelStoreOwner.run { - if (this is HasDefaultViewModelProviderFactory) { - this.defaultViewModelCreationExtras.withCreationCallback(creationCallback) - } else { - CreationExtras.Empty.withCreationCallback(creationCallback) - } - } - ) -} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/blaze/DashboardBlazeCard.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/blaze/DashboardBlazeCard.kt index d7b6279b60e..cd8e7295e91 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/blaze/DashboardBlazeCard.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/blaze/DashboardBlazeCard.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.Observer import com.woocommerce.android.NavGraphMainDirections @@ -45,7 +46,6 @@ import com.woocommerce.android.ui.compose.component.WCOutlinedButton import com.woocommerce.android.ui.compose.preview.LightDarkThemePreviews import com.woocommerce.android.ui.compose.rememberNavController import com.woocommerce.android.ui.compose.theme.WooThemeWithBackground -import com.woocommerce.android.ui.compose.viewModelWithFactory import com.woocommerce.android.ui.dashboard.DashboardFragmentDirections import com.woocommerce.android.ui.dashboard.DashboardViewModel import com.woocommerce.android.ui.dashboard.DashboardViewModel.DashboardWidgetAction @@ -64,7 +64,7 @@ fun DashboardBlazeCard( activityViewModel: MainActivityViewModel, parentViewModel: DashboardViewModel, modifier: Modifier = Modifier, - viewModel: DashboardBlazeViewModel = viewModelWithFactory { factory: DashboardBlazeViewModel.Factory -> + viewModel: DashboardBlazeViewModel = hiltViewModel { factory: DashboardBlazeViewModel.Factory -> factory.create(parentViewModel) } ) { diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/coupons/DashboardCouponsCard.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/coupons/DashboardCouponsCard.kt index 065b5686b16..f109537a3ab 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/coupons/DashboardCouponsCard.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/coupons/DashboardCouponsCard.kt @@ -25,6 +25,7 @@ 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.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.Observer import androidx.navigation.navOptions @@ -36,7 +37,6 @@ import com.woocommerce.android.ui.analytics.ranges.StatsTimeRange import com.woocommerce.android.ui.analytics.ranges.StatsTimeRangeSelection.SelectionType import com.woocommerce.android.ui.compose.animations.SkeletonView import com.woocommerce.android.ui.compose.rememberNavController -import com.woocommerce.android.ui.compose.viewModelWithFactory import com.woocommerce.android.ui.coupons.CouponListFragmentDirections import com.woocommerce.android.ui.dashboard.DashboardDateRangeHeader import com.woocommerce.android.ui.dashboard.DashboardFragmentDirections @@ -49,13 +49,13 @@ import com.woocommerce.android.ui.dashboard.coupons.DashboardCouponsViewModel.Da import com.woocommerce.android.ui.dashboard.coupons.DashboardCouponsViewModel.State import com.woocommerce.android.ui.dashboard.defaultHideMenuEntry import com.woocommerce.android.viewmodel.MultiLiveEvent -import java.util.Date +import java.util.* @Composable fun DashboardCouponsCard( parentViewModel: DashboardViewModel, modifier: Modifier = Modifier, - viewModel: DashboardCouponsViewModel = viewModelWithFactory { factory: DashboardCouponsViewModel.Factory -> + viewModel: DashboardCouponsViewModel = hiltViewModel { factory: DashboardCouponsViewModel.Factory -> factory.create(parentViewModel) } ) { diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/google/DashboardGoogleAdsCard.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/google/DashboardGoogleAdsCard.kt index 6f4586dc171..251dc503695 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/google/DashboardGoogleAdsCard.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/google/DashboardGoogleAdsCard.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.Observer import com.woocommerce.android.NavGraphMainDirections @@ -38,7 +39,6 @@ import com.woocommerce.android.model.DashboardWidget import com.woocommerce.android.ui.compose.animations.SkeletonView import com.woocommerce.android.ui.compose.component.WCOutlinedButton import com.woocommerce.android.ui.compose.rememberNavController -import com.woocommerce.android.ui.compose.viewModelWithFactory import com.woocommerce.android.ui.dashboard.DashboardViewModel import com.woocommerce.android.ui.dashboard.WidgetCard import com.woocommerce.android.ui.dashboard.WidgetError @@ -50,7 +50,7 @@ import com.woocommerce.android.viewmodel.MultiLiveEvent fun DashboardGoogleAdsCard( parentViewModel: DashboardViewModel, modifier: Modifier = Modifier, - viewModel: DashboardGoogleAdsViewModel = viewModelWithFactory { factory: DashboardGoogleAdsViewModel.Factory -> + viewModel: DashboardGoogleAdsViewModel = hiltViewModel { factory: DashboardGoogleAdsViewModel.Factory -> factory.create(parentViewModel = parentViewModel) } ) { diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/inbox/DashboardInboxCard.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/inbox/DashboardInboxCard.kt index d674e09c0fd..67edbd80498 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/inbox/DashboardInboxCard.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/inbox/DashboardInboxCard.kt @@ -23,12 +23,12 @@ 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.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.Observer import com.woocommerce.android.R import com.woocommerce.android.extensions.navigateSafely import com.woocommerce.android.ui.compose.rememberNavController -import com.woocommerce.android.ui.compose.viewModelWithFactory import com.woocommerce.android.ui.dashboard.DashboardFragmentDirections import com.woocommerce.android.ui.dashboard.DashboardViewModel import com.woocommerce.android.ui.dashboard.WidgetCard @@ -47,7 +47,7 @@ import com.woocommerce.android.viewmodel.MultiLiveEvent.Event fun DashboardInboxCard( parentViewModel: DashboardViewModel, modifier: Modifier = Modifier, - viewModel: DashboardInboxViewModel = viewModelWithFactory { factory: DashboardInboxViewModel.Factory -> + viewModel: DashboardInboxViewModel = hiltViewModel { factory: DashboardInboxViewModel.Factory -> factory.create(parentViewModel) } ) { diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/onboarding/DashboardOnboardingCard.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/onboarding/DashboardOnboardingCard.kt index 8d53ad05c8a..5a1d23eb18e 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/onboarding/DashboardOnboardingCard.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/onboarding/DashboardOnboardingCard.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.Observer import com.woocommerce.android.NavGraphMainDirections @@ -38,7 +39,6 @@ import com.woocommerce.android.extensions.navigateSafely import com.woocommerce.android.ui.compose.animations.SkeletonView import com.woocommerce.android.ui.compose.preview.LightDarkThemePreviews import com.woocommerce.android.ui.compose.rememberNavController -import com.woocommerce.android.ui.compose.viewModelWithFactory import com.woocommerce.android.ui.dashboard.DashboardFragmentDirections import com.woocommerce.android.ui.dashboard.DashboardViewModel import com.woocommerce.android.ui.dashboard.DashboardViewModel.DashboardWidgetMenu @@ -66,7 +66,7 @@ import com.woocommerce.android.viewmodel.MultiLiveEvent fun DashboardOnboardingCard( parentViewModel: DashboardViewModel, modifier: Modifier = Modifier, - onboardingViewModel: DashboardOnboardingViewModel = viewModelWithFactory( + onboardingViewModel: DashboardOnboardingViewModel = hiltViewModel( creationCallback = { factory: DashboardOnboardingViewModel.Factory -> factory.create(parentViewModel) } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/orders/DashboardOrdersCard.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/orders/DashboardOrdersCard.kt index 0fb13b4c160..2cf8b88f823 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/orders/DashboardOrdersCard.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/orders/DashboardOrdersCard.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.constraintlayout.compose.ConstraintLayout +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.Observer import androidx.navigation.NavController @@ -37,7 +38,6 @@ import com.woocommerce.android.extensions.navigateSafely import com.woocommerce.android.ui.compose.animations.SkeletonView import com.woocommerce.android.ui.compose.component.WCTag import com.woocommerce.android.ui.compose.rememberNavController -import com.woocommerce.android.ui.compose.viewModelWithFactory import com.woocommerce.android.ui.dashboard.DashboardFilterableCardHeader import com.woocommerce.android.ui.dashboard.DashboardViewModel import com.woocommerce.android.ui.dashboard.WidgetCard @@ -56,7 +56,7 @@ import com.woocommerce.android.viewmodel.MultiLiveEvent.Event fun DashboardOrdersCard( parentViewModel: DashboardViewModel, modifier: Modifier = Modifier, - viewModel: DashboardOrdersViewModel = viewModelWithFactory { factory: DashboardOrdersViewModel.Factory -> + viewModel: DashboardOrdersViewModel = hiltViewModel { factory: DashboardOrdersViewModel.Factory -> factory.create(parentViewModel) } ) { diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/reviews/DashboardReviewsCard.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/reviews/DashboardReviewsCard.kt index f5ce8730afb..03a747eeb8c 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/reviews/DashboardReviewsCard.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/reviews/DashboardReviewsCard.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.Observer import com.woocommerce.android.R @@ -40,7 +41,6 @@ import com.woocommerce.android.model.DashboardWidget import com.woocommerce.android.model.ProductReview import com.woocommerce.android.ui.compose.animations.SkeletonView import com.woocommerce.android.ui.compose.rememberNavController -import com.woocommerce.android.ui.compose.viewModelWithFactory import com.woocommerce.android.ui.dashboard.DashboardFilterableCardHeader import com.woocommerce.android.ui.dashboard.DashboardFragmentDirections import com.woocommerce.android.ui.dashboard.DashboardViewModel @@ -56,7 +56,7 @@ import com.woocommerce.android.viewmodel.MultiLiveEvent fun DashboardReviewsCard( parentViewModel: DashboardViewModel, modifier: Modifier = Modifier, - viewModel: DashboardReviewsViewModel = viewModelWithFactory { factory: DashboardReviewsViewModel.Factory -> + viewModel: DashboardReviewsViewModel = hiltViewModel { factory: DashboardReviewsViewModel.Factory -> factory.create(parentViewModel = parentViewModel) } ) { diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/stats/DashboardStatsCard.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/stats/DashboardStatsCard.kt index de64d8d7fee..244c14dc219 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/stats/DashboardStatsCard.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/stats/DashboardStatsCard.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.viewinterop.AndroidView +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.Observer import androidx.lifecycle.lifecycleScope @@ -24,7 +25,6 @@ import com.woocommerce.android.model.DashboardWidget import com.woocommerce.android.ui.analytics.ranges.StatsTimeRange import com.woocommerce.android.ui.analytics.ranges.StatsTimeRangeSelection.SelectionType import com.woocommerce.android.ui.compose.rememberNavController -import com.woocommerce.android.ui.compose.viewModelWithFactory import com.woocommerce.android.ui.dashboard.DashboardDateRangeHeader import com.woocommerce.android.ui.dashboard.DashboardFragmentDirections import com.woocommerce.android.ui.dashboard.DashboardStatsUsageTracksEventEmitter @@ -43,7 +43,7 @@ fun DashboardStatsCard( openDatePicker: (Long, Long, (Long, Long) -> Unit) -> Unit, parentViewModel: DashboardViewModel, modifier: Modifier = Modifier, - viewModel: DashboardStatsViewModel = viewModelWithFactory( + viewModel: DashboardStatsViewModel = hiltViewModel( creationCallback = { factory: DashboardStatsViewModel.Factory -> factory.create(parentViewModel) } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/stock/DashboardProductStockCard.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/stock/DashboardProductStockCard.kt index 2822f24f980..6d135725d89 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/stock/DashboardProductStockCard.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/stock/DashboardProductStockCard.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.Observer import com.woocommerce.android.NavGraphMainDirections @@ -38,7 +39,6 @@ import com.woocommerce.android.model.DashboardWidget import com.woocommerce.android.ui.compose.animations.SkeletonView import com.woocommerce.android.ui.compose.component.ProductThumbnail import com.woocommerce.android.ui.compose.rememberNavController -import com.woocommerce.android.ui.compose.viewModelWithFactory import com.woocommerce.android.ui.dashboard.DashboardFilterableCardHeader import com.woocommerce.android.ui.dashboard.DashboardViewModel import com.woocommerce.android.ui.dashboard.DashboardViewModel.DashboardWidgetMenu @@ -56,7 +56,7 @@ import com.woocommerce.android.viewmodel.MultiLiveEvent.Event fun DashboardProductStockCard( parentViewModel: DashboardViewModel, modifier: Modifier = Modifier, - viewModel: DashboardProductStockViewModel = viewModelWithFactory { f: DashboardProductStockViewModel.Factory -> + viewModel: DashboardProductStockViewModel = hiltViewModel { f: DashboardProductStockViewModel.Factory -> f.create(parentViewModel = parentViewModel) } ) { diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/topperformers/DashboardTopPerformersWidgetCard.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/topperformers/DashboardTopPerformersWidgetCard.kt index fe1d5e8f939..9a06d8d7352 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/topperformers/DashboardTopPerformersWidgetCard.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/topperformers/DashboardTopPerformersWidgetCard.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.Observer import com.woocommerce.android.NavGraphMainDirections @@ -41,7 +42,6 @@ import com.woocommerce.android.ui.compose.animations.SkeletonView import com.woocommerce.android.ui.compose.component.ProductThumbnail import com.woocommerce.android.ui.compose.preview.LightDarkThemePreviews import com.woocommerce.android.ui.compose.rememberNavController -import com.woocommerce.android.ui.compose.viewModelWithFactory import com.woocommerce.android.ui.dashboard.DashboardDateRangeHeader import com.woocommerce.android.ui.dashboard.DashboardFragmentDirections import com.woocommerce.android.ui.dashboard.DashboardViewModel @@ -68,7 +68,7 @@ import java.util.Locale fun DashboardTopPerformersWidgetCard( parentViewModel: DashboardViewModel, modifier: Modifier = Modifier, - topPerformersViewModel: DashboardTopPerformersViewModel = viewModelWithFactory( + topPerformersViewModel: DashboardTopPerformersViewModel = hiltViewModel( creationCallback = { factory: DashboardTopPerformersViewModel.Factory -> factory.create(parentViewModel) } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/login/sitecredentials/LoginSiteCredentialsScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/login/sitecredentials/LoginSiteCredentialsScreen.kt index 7040debd012..cdebf0ab85e 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/login/sitecredentials/LoginSiteCredentialsScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/login/sitecredentials/LoginSiteCredentialsScreen.kt @@ -17,7 +17,6 @@ import androidx.compose.material.Scaffold import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Clear import androidx.compose.runtime.Composable import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Alignment @@ -36,7 +35,6 @@ import com.woocommerce.android.ui.compose.component.WCOutlinedTextField import com.woocommerce.android.ui.compose.component.WCPasswordField import com.woocommerce.android.ui.compose.component.WCTextButton import com.woocommerce.android.ui.compose.component.getText -import com.woocommerce.android.ui.compose.component.web.WCWebView import com.woocommerce.android.ui.compose.theme.WooThemeWithBackground @Composable @@ -50,8 +48,7 @@ fun LoginSiteCredentialsScreen(viewModel: LoginSiteCredentialsViewModel) { onResetPasswordClick = viewModel::onResetPasswordClick, onBackClick = viewModel::onBackClick, onHelpButtonClick = viewModel::onHelpButtonClick, - onErrorDialogDismissed = viewModel::onErrorDialogDismissed, - onWebAuthorizationUrlLoaded = viewModel::onWebAuthorizationUrlLoaded + onErrorDialogDismissed = viewModel::onErrorDialogDismissed ) } } @@ -66,7 +63,6 @@ fun LoginSiteCredentialsScreen( onBackClick: () -> Unit, onHelpButtonClick: () -> Unit, onErrorDialogDismissed: () -> Unit, - onWebAuthorizationUrlLoaded: (String) -> Unit ) { Scaffold( topBar = { @@ -74,204 +70,128 @@ fun LoginSiteCredentialsScreen( title = stringResource(id = R.string.log_in), onNavigationButtonClick = onBackClick, onHelpButtonClick = onHelpButtonClick, - navigationIcon = if (viewState is LoginSiteCredentialsViewModel.ViewState.WebAuthorizationViewState) { - Icons.Filled.Clear - } else { - Icons.AutoMirrored.Filled.ArrowBack - } + navigationIcon = Icons.AutoMirrored.Filled.ArrowBack ) } ) { paddingValues -> - when (viewState) { - is LoginSiteCredentialsViewModel.ViewState.NativeLoginViewState -> NativeLoginForm( - viewState = viewState, - onUsernameChanged = onUsernameChanged, - onPasswordChanged = onPasswordChanged, - onContinueClick = onContinueClick, - onResetPasswordClick = onResetPasswordClick, - onErrorDialogDismissed = onErrorDialogDismissed, - onHelpButtonClick = onHelpButtonClick, - modifier = Modifier.padding(paddingValues) - ) - - is LoginSiteCredentialsViewModel.ViewState.WebAuthorizationViewState -> WebAuthorizationScreen( - viewState = viewState, - onPageFinished = onWebAuthorizationUrlLoaded, - onErrorDialogDismissed = { - onErrorDialogDismissed() - onBackClick() - }, - modifier = Modifier.padding(paddingValues) - ) - } - } -} - -@Composable -private fun NativeLoginForm( - viewState: LoginSiteCredentialsViewModel.ViewState.NativeLoginViewState, - onUsernameChanged: (String) -> Unit, - onPasswordChanged: (String) -> Unit, - onContinueClick: () -> Unit, - onResetPasswordClick: () -> Unit, - onErrorDialogDismissed: () -> Unit, - onHelpButtonClick: () -> Unit, - modifier: Modifier = Modifier, -) { - Column( - modifier = modifier - .background(MaterialTheme.colors.surface) - .fillMaxSize(), - ) { Column( modifier = Modifier - .weight(1f) - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(dimensionResource(id = R.dimen.major_100)), + .padding(paddingValues) + .background(MaterialTheme.colors.surface) + .fillMaxSize(), ) { - Text( - text = stringResource(id = R.string.enter_credentials_for_site, viewState.siteUrl), - style = MaterialTheme.typography.body2 - ) - Spacer(modifier = Modifier.height(dimensionResource(id = R.dimen.major_100))) - WCOutlinedTextField( - value = viewState.username, - onValueChange = onUsernameChanged, - label = stringResource(id = R.string.username), - singleLine = true, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next) - ) - WCPasswordField( - value = viewState.password, - onValueChange = onPasswordChanged, - label = stringResource(id = R.string.password), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions( - onDone = { onContinueClick() } + Column( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(dimensionResource(id = R.dimen.major_100)), + ) { + Text( + text = stringResource(id = R.string.enter_credentials_for_site, viewState.siteUrl), + style = MaterialTheme.typography.body2 ) - ) - WCTextButton(onClick = onResetPasswordClick) { - Text(text = stringResource(id = R.string.reset_your_password)) - } - } - - WCColoredButton( - onClick = onContinueClick, - enabled = viewState.isValid, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = dimensionResource(id = R.dimen.major_100)) - ) { - Text( - text = stringResource(id = R.string.continue_button) - ) - } - Spacer(modifier = Modifier.height(dimensionResource(id = R.dimen.major_100))) - } - - if (viewState.errorDialogMessage != null) { - AlertDialog( - text = { - Text(text = viewState.errorDialogMessage.getText()) - }, - onDismissRequest = onErrorDialogDismissed, - buttons = { - Column( - horizontalAlignment = Alignment.End, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = dimensionResource(id = R.dimen.major_100)) - ) { - WCTextButton( - onClick = { - onErrorDialogDismissed() - onHelpButtonClick() - } - ) { - Text(text = stringResource(id = R.string.login_site_address_more_help)) - } - WCTextButton( - onClick = onErrorDialogDismissed - ) { - Text( - text = stringResource(id = R.string.cancel), - textAlign = TextAlign.End - ) - } + Spacer(modifier = Modifier.height(dimensionResource(id = R.dimen.major_100))) + WCOutlinedTextField( + value = viewState.username, + onValueChange = onUsernameChanged, + label = stringResource(id = R.string.username), + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next) + ) + WCPasswordField( + value = viewState.password, + onValueChange = onPasswordChanged, + label = stringResource(id = R.string.password), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions( + onDone = { onContinueClick() } + ) + ) + WCTextButton(onClick = onResetPasswordClick) { + Text(text = stringResource(id = R.string.reset_your_password)) } } - ) - } - - if (viewState.loadingMessage != null) { - ProgressDialog(title = "", subtitle = stringResource(id = viewState.loadingMessage)) - } -} -@Composable -private fun WebAuthorizationScreen( - viewState: LoginSiteCredentialsViewModel.ViewState.WebAuthorizationViewState, - onPageFinished: (String) -> Unit, - onErrorDialogDismissed: () -> Unit, - modifier: Modifier = Modifier, -) { - when { - viewState.loadingMessage != null -> { - ProgressDialog(title = "", subtitle = stringResource(id = viewState.loadingMessage)) + WCColoredButton( + onClick = onContinueClick, + enabled = viewState.isValid, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = dimensionResource(id = R.dimen.major_100)) + ) { + Text( + text = stringResource(id = R.string.continue_button) + ) + } + Spacer(modifier = Modifier.height(dimensionResource(id = R.dimen.major_100))) } - viewState.errorDialogMessage != null -> { + if (viewState.errorDialogMessage != null) { AlertDialog( text = { Text(text = viewState.errorDialogMessage.getText()) }, onDismissRequest = onErrorDialogDismissed, - confirmButton = { - WCTextButton( - onClick = onErrorDialogDismissed + buttons = { + Column( + horizontalAlignment = Alignment.End, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = dimensionResource(id = R.dimen.major_100)) ) { - Text(text = stringResource(id = android.R.string.ok)) + WCTextButton( + onClick = { + onErrorDialogDismissed() + onHelpButtonClick() + } + ) { + Text(text = stringResource(id = R.string.login_site_address_more_help)) + } + WCTextButton( + onClick = onErrorDialogDismissed + ) { + Text( + text = stringResource(id = R.string.cancel), + textAlign = TextAlign.End + ) + } } } ) } - viewState.authorizationUrl != null -> { - WCWebView( - url = viewState.authorizationUrl, - userAgent = viewState.userAgent, - onPageFinished = onPageFinished, - modifier = modifier - ) + if (viewState.loadingMessage != null) { + ProgressDialog(title = "", subtitle = stringResource(id = viewState.loadingMessage)) } } } @Preview @Composable -private fun NativeLoginFormPreview() { +private fun LoginSiteCredentialsScreenPreview() { WooThemeWithBackground { - NativeLoginForm( - viewState = LoginSiteCredentialsViewModel.ViewState.NativeLoginViewState( + LoginSiteCredentialsScreen( + viewState = LoginSiteCredentialsViewModel.ViewState( siteUrl = "https://wordpress.com" ), onUsernameChanged = {}, onPasswordChanged = {}, onContinueClick = {}, onResetPasswordClick = {}, - onErrorDialogDismissed = {}, - onHelpButtonClick = {} + onBackClick = {}, + onHelpButtonClick = {}, + onErrorDialogDismissed = {} ) } } @Preview @Composable -private fun NativeLoginFormWithErrorDialogPreview() { +private fun LoginSiteCredentialsScreenWithErrorPreview() { WooThemeWithBackground { - NativeLoginForm( - viewState = LoginSiteCredentialsViewModel.ViewState.NativeLoginViewState( + LoginSiteCredentialsScreen( + viewState = LoginSiteCredentialsViewModel.ViewState( siteUrl = "https://wordpress.com", errorDialogMessage = UiString.UiStringRes(R.string.login_site_credentials_fetching_site_failed) ), @@ -279,8 +199,9 @@ private fun NativeLoginFormWithErrorDialogPreview() { onPasswordChanged = {}, onContinueClick = {}, onResetPasswordClick = {}, - onErrorDialogDismissed = {}, - onHelpButtonClick = {} + onBackClick = {}, + onHelpButtonClick = {}, + onErrorDialogDismissed = {} ) } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/login/sitecredentials/LoginSiteCredentialsViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/login/sitecredentials/LoginSiteCredentialsViewModel.kt index cfe241dae71..ec5f9d1809e 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/login/sitecredentials/LoginSiteCredentialsViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/login/sitecredentials/LoginSiteCredentialsViewModel.kt @@ -30,10 +30,7 @@ import com.woocommerce.android.viewmodel.ScopedViewModel import com.woocommerce.android.viewmodel.getNullableStateFlow import com.woocommerce.android.viewmodel.getStateFlow import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -41,7 +38,6 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.fluxc.module.ApplicationPasswordsClientId -import org.wordpress.android.fluxc.network.UserAgent import org.wordpress.android.fluxc.network.rest.wpapi.Nonce.CookieNonceErrorType.INVALID_CREDENTIALS import org.wordpress.android.fluxc.store.SiteStore.SiteError import org.wordpress.android.login.LoginAnalyticsListener @@ -58,7 +54,6 @@ class LoginSiteCredentialsViewModel @Inject constructor( applicationPasswordsNotifier: ApplicationPasswordsNotifier, private val analyticsTracker: AnalyticsTrackerWrapper, private val appPrefs: AppPrefsWrapper, - private val userAgent: UserAgent, private val resourceProvider: ResourceProvider, @ApplicationPasswordsClientId private val applicationPasswordsClientId: String ) : ScopedViewModel(savedStateHandle) { @@ -75,7 +70,6 @@ class LoginSiteCredentialsViewModel @Inject constructor( private val siteAddress: String = savedStateHandle[SITE_ADDRESS_KEY]!! - private val state = savedStateHandle.getStateFlow(viewModelScope, State.NativeLogin) private val errorDialogMessage = savedStateHandle.getNullableStateFlow( scope = viewModelScope, initialValue = null, @@ -90,17 +84,20 @@ class LoginSiteCredentialsViewModel @Inject constructor( get() = this?.applicationPasswordsAuthorizeUrl ?.let { url -> "$url?app_name=$applicationPasswordsClientId&success_url=$REDIRECTION_URL" } - @OptIn(ExperimentalCoroutinesApi::class) - val viewState = state.flatMapLatest { - // Reset loading and error state when the state changes - loadingMessage.value = 0 - errorDialogMessage.value = null - - when (it) { - State.NativeLogin -> prepareNativeLoginViewState() - State.WebAuthorization -> prepareWebAuthorizationViewState() - State.RetryWebAuthorization -> prepareWebAuthorizationViewState() - } + val viewState = combine( + flowOf(siteAddress.removeSchemeAndSuffix()), + savedStateHandle.getStateFlow(USERNAME_KEY, ""), + savedStateHandle.getStateFlow(PASSWORD_KEY, ""), + loadingMessage.map { message -> message.takeIf { it != 0 } }, + errorDialogMessage + ) { siteAddress, username, password, loadingMessage, errorDialog -> + ViewState( + siteUrl = siteAddress, + username = username, + password = password, + loadingMessage = loadingMessage, + errorDialogMessage = errorDialog + ) }.asLiveData() init { @@ -150,30 +147,15 @@ class LoginSiteCredentialsViewModel @Inject constructor( } fun onBackClick() { - if (state.value == State.WebAuthorization) { - fetchedSiteId.value = -1 - state.value = State.NativeLogin - } else { - triggerEvent(Exit) - } + triggerEvent(Exit) } fun onHelpButtonClick() { viewState.value?.let { - triggerEvent(ShowHelpScreen(siteAddress, (it as? ViewState.NativeLoginViewState)?.username.orEmpty())) + triggerEvent(ShowHelpScreen(siteAddress, it.username)) } } - /** - * This is currently a unreachable event due to the current usage of the application passwords feature - * available in the [ShowApplicationPasswordTutorialScreen] event, but it's kept here for future reference - * in case we need to start the Authorization from here back again. - */ - fun onStartWebAuthorizationClick() { - state.value = State.WebAuthorization - analyticsTracker.track(AnalyticsEvent.APPLICATION_PASSWORDS_AUTHORIZATION_WEB_VIEW_SHOWN) - } - fun onWebAuthorizationUrlLoaded(url: String) { if (url.startsWith(REDIRECTION_URL)) { launch { @@ -185,7 +167,6 @@ class LoginSiteCredentialsViewModel @Inject constructor( val isSuccess = params[SUCCESS_PARAMETER]?.toBoolean() ?: true if (!isSuccess) { fetchedSiteId.value = -1 - state.value = State.NativeLogin analyticsTracker.track(AnalyticsEvent.APPLICATION_PASSWORDS_AUTHORIZATION_REJECTED) triggerEvent(ShowSnackbar(R.string.login_site_credentials_web_authorization_connection_rejected)) @@ -208,53 +189,12 @@ class LoginSiteCredentialsViewModel @Inject constructor( } fun retryApplicationPasswordsCheck() = launch { - if (state.value == State.NativeLogin) { - // When using native login, retry fetching user info - fetchUserInfo() - } else { - // When using web authorization, retry fetching the site - fetchSite() - state.value = State.RetryWebAuthorization - } - } - - private fun prepareNativeLoginViewState(): Flow = combine( - flowOf(siteAddress.removeSchemeAndSuffix()), - savedStateHandle.getStateFlow(USERNAME_KEY, ""), - savedStateHandle.getStateFlow(PASSWORD_KEY, ""), - loadingMessage.map { message -> message.takeIf { it != 0 } }, - errorDialogMessage - ) { siteAddress, username, password, loadingMessage, errorDialog -> - ViewState.NativeLoginViewState( - siteUrl = siteAddress, - username = username, - password = password, - loadingMessage = loadingMessage, - errorDialogMessage = errorDialog - ) - } - - private fun prepareWebAuthorizationViewState(): Flow { - if (fetchedSiteId.value == -1) { - launch { fetchSite() } - } - - return combine( - loadingMessage.map { message -> message.takeIf { it != 0 } }, - errorDialogMessage, - fetchedSiteId.map { if (it == -1) null else wpApiSiteRepository.getSiteByLocalId(it) } - ) { loadingMessage, errorDialogMessage, site -> - ViewState.WebAuthorizationViewState( - authorizationUrl = site?.fullAuthorizationUrl, - userAgent = userAgent, - loadingMessage = loadingMessage, - errorDialogMessage = errorDialogMessage - ) - } + fetchedSiteId.value = -1 + login() } private suspend fun login() { - val state = requireNotNull(this@LoginSiteCredentialsViewModel.viewState.value as ViewState.NativeLoginViewState) + val state = requireNotNull(this@LoginSiteCredentialsViewModel.viewState.value) loadingMessage.value = R.string.logging_in wpApiSiteRepository.login( url = siteAddress, @@ -333,27 +273,16 @@ class LoginSiteCredentialsViewModel @Inject constructor( private suspend fun fetchSite() { val viewState = viewState.value - loadingMessage.value = if (state.value == State.WebAuthorization) { - R.string.login_site_credentials_fetching_site - } else { - R.string.logging_in - } + loadingMessage.value = R.string.logging_in wpApiSiteRepository.fetchSite( url = siteAddress, - username = (viewState as? ViewState.NativeLoginViewState)?.username, - password = (viewState as? ViewState.NativeLoginViewState)?.password + username = viewState?.username, + password = viewState?.password ).fold( onSuccess = { site -> if (site.hasWooCommerce) { fetchedSiteId.value = site.id - // In case of the native login, then continue with the login flow - // Otherwise, the web authorization flow will handle the login - if (state.value == State.NativeLogin) { - fetchUserInfo() - } else if (site.applicationPasswordsAuthorizeUrl.isNullOrEmpty()) { - analyticsTracker.track(AnalyticsEvent.APPLICATION_PASSWORDS_AUTHORIZATION_URL_NOT_AVAILABLE) - triggerEvent(ShowApplicationPasswordsUnavailableScreen(siteAddress, site.isJetpackConnected)) - } + fetchUserInfo() } else { triggerEvent(ShowNonWooErrorScreen(siteAddress)) } @@ -450,27 +379,14 @@ class LoginSiteCredentialsViewModel @Inject constructor( is UiStringText -> text } - private enum class State { - NativeLogin, WebAuthorization, RetryWebAuthorization - } - - sealed interface ViewState { - data class NativeLoginViewState( - val siteUrl: String, - val username: String = "", - val password: String = "", - @StringRes val loadingMessage: Int? = null, - val errorDialogMessage: UiString? = null - ) : ViewState { - val isValid = username.isNotBlank() && password.isNotBlank() - } - - data class WebAuthorizationViewState( - val authorizationUrl: String?, - val userAgent: UserAgent, - @StringRes val loadingMessage: Int? = null, - val errorDialogMessage: UiString? = null - ) : ViewState + data class ViewState( + val siteUrl: String, + val username: String = "", + val password: String = "", + @StringRes val loadingMessage: Int? = null, + val errorDialogMessage: UiString? = null + ) { + val isValid = username.isNotBlank() && password.isNotBlank() } @VisibleForTesting diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/login/sitecredentials/applicationpassword/ApplicationPasswordTutorialScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/login/sitecredentials/applicationpassword/ApplicationPasswordTutorialScreen.kt index d072961f9ea..7b3a42ea372 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/login/sitecredentials/applicationpassword/ApplicationPasswordTutorialScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/login/sitecredentials/applicationpassword/ApplicationPasswordTutorialScreen.kt @@ -1,5 +1,6 @@ package com.woocommerce.android.ui.login.sitecredentials.applicationpassword +import androidx.activity.compose.BackHandler import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -38,6 +39,8 @@ import org.wordpress.android.fluxc.network.UserAgent @Composable fun ApplicationPasswordTutorialScreen(viewModel: ApplicationPasswordTutorialViewModel) { + BackHandler { viewModel.onNavigationButtonClicked() } + val viewState = viewModel.viewState.observeAsState() ApplicationPasswordTutorialScreen( authorizationStarted = viewState.value?.authorizationStarted ?: false, diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/OrderNavigationTarget.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/OrderNavigationTarget.kt index ff7794a59ba..681467cdf22 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/OrderNavigationTarget.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/OrderNavigationTarget.kt @@ -53,6 +53,7 @@ sealed class OrderNavigationTarget : Event() { object ViewShippingLabelFormatOptions : OrderNavigationTarget() data class ViewPrintCustomsForm(val invoices: List, val isReprint: Boolean) : OrderNavigationTarget() data class StartShippingLabelCreationFlow(val orderId: Long) : OrderNavigationTarget() + data class StartWooShippingLabelCreationFlow(val orderId: Long) : OrderNavigationTarget() data class StartPaymentFlow( val orderId: Long, val paymentTypeFlow: CardReaderFlowParam.PaymentOrRefund.Payment.PaymentType, diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/OrderNavigator.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/OrderNavigator.kt index 30e58230cf0..a20b87fba7b 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/OrderNavigator.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/OrderNavigator.kt @@ -17,6 +17,7 @@ import com.woocommerce.android.ui.orders.OrderNavigationTarget.PrintShippingLabe import com.woocommerce.android.ui.orders.OrderNavigationTarget.RefundShippingLabel import com.woocommerce.android.ui.orders.OrderNavigationTarget.StartPaymentFlow import com.woocommerce.android.ui.orders.OrderNavigationTarget.StartShippingLabelCreationFlow +import com.woocommerce.android.ui.orders.OrderNavigationTarget.StartWooShippingLabelCreationFlow import com.woocommerce.android.ui.orders.OrderNavigationTarget.ViewCreateShippingLabelInfo import com.woocommerce.android.ui.orders.OrderNavigationTarget.ViewCustomFields import com.woocommerce.android.ui.orders.OrderNavigationTarget.ViewOrderFulfillInfo @@ -227,6 +228,12 @@ class OrderNavigator @Inject constructor() { ) fragment.findNavController().navigateSafely(action) } + + is StartWooShippingLabelCreationFlow -> { + val action = OrderDetailFragmentDirections + .actionOrderDetailFragmentToWooShippingLabelCreationFragment(target.orderId) + fragment.findNavController().navigateSafely(action) + } } } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/details/OrderDetailViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/details/OrderDetailViewModel.kt index 848f5814ad2..29c4c43cc75 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/details/OrderDetailViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/details/OrderDetailViewModel.kt @@ -44,6 +44,7 @@ import com.woocommerce.android.ui.orders.OrderNavigationTarget.PrintShippingLabe import com.woocommerce.android.ui.orders.OrderNavigationTarget.RefundShippingLabel import com.woocommerce.android.ui.orders.OrderNavigationTarget.StartPaymentFlow import com.woocommerce.android.ui.orders.OrderNavigationTarget.StartShippingLabelCreationFlow +import com.woocommerce.android.ui.orders.OrderNavigationTarget.StartWooShippingLabelCreationFlow import com.woocommerce.android.ui.orders.OrderNavigationTarget.ViewCreateShippingLabelInfo import com.woocommerce.android.ui.orders.OrderNavigationTarget.ViewOrderFulfillInfo import com.woocommerce.android.ui.orders.OrderNavigationTarget.ViewOrderStatusSelector @@ -93,7 +94,7 @@ import org.wordpress.android.fluxc.store.WooCommerceStore import javax.inject.Inject @HiltViewModel -@Suppress("LargeClass") +@Suppress("LargeClass", "LongParameterList", "TooManyFunctions") class OrderDetailViewModel @Inject constructor( savedState: SavedStateHandle, private val appPrefs: AppPrefs, @@ -656,7 +657,14 @@ class OrderDetailViewModel @Inject constructor( fun onCreateShippingLabelButtonTapped() { tracker.trackShippinhLabelTapped() - triggerEvent(StartShippingLabelCreationFlow(order.id)) + if ( + FeatureFlag.REVAMP_WOO_SHIPPING.isEnabled() && + shippingLabelOnboardingRepository.shippingPluginSupport.isWooShippingSupported() + ) { + triggerEvent(StartWooShippingLabelCreationFlow(order.id)) + } else { + triggerEvent(StartShippingLabelCreationFlow(order.id)) + } } fun onMarkOrderCompleteButtonTapped() { @@ -770,7 +778,7 @@ class OrderDetailViewModel @Inject constructor( } private fun fetchSLCreationEligibilityAsync() = async { - if (shippingLabelOnboardingRepository.isShippingPluginReady) { + if (shippingLabelOnboardingRepository.shippingPluginSupport.isSupported()) { orderDetailRepository.fetchSLCreationEligibility(navArgs.orderId) } orderDetailsTransactionLauncher.onPackageCreationEligibleFetched() @@ -802,7 +810,7 @@ class OrderDetailViewModel @Inject constructor( } private fun fetchOrderShippingLabelsAsync() = async { - if (shippingLabelOnboardingRepository.isShippingPluginReady) { + if (shippingLabelOnboardingRepository.shippingPluginSupport.isSupported()) { orderDetailRepository.fetchOrderShippingLabels(navArgs.orderId) } orderDetailsTransactionLauncher.onShippingLabelFetchingCompleted() @@ -874,7 +882,7 @@ class OrderDetailViewModel @Inject constructor( val orderEligibleForInPersonPayments = viewState.orderInfo?.isPaymentCollectableWithCardReader == true - val isOrderEligibleForSLCreation = shippingLabelOnboardingRepository.isShippingPluginReady && + val isOrderEligibleForSLCreation = shippingLabelOnboardingRepository.shippingPluginSupport.isSupported() && orderDetailRepository.isOrderEligibleForSLCreation(order.id) && !orderEligibleForInPersonPayments diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/details/ShippingLabelOnboardingRepository.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/details/ShippingLabelOnboardingRepository.kt index 908e68e3b6a..5c364fa5b4c 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/details/ShippingLabelOnboardingRepository.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/details/ShippingLabelOnboardingRepository.kt @@ -3,7 +3,6 @@ package com.woocommerce.android.ui.orders.details import com.woocommerce.android.AppPrefsWrapper import com.woocommerce.android.extensions.semverCompareTo import com.woocommerce.android.model.Order -import com.woocommerce.android.model.WooPlugin import com.woocommerce.android.tools.SelectedSite import com.woocommerce.android.util.FeatureFlag import javax.inject.Inject @@ -16,14 +15,15 @@ class ShippingLabelOnboardingRepository @Inject constructor( companion object { // The required version to support shipping label creation const val SUPPORTED_WCS_VERSION = "1.25.11" + const val SUPPORTED_WC_SHIPPING_VERSION = "1.0.6" const val SUPPORTED_WCS_CURRENCY = "USD" const val SUPPORTED_WCS_COUNTRY = "US" } - val isShippingPluginReady: Boolean by lazy { isShippingLabelSupported() } + val shippingPluginSupport: ShippingLabelSupport by lazy { getShippingLabelSupport() } fun shouldShowWcShippingBanner(order: Order, eligibleForIpp: Boolean): Boolean = - !isShippingPluginReady && + !shippingPluginSupport.isSupported() && orderDetailRepository.getStoreCountryCode() == SUPPORTED_WCS_COUNTRY && order.currency == SUPPORTED_WCS_CURRENCY && !order.isCashPayment && @@ -45,19 +45,35 @@ class ShippingLabelOnboardingRepository @Inject constructor( } @Suppress("ReturnCount") - private fun isShippingLabelSupported(): Boolean { - orderDetailRepository.getWooServicesPluginInfo() + private fun getShippingLabelSupport(): ShippingLabelSupport { + orderDetailRepository.getWooShippingPluginInfo() .takeIf { val pluginVersion = it.version ?: "0.0.0" - it.isPluginReady() && pluginVersion.semverCompareTo(SUPPORTED_WCS_VERSION) >= 0 - }?.let { return true } + it.isOperational && + pluginVersion.semverCompareTo(SUPPORTED_WC_SHIPPING_VERSION) >= 0 + }?.let { + return if (FeatureFlag.REVAMP_WOO_SHIPPING.isEnabled()) { + ShippingLabelSupport.WC_SHIPPING_SUPPORTED + } else { + ShippingLabelSupport.WCS_SUPPORTED + } + } - orderDetailRepository.getWooShippingPluginInfo() - .takeIf { it.isPluginReady() && FeatureFlag.NEW_SHIPPING_SUPPORT.isEnabled() } - ?.let { return true } + orderDetailRepository.getWooServicesPluginInfo() + .takeIf { + val pluginVersion = it.version ?: "0.0.0" + it.isOperational && pluginVersion.semverCompareTo(SUPPORTED_WCS_VERSION) >= 0 + }?.let { return ShippingLabelSupport.WCS_SUPPORTED } - return false + return ShippingLabelSupport.NOT_SUPPORTED } - private fun WooPlugin.isPluginReady() = isInstalled && isActive + enum class ShippingLabelSupport { + NOT_SUPPORTED, + WC_SHIPPING_SUPPORTED, + WCS_SUPPORTED; + + fun isSupported() = this == WCS_SUPPORTED || this == WC_SHIPPING_SUPPORTED + fun isWooShippingSupported() = this == WC_SHIPPING_SUPPORTED + } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/wooshippinglabels/WooShippingLabelCreationFragment.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/wooshippinglabels/WooShippingLabelCreationFragment.kt new file mode 100644 index 00000000000..783949d31de --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/wooshippinglabels/WooShippingLabelCreationFragment.kt @@ -0,0 +1,39 @@ +package com.woocommerce.android.ui.orders.wooshippinglabels + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import com.woocommerce.android.R +import com.woocommerce.android.ui.base.BaseFragment +import com.woocommerce.android.ui.compose.theme.WooThemeWithBackground + +class WooShippingLabelCreationFragment : BaseFragment() { + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + WooThemeWithBackground { + Surface { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text(text = "This is the new shipping label flow") + } + } + } + } + } + } + + override fun getFragmentTitle() = getString(R.string.orderdetail_shipping_label_create_shipping_label) +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/payments/cardreader/LearnMoreUrlProvider.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/payments/cardreader/LearnMoreUrlProvider.kt index 942b5235978..f77c32ce279 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/payments/cardreader/LearnMoreUrlProvider.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/payments/cardreader/LearnMoreUrlProvider.kt @@ -27,10 +27,7 @@ class LearnMoreUrlProvider @Inject constructor( } } LearnMoreUrlType.CASH_ON_DELIVERY -> { - when (preferredPlugin) { - STRIPE_EXTENSION_GATEWAY -> AppUrls.STRIPE_LEARN_MORE_ABOUT_PAYMENTS_CASH_ON_DELIVERY - WOOCOMMERCE_PAYMENTS, null -> AppUrls.WOOCOMMERCE_LEARN_MORE_ABOUT_PAYMENTS_CASH_ON_DELIVERY - } + AppUrls.WOOCOMMERCE_LEARN_MORE_ABOUT_PAYMENTS_CASH_ON_DELIVERY } } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/DuplicateProduct.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/DuplicateProduct.kt index 9e408d178f9..9c60ac6f78b 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/DuplicateProduct.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/DuplicateProduct.kt @@ -3,6 +3,7 @@ package com.woocommerce.android.ui.products import com.woocommerce.android.R import com.woocommerce.android.WooException import com.woocommerce.android.model.Product +import com.woocommerce.android.model.ProductAggregate import com.woocommerce.android.ui.products.details.ProductDetailRepository import com.woocommerce.android.ui.products.variations.VariationRepository import com.woocommerce.android.util.WooLog @@ -18,19 +19,24 @@ class DuplicateProduct @Inject constructor( private val resourceProvider: ResourceProvider, ) { - suspend operator fun invoke(product: Product): Result { - val newProduct = product.copy( - remoteId = 0, - name = resourceProvider.getString(R.string.product_duplicate_copied_product_name, product.name), - sku = "", - status = ProductStatus.DRAFT + suspend operator fun invoke(productAggregate: ProductAggregate): Result { + val newProduct = productAggregate.copy( + product = productAggregate.product.copy( + remoteId = 0, + name = resourceProvider.getString( + R.string.product_duplicate_copied_product_name, + productAggregate.product.name + ), + sku = "", + status = ProductStatus.DRAFT + ) ) val (duplicateProductSuccess, duplicatedProductRemoteId) = productDetailRepository.addProduct(newProduct) return if (duplicateProductSuccess) { - if (product.numVariations > 0) { - duplicateVariations(product, duplicatedProductRemoteId) + if (productAggregate.product.numVariations > 0) { + duplicateVariations(productAggregate.product, duplicatedProductRemoteId) } else { Result.success(duplicatedProductRemoteId) } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductHelper.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductHelper.kt index e11a2a84264..592ad2f4b6f 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductHelper.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductHelper.kt @@ -1,14 +1,13 @@ package com.woocommerce.android.ui.products import com.woocommerce.android.model.Product +import com.woocommerce.android.model.ProductAggregate import com.woocommerce.android.model.SubscriptionDetails import com.woocommerce.android.model.SubscriptionPeriod import com.woocommerce.android.ui.products.ProductBackorderStatus.NotAvailable import com.woocommerce.android.ui.products.ProductStatus.DRAFT import com.woocommerce.android.ui.products.ProductStatus.PUBLISH import com.woocommerce.android.ui.products.ProductStockStatus.InStock -import com.woocommerce.android.ui.products.ProductType.SUBSCRIPTION -import com.woocommerce.android.ui.products.ProductType.VARIABLE_SUBSCRIPTION import com.woocommerce.android.ui.products.settings.ProductCatalogVisibility.VISIBLE import java.math.BigDecimal import java.util.Date @@ -93,12 +92,6 @@ object ProductHelper { variationIds = listOf(), downloads = listOf(), isPurchasable = false, - subscription = - if (productType == SUBSCRIPTION || productType == VARIABLE_SUBSCRIPTION) { - getDefaultSubscriptionDetails() - } else { - null - }, isSampleProduct = false, parentId = 0, minAllowedQuantity = null, @@ -111,6 +104,19 @@ object ProductHelper { ) } + fun getDefaultProductAggregate(productType: ProductType, isVirtual: Boolean): ProductAggregate { + return ProductAggregate( + product = getDefaultNewProduct(productType, isVirtual), + subscription = if (productType == ProductType.SUBSCRIPTION || + productType == ProductType.VARIABLE_SUBSCRIPTION + ) { + getDefaultSubscriptionDetails() + } else { + null + } + ) + } + fun getDefaultSubscriptionDetails(): SubscriptionDetails = SubscriptionDetails( price = null, diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ai/SaveAiGeneratedProduct.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ai/SaveAiGeneratedProduct.kt index ed660c9ede7..e3b3c7c70d8 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ai/SaveAiGeneratedProduct.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ai/SaveAiGeneratedProduct.kt @@ -1,70 +1,114 @@ package com.woocommerce.android.ui.products.ai +import com.woocommerce.android.model.Image import com.woocommerce.android.model.Product +import com.woocommerce.android.model.ProductCategory +import com.woocommerce.android.model.ProductTag import com.woocommerce.android.ui.products.ProductStatus +import com.woocommerce.android.ui.products.ai.preview.UploadImage import com.woocommerce.android.ui.products.categories.ProductCategoriesRepository import com.woocommerce.android.ui.products.details.ProductDetailRepository import com.woocommerce.android.ui.products.tags.ProductTagsRepository import com.woocommerce.android.util.WooLog +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import javax.inject.Inject class SaveAiGeneratedProduct @Inject constructor( private val productCategoriesRepository: ProductCategoriesRepository, private val productTagsRepository: ProductTagsRepository, - private val productDetailRepository: ProductDetailRepository + private val productDetailRepository: ProductDetailRepository, + private val uploadImage: UploadImage ) { - @Suppress("ReturnCount") suspend operator fun invoke( product: Product, - selectedImage: Product.Image? - ): Result { - // Create missing categories + selectedImage: Image? + ): AiProductSaveResult = coroutineScope { + // Start uploading the selected image + val imageTask = selectedImage?.let { selectedImage -> startUploadingImage(selectedImage) } + val missingCategories = product.categories.filter { it.remoteCategoryId == 0L } - val createdCategories = missingCategories - .takeIf { it.isNotEmpty() }?.let { productCategories -> - WooLog.d( - tag = WooLog.T.PRODUCTS, - message = "Create the missing product categories ${productCategories.map { it.name }}" - ) - productCategoriesRepository.addProductCategories(productCategories) - }?.fold( - onSuccess = { it }, - onFailure = { - WooLog.e(WooLog.T.PRODUCTS, "Failed to add product categories", it) - return Result.failure(it) - } - ) + // Start create missing categories + val categoriesTask = missingCategories + .takeIf { it.isNotEmpty() } + ?.let { productCategories -> startCreatingCategories(productCategories) } - // Create missing tags + // Start Create missing tags val missingTags = product.tags.filter { it.remoteTagId == 0L } - val createdTags = missingTags - .takeIf { it.isNotEmpty() }?.let { productTags -> - WooLog.d( - tag = WooLog.T.PRODUCTS, - message = "Create the missing product tags ${productTags.map { it.name }}" - ) - productTagsRepository.addProductTags(productTags.map { it.name }) - }?.fold( - onSuccess = { it }, - onFailure = { - WooLog.e(WooLog.T.PRODUCTS, "Failed to add product tags", it) - return Result.failure(it) - } - ) + val tagsTask = missingTags + .takeIf { it.isNotEmpty() } + ?.let { productTags -> startCreatingTags(productTags) } + + // Wait for the image to be uploaded + val image = imageTask?.await()?.getOrElse { + return@coroutineScope AiProductSaveResult.Failure.UploadImageFailure + } + + // Wait for the created categories and tags + val createdCategories = categoriesTask?.await()?.getOrElse { + return@coroutineScope AiProductSaveResult.Failure.Generic(image?.asWPMediaLibraryImage()) + } + val createdTags = tagsTask?.await()?.getOrElse { + return@coroutineScope AiProductSaveResult.Failure.Generic(image?.asWPMediaLibraryImage()) + } val updatedProduct = product.copy( categories = product.categories - missingCategories.toSet() + createdCategories.orEmpty(), tags = product.tags - missingTags.toSet() + createdTags.orEmpty(), - images = listOfNotNull(selectedImage), + images = listOfNotNull(image), status = ProductStatus.DRAFT ) - return productDetailRepository.addProduct(updatedProduct).let { (success, productId) -> + productDetailRepository.addProduct(updatedProduct).let { (success, productId) -> if (success) { - Result.success(productId) + WooLog.d( + tag = WooLog.T.PRODUCTS, + message = "Successfully saved the AI generated product as draft with id $productId" + ) + AiProductSaveResult.Success(productId) } else { - Result.failure(Exception("Failed to save the AI generated product")) + WooLog.e(WooLog.T.PRODUCTS, "Failed to save the AI generated product as draft") + AiProductSaveResult.Failure.Generic(image?.asWPMediaLibraryImage()) } } } + + private fun CoroutineScope.startUploadingImage(image: Image) = async { + uploadImage(image).onFailure { + WooLog.e(WooLog.T.PRODUCTS, "Failed to upload the selected image", it) + } + } + + private fun CoroutineScope.startCreatingCategories(categories: List) = async { + WooLog.d( + tag = WooLog.T.PRODUCTS, + message = "Create the missing product categories ${categories.map { it.name }}" + ) + productCategoriesRepository.addProductCategories(categories) + .onFailure { + WooLog.e(WooLog.T.PRODUCTS, "Failed to add product categories", it) + } + } + + private fun CoroutineScope.startCreatingTags(tags: List) = async { + WooLog.d( + tag = WooLog.T.PRODUCTS, + message = "Create the missing product tags ${tags.map { it.name }}" + ) + productTagsRepository.addProductTags(tags.map { it.name }) + .onFailure { + WooLog.e(WooLog.T.PRODUCTS, "Failed to add product tags", it) + } + } + + private fun Product.Image.asWPMediaLibraryImage() = Image.WPMediaLibraryImage(this) +} + +sealed interface AiProductSaveResult { + data class Success(val productId: Long) : AiProductSaveResult + sealed interface Failure : AiProductSaveResult { + data object UploadImageFailure : Failure + data class Generic(val uploadedImage: Image.WPMediaLibraryImage? = null) : Failure + } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ai/preview/AiProductPreviewViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ai/preview/AiProductPreviewViewModel.kt index ee8e637d81e..93de74cbd64 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ai/preview/AiProductPreviewViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ai/preview/AiProductPreviewViewModel.kt @@ -12,9 +12,8 @@ import com.woocommerce.android.analytics.AnalyticsTracker import com.woocommerce.android.analytics.AnalyticsTrackerWrapper import com.woocommerce.android.extensions.combine import com.woocommerce.android.model.Image -import com.woocommerce.android.model.Image.WPMediaLibraryImage -import com.woocommerce.android.model.Product import com.woocommerce.android.ui.products.ai.AIProductModel +import com.woocommerce.android.ui.products.ai.AiProductSaveResult import com.woocommerce.android.ui.products.ai.BuildProductPreviewProperties import com.woocommerce.android.ui.products.ai.ProductPropertyCard import com.woocommerce.android.ui.products.ai.SaveAiGeneratedProduct @@ -42,7 +41,6 @@ class AiProductPreviewViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val buildProductPreviewProperties: BuildProductPreviewProperties, private val generateProductWithAI: GenerateProductWithAI, - private val uploadImage: UploadImage, private val analyticsTracker: AnalyticsTrackerWrapper, private val saveAiGeneratedProduct: SaveAiGeneratedProduct, private val resourceProvider: ResourceProvider @@ -229,12 +227,10 @@ class AiProductPreviewViewModel @Inject constructor( val product = generatedProduct.value?.getOrNull()?.toProduct(selectedVariant.value) ?: return savingProductState.value = SavingProductState.Loading viewModelScope.launch { - val image = uploadSelectedImage().onFailure { - return@launch - }.getOrNull() + val image = imageState.value.image val editedFields = userEditedFields.value - saveAiGeneratedProduct( + val result = saveAiGeneratedProduct( product.copy( name = editedFields.names[selectedVariant.value] ?: product.name, description = editedFields.descriptions[selectedVariant.value] ?: product.description, @@ -242,39 +238,37 @@ class AiProductPreviewViewModel @Inject constructor( ?: product.shortDescription ), image - ).fold( - onSuccess = { productId -> + ) + + when (result) { + is AiProductSaveResult.Success -> { savingProductState.value = SavingProductState.Success - triggerEvent(NavigateToProductDetailScreen(productId)) + triggerEvent(NavigateToProductDetailScreen(result.productId)) analyticsTracker.track(AnalyticsEvent.PRODUCT_CREATION_AI_SAVE_AS_DRAFT_SUCCESS) - }, - onFailure = { + } + + is AiProductSaveResult.Failure -> { + // Keep track of the uploaded image to avoid re-uploading it on retry + (result as? AiProductSaveResult.Failure.Generic)?.uploadedImage?.let { + imageState.value = imageState.value.copy(image = it) + } + + val messageRes = when (result) { + is AiProductSaveResult.Failure.UploadImageFailure -> + R.string.ai_product_creation_error_media_upload + + else -> R.string.error_generic + } + savingProductState.value = SavingProductState.Error( - messageRes = R.string.error_generic, + messageRes = messageRes, onRetryClick = ::onSaveProductAsDraft, onDismissClick = { savingProductState.value = SavingProductState.Idle } ) analyticsTracker.track(AnalyticsEvent.PRODUCT_CREATION_AI_SAVE_AS_DRAFT_FAILED) } - ) - } - } - - private suspend fun uploadSelectedImage(): Result { - val image = imageState.value.image ?: return Result.success(null) - return uploadImage(image) - .onSuccess { - imageState.value = imageState.value.copy( - image = WPMediaLibraryImage(content = it) - ) - } - .onFailure { - savingProductState.value = SavingProductState.Error( - messageRes = R.string.ai_product_creation_error_media_upload, - onRetryClick = ::onSaveProductAsDraft, - onDismissClick = { savingProductState.value = SavingProductState.Idle } - ) } + } } private fun trackUndoEditClick(field: String) { diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailBottomSheetBuilder.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailBottomSheetBuilder.kt index a7b3785f3b6..ee9c9e4d100 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailBottomSheetBuilder.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailBottomSheetBuilder.kt @@ -4,6 +4,7 @@ import androidx.annotation.StringRes import com.woocommerce.android.R.string import com.woocommerce.android.analytics.AnalyticsEvent import com.woocommerce.android.model.Product +import com.woocommerce.android.model.ProductAggregate import com.woocommerce.android.model.SubscriptionProductVariation import com.woocommerce.android.ui.customfields.CustomFieldsRepository import com.woocommerce.android.ui.products.ProductNavigationTarget @@ -49,84 +50,84 @@ class ProductDetailBottomSheetBuilder( ) @Suppress("LongMethod") - suspend fun buildBottomSheetList(product: Product): List { - return when (product.productType) { + suspend fun buildBottomSheetList(productAggregate: ProductAggregate): List { + return when (productAggregate.product.productType) { SIMPLE, SUBSCRIPTION -> { listOfNotNull( - product.getShipping(), - product.getCategories(), - product.getTags(), - product.getShortDescription(), - product.getLinkedProducts(), - product.getDownloadableFiles(), - product.getCustomFields() + productAggregate.getShipping(), + productAggregate.product.getCategories(), + productAggregate.product.getTags(), + productAggregate.product.getShortDescription(), + productAggregate.product.getLinkedProducts(), + productAggregate.product.getDownloadableFiles(), + productAggregate.product.getCustomFields() ) } EXTERNAL -> { listOfNotNull( - product.getCategories(), - product.getTags(), - product.getShortDescription(), - product.getLinkedProducts(), - product.getCustomFields() + productAggregate.product.getCategories(), + productAggregate.product.getTags(), + productAggregate.product.getShortDescription(), + productAggregate.product.getLinkedProducts(), + productAggregate.product.getCustomFields() ) } GROUPED -> { listOfNotNull( - product.getCategories(), - product.getTags(), - product.getShortDescription(), - product.getLinkedProducts(), - product.getCustomFields() + productAggregate.product.getCategories(), + productAggregate.product.getTags(), + productAggregate.product.getShortDescription(), + productAggregate.product.getLinkedProducts(), + productAggregate.product.getCustomFields() ) } VARIABLE, VARIABLE_SUBSCRIPTION -> { listOfNotNull( - product.getShipping(), - product.getCategories(), - product.getTags(), - product.getShortDescription(), - product.getLinkedProducts(), - product.getCustomFields() + productAggregate.getShipping(), + productAggregate.product.getCategories(), + productAggregate.product.getTags(), + productAggregate.product.getShortDescription(), + productAggregate.product.getLinkedProducts(), + productAggregate.product.getCustomFields() ) } else -> { listOfNotNull( - product.getCategories(), - product.getTags(), - product.getShortDescription(), - product.getCustomFields() + productAggregate.product.getCategories(), + productAggregate.product.getTags(), + productAggregate.product.getShortDescription(), + productAggregate.product.getCustomFields() ) } } } - private fun Product.getShipping(): ProductDetailBottomSheetUiItem? { - return if (!isVirtual && !hasShipping) { + private fun ProductAggregate.getShipping(): ProductDetailBottomSheetUiItem? { + return if (!product.isVirtual && !hasShipping) { ProductDetailBottomSheetUiItem( ProductDetailBottomSheetType.PRODUCT_SHIPPING, ViewProductShipping( ShippingData( - weight = weight, - length = length, - width = width, - height = height, - shippingClassSlug = shippingClass, - shippingClassId = shippingClassId, - subscriptionShippingData = if (productType == SUBSCRIPTION || - this.productType == VARIABLE_SUBSCRIPTION + weight = product.weight, + length = product.length, + width = product.width, + height = product.height, + shippingClassSlug = product.shippingClass, + shippingClassId = product.shippingClassId, + subscriptionShippingData = if (product.productType == SUBSCRIPTION || + product.productType == VARIABLE_SUBSCRIPTION ) { ShippingData.SubscriptionShippingData( oneTimeShipping = subscription?.oneTimeShipping ?: false, - canEnableOneTimeShipping = if (productType == SUBSCRIPTION) { + canEnableOneTimeShipping = if (product.productType == SUBSCRIPTION) { subscription?.supportsOneTimeShipping ?: false } else { // For variable subscription products, we need to check against the variations - variationRepository.getProductVariationList(remoteId).all { + variationRepository.getProductVariationList(product.remoteId).all { (it as? SubscriptionProductVariation)?.subscriptionDetails ?.supportsOneTimeShipping ?: false } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailCardBuilder.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailCardBuilder.kt index 171a2433cb3..13ed2ca0cf2 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailCardBuilder.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailCardBuilder.kt @@ -19,6 +19,8 @@ import com.woocommerce.android.extensions.filterNotEmpty import com.woocommerce.android.extensions.isEligibleForAI import com.woocommerce.android.extensions.isSet import com.woocommerce.android.model.Product +import com.woocommerce.android.model.ProductAggregate +import com.woocommerce.android.model.SubscriptionDetails import com.woocommerce.android.model.SubscriptionProductVariation import com.woocommerce.android.tools.SelectedSite import com.woocommerce.android.ui.blaze.BlazeUrlsHelper.BlazeFlowSource @@ -110,38 +112,41 @@ class ProductDetailCardBuilder( private val onTooltipDismiss = { appPrefsWrapper.isAIProductDescriptionTooltipDismissed = true } - suspend fun buildPropertyCards(product: Product, originalSku: String): List { + suspend fun buildPropertyCards( + productAggregate: ProductAggregate, + originalSku: String + ): List { this.originalSku = originalSku val cards = mutableListOf() - cards.addIfNotEmpty(getPrimaryCard(product)) - - cards.addIfNotEmpty(getBlazeCard(product)) - - when (product.productType) { - SIMPLE -> cards.addIfNotEmpty(getSimpleProductCard(product)) - VARIABLE -> cards.addIfNotEmpty(getVariableProductCard(product)) - GROUPED -> cards.addIfNotEmpty(getGroupedProductCard(product)) - EXTERNAL -> cards.addIfNotEmpty(getExternalProductCard(product)) - SUBSCRIPTION -> cards.addIfNotEmpty(getSubscriptionProductCard(product)) - VARIABLE_SUBSCRIPTION -> cards.addIfNotEmpty(getVariableSubscriptionProductCard(product)) - BUNDLE -> cards.addIfNotEmpty(getBundleProductsCard(product)) - COMPOSITE -> cards.addIfNotEmpty(getCompositeProductsCard(product)) - else -> cards.addIfNotEmpty(getOtherProductCard(product)) + cards.addIfNotEmpty(getPrimaryCard(productAggregate)) + + cards.addIfNotEmpty(getBlazeCard(productAggregate)) + + when (productAggregate.product.productType) { + SIMPLE -> cards.addIfNotEmpty(getSimpleProductCard(productAggregate)) + VARIABLE -> cards.addIfNotEmpty(getVariableProductCard(productAggregate)) + GROUPED -> cards.addIfNotEmpty(getGroupedProductCard(productAggregate)) + EXTERNAL -> cards.addIfNotEmpty(getExternalProductCard(productAggregate)) + SUBSCRIPTION -> cards.addIfNotEmpty(getSubscriptionProductCard(productAggregate)) + VARIABLE_SUBSCRIPTION -> cards.addIfNotEmpty(getVariableSubscriptionProductCard(productAggregate)) + BUNDLE -> cards.addIfNotEmpty(getBundleProductsCard(productAggregate)) + COMPOSITE -> cards.addIfNotEmpty(getCompositeProductsCard(productAggregate)) + else -> cards.addIfNotEmpty(getOtherProductCard(productAggregate)) } return cards } - private fun getPrimaryCard(product: Product): ProductPropertyCard { - val showTooltip = product.description.isEmpty() && + private fun getPrimaryCard(productAggregate: ProductAggregate): ProductPropertyCard { + val showTooltip = productAggregate.product.description.isEmpty() && !appPrefsWrapper.isAIProductDescriptionTooltipDismissed && appPrefsWrapper.getAIDescriptionTooltipShownNumber() <= MAXIMUM_TIMES_TO_SHOW_TOOLTIP return ProductPropertyCard( type = PRIMARY, properties = ( - listOf(product.title()) + - product.description( + listOf(productAggregate.product.title()) + + productAggregate.product.description( showAIButton = selectedSite.get().isEligibleForAI, showTooltip = showTooltip, onWriteWithAIClicked = viewModel::onWriteWithAIClicked, @@ -151,15 +156,15 @@ class ProductDetailCardBuilder( ) } - private suspend fun getBlazeCard(product: Product): ProductPropertyCard? { - val isProductPublic = product.status == ProductStatus.PUBLISH && + private suspend fun getBlazeCard(productAggregate: ProductAggregate): ProductPropertyCard? { + val isProductPublic = productAggregate.product.status == ProductStatus.PUBLISH && viewModel.getProductVisibility() == ProductVisibility.PUBLIC @Suppress("ComplexCondition") if (!isBlazeEnabled() || !isProductPublic || viewModel.isProductUnderCreation || - isProductCurrentlyPromoted(product.remoteId.toString()) + isProductCurrentlyPromoted(productAggregate.product.remoteId.toString()) ) { return null } @@ -186,169 +191,169 @@ class ProductDetailCardBuilder( ) } - private suspend fun getSimpleProductCard(product: Product): ProductPropertyCard { + private suspend fun getSimpleProductCard(productAggregate: ProductAggregate): ProductPropertyCard { return ProductPropertyCard( type = SECONDARY, properties = listOf( - product.price(), - if (viewModel.isProductUnderCreation) null else product.productReviews(), - product.inventory(SIMPLE), - product.addons(), - product.quantityRules(), - product.shipping(), - product.categories(), - product.tags(), - product.shortDescription(), - product.linkedProducts(), - product.downloads(), - product.customFields(), - product.productType() + productAggregate.price(), + if (viewModel.isProductUnderCreation) null else productAggregate.product.productReviews(), + productAggregate.product.inventory(SIMPLE), + productAggregate.product.addons(), + productAggregate.product.quantityRules(), + productAggregate.shipping(), + productAggregate.product.categories(), + productAggregate.product.tags(), + productAggregate.product.shortDescription(), + productAggregate.product.linkedProducts(), + productAggregate.product.downloads(), + productAggregate.product.customFields(), + productAggregate.product.productType() ).filterNotEmpty() ) } - private suspend fun getGroupedProductCard(product: Product): ProductPropertyCard { + private suspend fun getGroupedProductCard(productAggregate: ProductAggregate): ProductPropertyCard { return ProductPropertyCard( type = SECONDARY, properties = listOf( - product.groupedProducts(), - if (viewModel.isProductUnderCreation) null else product.productReviews(), - product.inventory(GROUPED), - product.addons(), - product.quantityRules(), - product.categories(), - product.tags(), - product.shortDescription(), - product.linkedProducts(), - product.customFields(), - product.productType() + productAggregate.product.groupedProducts(), + if (viewModel.isProductUnderCreation) null else productAggregate.product.productReviews(), + productAggregate.product.inventory(GROUPED), + productAggregate.product.addons(), + productAggregate.product.quantityRules(), + productAggregate.product.categories(), + productAggregate.product.tags(), + productAggregate.product.shortDescription(), + productAggregate.product.linkedProducts(), + productAggregate.product.customFields(), + productAggregate.product.productType() ).filterNotEmpty() ) } - private suspend fun getExternalProductCard(product: Product): ProductPropertyCard { + private suspend fun getExternalProductCard(productAggregate: ProductAggregate): ProductPropertyCard { return ProductPropertyCard( type = SECONDARY, properties = listOf( - product.price(), - if (viewModel.isProductUnderCreation) null else product.productReviews(), - product.externalLink(), - product.inventory(EXTERNAL), - product.addons(), - product.quantityRules(), - product.categories(), - product.tags(), - product.shortDescription(), - product.linkedProducts(), - product.customFields(), - product.productType() + productAggregate.price(), + if (viewModel.isProductUnderCreation) null else productAggregate.product.productReviews(), + productAggregate.product.externalLink(), + productAggregate.product.inventory(EXTERNAL), + productAggregate.product.addons(), + productAggregate.product.quantityRules(), + productAggregate.product.categories(), + productAggregate.product.tags(), + productAggregate.product.shortDescription(), + productAggregate.product.linkedProducts(), + productAggregate.product.customFields(), + productAggregate.product.productType() ).filterNotEmpty() ) } - private suspend fun getVariableProductCard(product: Product): ProductPropertyCard { + private suspend fun getVariableProductCard(productAggregate: ProductAggregate): ProductPropertyCard { return ProductPropertyCard( type = SECONDARY, properties = listOf( - product.warning(), - product.variations(), - product.variationAttributes(), - if (viewModel.isProductUnderCreation) null else product.productReviews(), - product.inventory(VARIABLE), - product.addons(), - product.quantityRules(), - product.shipping(), - product.categories(), - product.tags(), - product.shortDescription(), - product.linkedProducts(), - product.customFields(), - product.productType() + productAggregate.product.warning(), + productAggregate.product.variations(), + productAggregate.product.variationAttributes(), + if (viewModel.isProductUnderCreation) null else productAggregate.product.productReviews(), + productAggregate.product.inventory(VARIABLE), + productAggregate.product.addons(), + productAggregate.product.quantityRules(), + productAggregate.shipping(), + productAggregate.product.categories(), + productAggregate.product.tags(), + productAggregate.product.shortDescription(), + productAggregate.product.linkedProducts(), + productAggregate.product.customFields(), + productAggregate.product.productType() ).filterNotEmpty() ) } - private suspend fun getSubscriptionProductCard(product: Product): ProductPropertyCard { + private suspend fun getSubscriptionProductCard(productAggregate: ProductAggregate): ProductPropertyCard { return ProductPropertyCard( type = SECONDARY, properties = listOf( - product.price(), - product.subscriptionExpirationDate(), - product.subscriptionTrial(), - if (viewModel.isProductUnderCreation) null else product.productReviews(), - product.inventory(SIMPLE), - product.addons(), - product.quantityRules(), - product.shipping(), - product.categories(), - product.tags(), - product.shortDescription(), - product.linkedProducts(), - product.downloads(), - product.customFields(), - product.productType() + productAggregate.price(), + productAggregate.subscription?.subscriptionExpirationDate(), + productAggregate.subscription?.subscriptionTrial(), + if (viewModel.isProductUnderCreation) null else productAggregate.product.productReviews(), + productAggregate.product.inventory(SIMPLE), + productAggregate.product.addons(), + productAggregate.product.quantityRules(), + productAggregate.shipping(), + productAggregate.product.categories(), + productAggregate.product.tags(), + productAggregate.product.shortDescription(), + productAggregate.product.linkedProducts(), + productAggregate.product.downloads(), + productAggregate.product.customFields(), + productAggregate.product.productType() ).filterNotEmpty() ) } - private suspend fun getVariableSubscriptionProductCard(product: Product): ProductPropertyCard { + private suspend fun getVariableSubscriptionProductCard(productAggregate: ProductAggregate): ProductPropertyCard { return ProductPropertyCard( type = SECONDARY, properties = listOf( - product.warning(), - product.variations(), - product.variationAttributes(), - if (viewModel.isProductUnderCreation) null else product.productReviews(), - product.inventory(VARIABLE), - product.addons(), - product.quantityRules(), - product.shipping(), - product.categories(), - product.tags(), - product.shortDescription(), - product.linkedProducts(), - product.customFields(), - product.productType() + productAggregate.product.warning(), + productAggregate.product.variations(), + productAggregate.product.variationAttributes(), + if (viewModel.isProductUnderCreation) null else productAggregate.product.productReviews(), + productAggregate.product.inventory(VARIABLE), + productAggregate.product.addons(), + productAggregate.product.quantityRules(), + productAggregate.shipping(), + productAggregate.product.categories(), + productAggregate.product.tags(), + productAggregate.product.shortDescription(), + productAggregate.product.linkedProducts(), + productAggregate.product.customFields(), + productAggregate.product.productType() ).filterNotEmpty() ) } - private suspend fun getBundleProductsCard(product: Product): ProductPropertyCard { + private suspend fun getBundleProductsCard(productAggregate: ProductAggregate): ProductPropertyCard { return ProductPropertyCard( type = SECONDARY, properties = listOf( - product.bundleProducts(), - product.price(), - if (viewModel.isProductUnderCreation) null else product.productReviews(), - product.inventory(SIMPLE), - product.addons(), - product.quantityRules(), - product.categories(), - product.tags(), - product.shortDescription(), - product.linkedProducts(), - product.customFields(), - product.productType() + productAggregate.product.bundleProducts(), + productAggregate.price(), + if (viewModel.isProductUnderCreation) null else productAggregate.product.productReviews(), + productAggregate.product.inventory(SIMPLE), + productAggregate.product.addons(), + productAggregate.product.quantityRules(), + productAggregate.product.categories(), + productAggregate.product.tags(), + productAggregate.product.shortDescription(), + productAggregate.product.linkedProducts(), + productAggregate.product.customFields(), + productAggregate.product.productType() ).filterNotEmpty() ) } - private suspend fun getCompositeProductsCard(product: Product): ProductPropertyCard { + private suspend fun getCompositeProductsCard(productAggregate: ProductAggregate): ProductPropertyCard { return ProductPropertyCard( type = SECONDARY, properties = listOf( - product.componentProducts(), - product.price(), - if (viewModel.isProductUnderCreation) null else product.productReviews(), - product.inventory(SIMPLE), - product.addons(), - product.quantityRules(), - product.categories(), - product.tags(), - product.shortDescription(), - product.linkedProducts(), - product.customFields(), - product.productType() + productAggregate.product.componentProducts(), + productAggregate.price(), + if (viewModel.isProductUnderCreation) null else productAggregate.product.productReviews(), + productAggregate.product.inventory(SIMPLE), + productAggregate.product.addons(), + productAggregate.product.quantityRules(), + productAggregate.product.categories(), + productAggregate.product.tags(), + productAggregate.product.shortDescription(), + productAggregate.product.linkedProducts(), + productAggregate.product.customFields(), + productAggregate.product.productType() ).filterNotEmpty() ) } @@ -357,19 +362,19 @@ class ProductDetailCardBuilder( * Used for product types the app doesn't support yet (ex: subscriptions), uses a subset * of properties since we can't be sure pricing, shipping, etc., are applicable */ - private suspend fun getOtherProductCard(product: Product): ProductPropertyCard { + private suspend fun getOtherProductCard(productAggregate: ProductAggregate): ProductPropertyCard { return ProductPropertyCard( type = SECONDARY, properties = listOf( - if (viewModel.isProductUnderCreation) null else product.productReviews(), - product.addons(), - product.quantityRules(), - product.categories(), - product.tags(), - product.shortDescription(), - product.linkedProducts(), - product.customFields(), - product.productType() + if (viewModel.isProductUnderCreation) null else productAggregate.product.productReviews(), + productAggregate.product.addons(), + productAggregate.product.quantityRules(), + productAggregate.product.categories(), + productAggregate.product.tags(), + productAggregate.product.shortDescription(), + productAggregate.product.linkedProducts(), + productAggregate.product.customFields(), + productAggregate.product.productType() ).filterNotEmpty() ) } @@ -463,16 +468,17 @@ class ProductDetailCardBuilder( } } - private fun Product.shipping(): ProductProperty? { - return if (!this.isVirtual && hasShipping) { - val weightWithUnits = this.getWeightWithUnits(parameters.weightUnit) - val sizeWithUnits = this.getSizeWithUnits(parameters.dimensionUnit) + @Suppress("LongMethod") + private fun ProductAggregate.shipping(): ProductProperty? { + return if (!this.product.isVirtual && hasShipping) { + val weightWithUnits = product.getWeightWithUnits(parameters.weightUnit) + val sizeWithUnits = product.getSizeWithUnits(parameters.dimensionUnit) val shippingGroup = mapOf( Pair(resources.getString(string.product_weight), weightWithUnits), Pair(resources.getString(string.product_dimensions), sizeWithUnits), Pair( resources.getString(string.product_shipping_class), - viewModel.getShippingClassByRemoteShippingClassId(this.shippingClassId) + viewModel.getShippingClassByRemoteShippingClassId(this.product.shippingClassId) ), Pair( resources.getString(string.subscription_one_time_shipping), @@ -492,22 +498,22 @@ class ProductDetailCardBuilder( viewModel.onEditProductCardClicked( ViewProductShipping( ShippingData( - weight = weight, - length = length, - width = width, - height = height, - shippingClassSlug = shippingClass, - shippingClassId = shippingClassId, - subscriptionShippingData = if (productType == SUBSCRIPTION || - this.productType == VARIABLE_SUBSCRIPTION + weight = product.weight, + length = product.length, + width = product.width, + height = product.height, + shippingClassSlug = product.shippingClass, + shippingClassId = product.shippingClassId, + subscriptionShippingData = if (product.productType == SUBSCRIPTION || + product.productType == VARIABLE_SUBSCRIPTION ) { ShippingData.SubscriptionShippingData( oneTimeShipping = subscription?.oneTimeShipping ?: false, - canEnableOneTimeShipping = if (productType == SUBSCRIPTION) { + canEnableOneTimeShipping = if (product.productType == SUBSCRIPTION) { subscription?.supportsOneTimeShipping ?: false } else { // For variable subscription products, we need to check against the variations - variationRepository.getProductVariationList(remoteId).all { + variationRepository.getProductVariationList(product.remoteId).all { (it as? SubscriptionProductVariation)?.subscriptionDetails ?.supportsOneTimeShipping ?: false } @@ -552,16 +558,16 @@ class ProductDetailCardBuilder( } } - private fun Product.price(): ProductProperty { + private fun ProductAggregate.price(): ProductProperty { val pricingData = PricingData( - taxClass = taxClass, - taxStatus = taxStatus, - isSaleScheduled = isSaleScheduled, - saleStartDate = saleStartDateGmt, - saleEndDate = saleEndDateGmt, - regularPrice = regularPrice, - salePrice = salePrice, - isSubscription = this.productType == SUBSCRIPTION, + taxClass = product.taxClass, + taxStatus = product.taxStatus, + isSaleScheduled = product.isSaleScheduled, + saleStartDate = product.saleStartDateGmt, + saleEndDate = product.saleEndDateGmt, + regularPrice = product.regularPrice, + salePrice = product.salePrice, + isSubscription = product.productType == SUBSCRIPTION, subscriptionPeriod = subscription?.period, subscriptionInterval = subscription?.periodInterval, subscriptionSignUpFee = subscription?.signUpFee, @@ -578,7 +584,7 @@ class ProductDetailCardBuilder( string.product_price, pricingGroup, drawable.ic_gridicons_money, - showTitle = this.regularPrice.isSet() + showTitle = product.regularPrice.isSet() ) { viewModel.onEditProductCardClicked( ViewProductPricing(pricingData), @@ -894,43 +900,37 @@ class ProductDetailCardBuilder( ) } - private fun Product.subscriptionExpirationDate(): ProductProperty? = - this.subscription?.let { subscription -> - PropertyGroup( - title = string.product_subscription_expiration_title, - icon = drawable.ic_calendar_expiration, - properties = mapOf( - resources.getString(string.subscription_expire) to subscription.expirationDisplayValue( - resources - ) - ), - showTitle = true, - onClick = { - viewModel.onEditProductCardClicked( - ViewProductSubscriptionExpiration(subscription), - AnalyticsEvent.PRODUCT_DETAILS_VIEW_SUBSCRIPTION_EXPIRATION_TAPPED - ) - } + private fun SubscriptionDetails.subscriptionExpirationDate(): ProductProperty = PropertyGroup( + title = string.product_subscription_expiration_title, + icon = drawable.ic_calendar_expiration, + properties = mapOf( + resources.getString(string.subscription_expire) to expirationDisplayValue( + resources + ) + ), + showTitle = true, + onClick = { + viewModel.onEditProductCardClicked( + ViewProductSubscriptionExpiration(this), + AnalyticsEvent.PRODUCT_DETAILS_VIEW_SUBSCRIPTION_EXPIRATION_TAPPED ) } - - private fun Product.subscriptionTrial(): ProductProperty? = - this.subscription?.let { subscription -> - PropertyGroup( - title = string.product_subscription_free_trial_title, - icon = drawable.ic_hourglass_empty, - properties = mapOf( - resources.getString(string.subscription_free_trial) to subscription.trialDisplayValue(resources) - ), - showTitle = true, - onClick = { - viewModel.onEditProductCardClicked( - ViewProductSubscriptionFreeTrial(subscription), - AnalyticsEvent.PRODUCT_DETAILS_VIEW_SUBSCRIPTION_FREE_TRIAL_TAPPED - ) - } + ) + + private fun SubscriptionDetails.subscriptionTrial(): ProductProperty = PropertyGroup( + title = string.product_subscription_free_trial_title, + icon = drawable.ic_hourglass_empty, + properties = mapOf( + resources.getString(string.subscription_free_trial) to trialDisplayValue(resources) + ), + showTitle = true, + onClick = { + viewModel.onEditProductCardClicked( + ViewProductSubscriptionFreeTrial(this), + AnalyticsEvent.PRODUCT_DETAILS_VIEW_SUBSCRIPTION_FREE_TRIAL_TAPPED ) } + ) private fun Product.warning(): ProductProperty? { val variations = variationRepository.getProductVariationList(this.remoteId) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailRepository.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailRepository.kt index 8605bebf736..265e4a146c3 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailRepository.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailRepository.kt @@ -5,14 +5,17 @@ import com.woocommerce.android.analytics.AnalyticsEvent.PRODUCT_DETAIL_UPDATE_ER import com.woocommerce.android.analytics.AnalyticsEvent.PRODUCT_DETAIL_UPDATE_SUCCESS import com.woocommerce.android.analytics.AnalyticsTracker import com.woocommerce.android.model.Product +import com.woocommerce.android.model.ProductAggregate import com.woocommerce.android.model.ProductAttribute import com.woocommerce.android.model.ProductAttributeTerm import com.woocommerce.android.model.ProductGlobalAttribute import com.woocommerce.android.model.RequestResult import com.woocommerce.android.model.ShippingClass +import com.woocommerce.android.model.SubscriptionDetailsMapper import com.woocommerce.android.model.TaxClass import com.woocommerce.android.model.toAppModel import com.woocommerce.android.model.toDataModel +import com.woocommerce.android.model.toMetaData import com.woocommerce.android.tools.SelectedSite import com.woocommerce.android.ui.products.models.QuantityRules import com.woocommerce.android.util.ContinuationWrapper @@ -35,6 +38,7 @@ import org.wordpress.android.fluxc.action.WCProductAction.FETCH_SINGLE_PRODUCT_S import org.wordpress.android.fluxc.action.WCProductAction.UPDATED_PRODUCT import org.wordpress.android.fluxc.action.WCProductAction.UPDATE_PRODUCT_PASSWORD import org.wordpress.android.fluxc.generated.WCProductActionBuilder +import org.wordpress.android.fluxc.model.metadata.MetadataChanges import org.wordpress.android.fluxc.model.metadata.WCMetaData import org.wordpress.android.fluxc.store.WCGlobalAttributeStore import org.wordpress.android.fluxc.store.WCProductStore @@ -90,6 +94,17 @@ class ProductDetailRepository @Inject constructor( return getProduct(remoteProductId) } + suspend fun fetchAndGetProductAggregate(remoteProductId: Long): ProductAggregate? { + val payload = WCProductStore.FetchSingleProductPayload(selectedSite.get(), remoteProductId) + val result = productStore.fetchSingleProduct(payload) + + if (result.isError) { + lastFetchProductErrorType = result.error.type + } + + return getProductAggregate(remoteProductId) + } + suspend fun fetchProductPassword(remoteProductId: Long): String? { this.remoteProductId = remoteProductId val result = continuationFetchProductPassword.callAndWaitUntilTimeout(AppConstants.REQUEST_TIMEOUT) { @@ -107,14 +122,25 @@ class ProductDetailRepository @Inject constructor( * * @return the result of the action as a [Boolean] */ - suspend fun updateProduct(updatedProduct: Product): Pair { + suspend fun updateProduct(updatedProductAggregate: ProductAggregate): Pair { return try { suspendCoroutineWithTimeout>(AppConstants.REQUEST_TIMEOUT) { continuationUpdateProduct = it - val cachedProduct = getCachedWCProductModel(updatedProduct.remoteId) - val product = updatedProduct.toDataModel(cachedProduct) - val payload = WCProductStore.UpdateProductPayload(selectedSite.get(), product) + val cachedProduct = getCachedWCProductModel(updatedProductAggregate.remoteId) + val product = updatedProductAggregate.product.toDataModel(cachedProduct) + val metadataChanges = MetadataChanges( + // Even though the subscription keys are passed as new metadata here, the server will replace any + // existing keys with the new ones. + insertedMetadata = updatedProductAggregate.subscription?.toMetaData()?.map { (key, value) -> + WCMetaData(id = 0L, key = key, value = value) + } ?: emptyList() + ) + val payload = WCProductStore.UpdateProductPayload( + site = selectedSite.get(), + product = product, + metadataChanges = metadataChanges + ) dispatcher.dispatch(WCProductActionBuilder.newUpdateProductAction(payload)) } ?: Pair(false, null) // request timed out } catch (e: CancellationException) { @@ -123,17 +149,30 @@ class ProductDetailRepository @Inject constructor( } } + /** + * Fires the request to update the product + * + * @return the result of the action as a [Boolean] + */ + suspend fun updateProduct(updatedProduct: Product): Pair { + return updateProduct(ProductAggregate(updatedProduct, null)) + } + /** * Fires the request to add a product * * @return the result of the action as a [Boolean] */ - suspend fun addProduct(product: Product): Pair { + suspend fun addProduct(productAggregate: ProductAggregate): Pair { return try { suspendCoroutineWithTimeout>(AppConstants.REQUEST_TIMEOUT) { continuationAddProduct = it - val model = product.toDataModel(null) - val payload = WCProductStore.AddProductPayload(selectedSite.get(), model) + val model = productAggregate.product.toDataModel(null) + val payload = WCProductStore.AddProductPayload( + site = selectedSite.get(), + product = model, + metadata = productAggregate.subscription?.toMetaData() + ) dispatcher.dispatch(WCProductActionBuilder.newAddProductAction(payload)) } ?: Pair(false, 0L) // request timed out } catch (e: CancellationException) { @@ -142,6 +181,13 @@ class ProductDetailRepository @Inject constructor( } } + /** + * Fires the request to add a product + * + * @return the result of the action as a [Boolean] + */ + suspend fun addProduct(product: Product): Pair = addProduct(ProductAggregate(product, null)) + /** * Fires the request to update the product password * @@ -276,6 +322,12 @@ class ProductDetailRepository @Inject constructor( getCachedWCProductModel(remoteProductId)?.toAppModel() } + suspend fun getProductAggregate(remoteProductId: Long): ProductAggregate? { + val product = getProduct(remoteProductId) ?: return null + val subscriptionDetails = SubscriptionDetailsMapper.toAppModel(getProductMetadata(remoteProductId)) + return ProductAggregate(product, subscriptionDetails) + } + fun isSkuAvailableLocally(sku: String) = !productStore.geProductExistsBySku(selectedSite.get(), sku) fun getCachedVariationCount(remoteProductId: Long) = diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailViewModel.kt index 6fa960ddbc3..2bf012494e4 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailViewModel.kt @@ -33,12 +33,14 @@ import com.woocommerce.android.media.MediaFilesRepository import com.woocommerce.android.media.MediaFilesRepository.UploadResult import com.woocommerce.android.model.Component import com.woocommerce.android.model.Product +import com.woocommerce.android.model.ProductAggregate import com.woocommerce.android.model.ProductAttribute import com.woocommerce.android.model.ProductCategory import com.woocommerce.android.model.ProductFile import com.woocommerce.android.model.ProductGlobalAttribute import com.woocommerce.android.model.ProductTag import com.woocommerce.android.model.RequestResult +import com.woocommerce.android.model.SubscriptionDetails import com.woocommerce.android.model.SubscriptionPeriod import com.woocommerce.android.model.addTags import com.woocommerce.android.model.sortCategories @@ -187,8 +189,8 @@ class ProductDetailViewModel @Inject constructor( savedState = savedState, initialValue = ProductDetailViewState(areImagesAvailable = !selectedSite.get().isPrivate) ) { old, new -> - if (old?.productDraft != new.productDraft) { - new.productDraft?.let { + if (old?.productAggregateDraft != new.productAggregateDraft) { + new.productAggregateDraft?.let { updateCards(it) draftChanges.value = it } @@ -200,9 +202,9 @@ class ProductDetailViewModel @Inject constructor( * The goal of this is to allow composition of reactive streams using the product draft changes, * we need a separate stream because [LiveDataDelegate] supports a single observer. */ - private val draftChanges = MutableStateFlow(null) + private val draftChanges = MutableStateFlow(null) - private val storedProduct = MutableStateFlow(null) + private val storedProductAggregate = MutableStateFlow(null) /** * Saving more data than necessary into the SavedState has associated risks which were not known at the time this @@ -298,9 +300,9 @@ class ProductDetailViewModel @Inject constructor( ProductDetailBottomSheetBuilder(resources, variationRepository, customFieldsRepository) } - private val _hasChanges = storedProduct - .combine(draftChanges) { storedProduct, productDraft -> - storedProduct?.let { product -> productDraft?.isSameProduct(product) == false } ?: false + private val _hasChanges = storedProductAggregate + .combine(draftChanges) { storedProductAggregate, productAggregateDraft -> + storedProductAggregate?.let { product -> productAggregateDraft?.isSame(product) == false } ?: false }.stateIn(viewModelScope, SharingStarted.Eagerly, false) val hasChanges = _hasChanges.asLiveData() @@ -318,8 +320,8 @@ class ProductDetailViewModel @Inject constructor( val menuButtonsState = draftChanges .filterNotNull() - .combine(_hasChanges) { productDraft, hasChanges -> - Pair(productDraft, hasChanges) + .combine(_hasChanges) { draft, hasChanges -> + Pair(draft.product, hasChanges) }.map { (productDraft, hasChanges) -> val canBeSavedAsDraft = this.isAddNewProductFlow && !isProductStoredAtSite && @@ -374,7 +376,7 @@ class ProductDetailViewModel @Inject constructor( * Validates if the view model was started for the **add** flow AND there is an already valid product to modify. */ val isProductUnderCreation: Boolean - get() = isAddNewProductFlow and isProductStoredAtSite.not() + get() = isAddNewProductFlow && isProductStoredAtSite.not() /** * Returns boolean value of [navArgs.isTrashEnabled] to determine if the detail fragment should enable @@ -442,29 +444,31 @@ class ProductDetailViewModel @Inject constructor( private fun startAddNewProduct() { val defaultProduct = createDefaultProductForAddFlow() viewState = viewState.copy( - productDraft = defaultProduct + productAggregateDraft = defaultProduct ) updateProductState(defaultProduct) trackProductDetailLoaded() } - private fun createDefaultProductForAddFlow(): Product { + private fun createDefaultProductForAddFlow(): ProductAggregate { val preferredSavedType = appPrefsWrapper.getSelectedProductType() val defaultProductType = ProductType.fromString(preferredSavedType) val isProductVirtual = appPrefsWrapper.isSelectedProductVirtual() - return ProductHelper.getDefaultNewProduct(defaultProductType, isProductVirtual) + return ProductHelper.getDefaultProductAggregate(defaultProductType, isProductVirtual) } private fun initializeStoredProductAfterRestoration() { launch { if (isAddNewProductFlow && !isProductStoredAtSite) { - storedProduct.value = createDefaultProductForAddFlow() + storedProductAggregate.value = createDefaultProductForAddFlow() } else { when (val mode = navArgs.mode) { is ProductDetailFragment.Mode.ShowProduct -> { - storedProduct.value = productRepository.getProductAsync( + productRepository.getProductAggregate( viewState.productDraft?.remoteId ?: mode.remoteProductId - ) + )?.let { + storedProductAggregate.value = it + } } ProductDetailFragment.Mode.Loading -> { @@ -681,7 +685,7 @@ class ProductDetailViewModel @Inject constructor( when (variationRepository.bulkCreateVariations(remoteProductId, variationCandidates)) { RequestResult.SUCCESS -> { tracker.track(AnalyticsEvent.PRODUCT_VARIATION_GENERATION_SUCCESS) - productRepository.fetchAndGetProduct(remoteProductId) + productRepository.fetchAndGetProductAggregate(remoteProductId) ?.also { updateProductState(productToUpdateFrom = it) } triggerEvent(ProductExitEvent.ExitAttributesAdded) } @@ -704,22 +708,23 @@ class ProductDetailViewModel @Inject constructor( ) variationRepository.createEmptyVariation(draft) ?.let { - productRepository.fetchAndGetProduct(draft.remoteId) + productRepository.fetchAndGetProductAggregate(draft.remoteId) ?.also { updateProductState(productToUpdateFrom = it) } } }.also { attributeListViewState = attributeListViewState.copy(progressDialogState = ProgressDialogState.Hidden) } - fun hasCategoryChanges() = storedProduct.value?.hasCategoryChanges(viewState.productDraft) ?: false + fun hasCategoryChanges() = storedProductAggregate.value + ?.product?.hasCategoryChanges(viewState.productDraft) ?: false - fun hasTagChanges() = storedProduct.value?.hasTagChanges(viewState.productDraft) ?: false + fun hasTagChanges() = storedProductAggregate.value?.product?.hasTagChanges(viewState.productDraft) ?: false fun hasSettingsChanges(): Boolean { - return if (storedProduct.value?.hasSettingsChanges(viewState.productDraft) == true) { + return if (storedProductAggregate.value?.product?.hasSettingsChanges(viewState.productDraft) == true) { true } else { - storedProduct.value?.password != viewState.draftPassword + storedProductAggregate.value?.product?.password != viewState.draftPassword } } @@ -847,7 +852,8 @@ class ProductDetailViewModel @Inject constructor( ) } - fun hasExternalLinkChanges() = storedProduct.value?.hasExternalLinkChanges(viewState.productDraft) ?: false + fun hasExternalLinkChanges() = storedProductAggregate.value + ?.product?.hasExternalLinkChanges(viewState.productDraft) ?: false /** * Called when the back= button is clicked in a product sub detail screen @@ -1011,11 +1017,11 @@ class ProductDetailViewModel @Inject constructor( * 3. is a Draft */ fun saveAsDraftIfNewVariableProduct() = launch { - viewState.productDraft + viewState.productAggregateDraft ?.takeIf { - isProductStoredAtSite.not() and - it.productType.isVariableProduct() and - (it.status == DRAFT) + isProductStoredAtSite.not() && + it.product.productType.isVariableProduct() && + (it.product.status == DRAFT) } ?.takeIf { addProduct(it).first } ?.let { @@ -1029,22 +1035,22 @@ class ProductDetailViewModel @Inject constructor( stat = AnalyticsEvent.PRODUCT_DETAIL_UPDATE_BUTTON_TAPPED, properties = mapOf(AnalyticsTracker.KEY_IS_AI_CONTENT to navArgs.isAIContent) ) - viewState.productDraft?.let { - val product = if (isPublish) it.copy(status = ProductStatus.PUBLISH) else it + viewState.productAggregateDraft?.let { + val product = if (isPublish) it.copy(product = it.product.copy(status = ProductStatus.PUBLISH)) else it viewState = viewState.copy(isProgressDialogShown = true) launch { updateProduct(isPublish, product) } } } private fun startPublishProduct(productStatus: ProductStatus, exitWhenDone: Boolean = false) { - viewState.productDraft?.let { - val product = it.copy(status = productStatus) - trackPublishing(product) + viewState.productAggregateDraft?.let { + val productAggregate = it.copy(product = it.product.copy(status = productStatus)) + trackPublishing(productAggregate.product) viewState = viewState.copy(isProgressDialogShown = true) launch { - val (isSuccess, newProductId) = addProduct(product) + val (isSuccess, newProductId) = addProduct(productAggregate) viewState = viewState.copy(isProgressDialogShown = false) val snackbarMessage = pickAddProductRequestSnackbarText(isSuccess, productStatus) triggerEvent(ShowSnackbar(snackbarMessage)) @@ -1052,8 +1058,8 @@ class ProductDetailViewModel @Inject constructor( if (isPublishingFirstProduct()) { triggerEvent( ProductNavigationTarget.ViewFirstProductCelebration( - productName = product.name, - permalink = product.permalink + productName = productAggregate.product.name, + permalink = productAggregate.product.permalink ) ) } @@ -1064,13 +1070,13 @@ class ProductDetailViewModel @Inject constructor( ) } tracker.track(AnalyticsEvent.ADD_PRODUCT_SUCCESS) - if (product.remoteId != newProductId) { + if (productAggregate.remoteId != newProductId) { // Assign the current uploads to the new product id mediaFileUploadHandler.assignUploadsToCreatedProduct(newProductId) } if (exitWhenDone) { triggerEvent(ProductNavigationTarget.ExitProduct) - } else if (product.remoteId != newProductId) { + } else if (productAggregate.remoteId != newProductId) { // Restart observing image uploads using the new product id observeImageUploadEvents() } @@ -1135,10 +1141,10 @@ class ProductDetailViewModel @Inject constructor( } private fun trackWithProductId(event: AnalyticsEvent) { - storedProduct.value?.let { + storedProductAggregate.value?.let { tracker.track( event, - mapOf(AnalyticsTracker.KEY_PRODUCT_ID to it.remoteId) + mapOf(AnalyticsTracker.KEY_PRODUCT_ID to it.product.remoteId) ) } } @@ -1175,7 +1181,7 @@ class ProductDetailViewModel @Inject constructor( */ fun onSettingsVisibilityButtonClicked() { val visibility = getProductVisibility() - val password = viewState.draftPassword ?: storedProduct.value?.password + val password = viewState.draftPassword ?: storedProductAggregate.value?.product?.password triggerEvent( ProductNavigationTarget.ViewProductVisibility( selectedSite.connectionType == SiteConnectionType.ApplicationPasswords, @@ -1222,13 +1228,11 @@ class ProductDetailViewModel @Inject constructor( ) { updateProductDraft(type = productType.value, isVirtual = isVirtual) - viewState.productDraft?.let { productDraft -> - if (productType == ProductType.SUBSCRIPTION && productDraft.subscription == null) { + viewState.productAggregateDraft?.let { productAggregateDraft -> + if (productType == ProductType.SUBSCRIPTION && productAggregateDraft.subscription == null) { viewState = viewState.copy( - productDraft = productDraft.copy( - subscription = ProductHelper.getDefaultSubscriptionDetails().copy( - price = productDraft.regularPrice - ) + subscriptionDraft = ProductHelper.getDefaultSubscriptionDetails().copy( + price = productAggregateDraft.product.regularPrice ) ) } @@ -1331,12 +1335,12 @@ class ProductDetailViewModel @Inject constructor( saleEndDateGmt = if (productHasSale(isSaleScheduled, product)) { saleEndDate } else { - storedProduct.value?.saleEndDateGmt + storedProductAggregate.value?.product?.saleEndDateGmt }, saleStartDateGmt = if (productHasSale(isSaleScheduled, product)) { saleStartDate ?: product.saleStartDateGmt } else { - storedProduct.value?.saleStartDateGmt + storedProductAggregate.value?.product?.saleStartDateGmt }, downloads = downloads ?: product.downloads, downloadLimit = downloadLimit ?: product.downloadLimit, @@ -1353,17 +1357,16 @@ class ProductDetailViewModel @Inject constructor( } fun updateProductSubscription( - price: BigDecimal? = viewState.productDraft?.subscription?.price, + price: BigDecimal? = viewState.productAggregateDraft?.subscription?.price, period: SubscriptionPeriod? = null, periodInterval: Int? = null, - signUpFee: BigDecimal? = viewState.productDraft?.subscription?.signUpFee, + signUpFee: BigDecimal? = viewState.productAggregateDraft?.subscription?.signUpFee, length: Int? = null, trialLength: Int? = null, trialPeriod: SubscriptionPeriod? = null, oneTimeShipping: Boolean? = null ) { - viewState.productDraft?.let { product -> - val subscription = product.subscription ?: return + viewState.productAggregateDraft?.subscription?.let { subscription -> // The length ranges depend on the subscription period (days,weeks,months,years) and interval. If these // change we need to reset the length to "Never expire". This replicates web behavior val updatedLength = subscription.resetSubscriptionLengthIfThePeriodOrIntervalChanged( @@ -1381,7 +1384,7 @@ class ProductDetailViewModel @Inject constructor( trialPeriod = trialPeriod ?: subscription.trialPeriod, oneTimeShipping = oneTimeShipping ?: subscription.oneTimeShipping ) - viewState = viewState.copy(productDraft = product.copy(subscription = updatedSubscription)) + viewState = viewState.copy(subscriptionDraft = updatedSubscription) } } @@ -1402,10 +1405,13 @@ class ProductDetailViewModel @Inject constructor( } } - private fun updateCards(product: Product) { + private fun updateCards(productAggregate: ProductAggregate) { launch(dispatchers.io) { mutex.withLock { - val cards = cardBuilder.buildPropertyCards(product, storedProduct.value?.sku ?: "") + val cards = cardBuilder.buildPropertyCards( + productAggregate = productAggregate, + originalSku = storedProductAggregate.value?.product?.sku ?: "" + ) withContext(dispatchers.main) { _productDetailCards.value = cards } @@ -1415,7 +1421,7 @@ class ProductDetailViewModel @Inject constructor( } private fun fetchBottomSheetList() { - viewState.productDraft?.let { + viewState.productAggregateDraft?.let { launch(dispatchers.computation) { val detailList = productDetailBottomSheetBuilder.buildBottomSheetList(it) withContext(dispatchers.main) { @@ -1454,13 +1460,13 @@ class ProductDetailViewModel @Inject constructor( launch { // fetch product - val productInDb = productRepository.getProductAsync(remoteProductId) - if (productInDb != null) { + val productAggregateInDb = productRepository.getProductAggregate(remoteProductId) + if (productAggregateInDb != null) { val shouldFetch = remoteProductId != getRemoteProductId() - updateProductState(productInDb) + updateProductState(productAggregateInDb) val cachedVariationCount = productRepository.getCachedVariationCount(remoteProductId) - if (shouldFetch || cachedVariationCount != productInDb.numVariations) { + if (shouldFetch || cachedVariationCount != productAggregateInDb.product.numVariations) { fetchProduct(remoteProductId) fetchProductPassword(remoteProductId) } @@ -1477,7 +1483,7 @@ class ProductDetailViewModel @Inject constructor( */ private fun trackProductDetailLoaded() { if (hasTrackedProductDetailLoaded.not()) { - storedProduct.value?.let { product -> + storedProductAggregate.value?.product?.let { product -> launch { val properties = mapOf( AnalyticsTracker.KEY_HAS_LINKED_PRODUCTS to product.hasLinkedProducts(), @@ -1531,8 +1537,8 @@ class ProductDetailViewModel @Inject constructor( * then the visibility is `PRIVATE`, otherwise it's `PUBLIC`. */ fun getProductVisibility(): ProductVisibility { - val status = viewState.productDraft?.status ?: storedProduct.value?.status - val password = viewState.draftPassword ?: storedProduct.value?.password + val status = viewState.productDraft?.status ?: storedProductAggregate.value?.product?.status + val password = viewState.draftPassword ?: storedProductAggregate.value?.product?.password return when { password?.isNotEmpty() == true -> { ProductVisibility.PASSWORD_PROTECTED @@ -1555,11 +1561,11 @@ class ProductDetailViewModel @Inject constructor( val productPasswordApi = determineProductPasswordApi() val password = when (productPasswordApi) { ProductPasswordApi.WPCOM -> productRepository.fetchProductPassword(remoteProductId) - ProductPasswordApi.CORE -> storedProduct.value?.password + ProductPasswordApi.CORE -> storedProductAggregate.value?.product?.password ProductPasswordApi.UNSUPPORTED -> return } - storedProduct.update { it?.copy(password = password) } + storedProductAggregate.update { it?.copy(product = it.product.copy(password = password)) } viewState = viewState.copy( productDraft = viewState.productDraft?.copy(password = viewState.draftPassword ?: password) ) @@ -1567,7 +1573,7 @@ class ProductDetailViewModel @Inject constructor( private suspend fun fetchProduct(remoteProductId: Long) { if (checkConnection()) { - val fetchedProduct = productRepository.fetchAndGetProduct(remoteProductId) + val fetchedProduct = productRepository.fetchAndGetProductAggregate(remoteProductId) if (fetchedProduct != null) { updateProductState(fetchedProduct) } else { @@ -1853,7 +1859,8 @@ class ProductDetailViewModel @Inject constructor( triggerEvent(ProductNavigationTarget.RenameProductAttribute(attributeName)) } - fun hasAttributeChanges() = storedProduct.value?.hasAttributeChanges(viewState.productDraft) ?: false + fun hasAttributeChanges() = storedProductAggregate.value + ?.product?.hasAttributeChanges(viewState.productDraft) ?: false /** * Used by the add attribute screen to fetch the list of store-wide product attributes @@ -1947,20 +1954,22 @@ class ProductDetailViewModel @Inject constructor( * Updates the product to the backend only if network is connected. * Otherwise, an offline snackbar is displayed. */ - private suspend fun updateProduct(isPublish: Boolean, product: Product) { + private suspend fun updateProduct(isPublish: Boolean, productAggregate: ProductAggregate) { if (!checkConnection()) { viewState = viewState.copy(isProgressDialogShown = false) return } - val result = productRepository.updateProduct(product.copy(password = viewState.draftPassword)) + val result = productRepository.updateProduct( + productAggregate.copy(product = productAggregate.product.copy(password = viewState.draftPassword)) + ) if (result.first) { val successMsg = pickProductUpdateSuccessText(isPublish) - val isPasswordChanged = storedProduct.value?.password != viewState.draftPassword + val isPasswordChanged = storedProductAggregate.value?.product?.password != viewState.draftPassword if (isPasswordChanged && determineProductPasswordApi() == ProductPasswordApi.WPCOM) { // Update the product password using WordPress.com API val password = viewState.productDraft?.password - if (productRepository.updateProductPassword(product.remoteId, password)) { - storedProduct.update { it?.copy(password = password) } + if (productRepository.updateProductPassword(productAggregate.remoteId, password)) { + storedProductAggregate.update { it?.copy(product = it.product.copy(password = password)) } triggerEvent(ShowSnackbar(successMsg)) } else { triggerEvent(ShowSnackbar(R.string.product_detail_update_product_password_error)) @@ -1975,7 +1984,7 @@ class ProductDetailViewModel @Inject constructor( productDraft = null ) triggerEvent(ProductUpdated) - loadRemoteProduct(product.remoteId) + loadRemoteProduct(productAggregate.remoteId) } else { result.second?.let { if (it.canDisplayMessage) { @@ -1994,10 +2003,10 @@ class ProductDetailViewModel @Inject constructor( * Otherwise, an offline snackbar is displayed. Returns true only * if product successfully added */ - private suspend fun addProduct(product: Product): Pair { + private suspend fun addProduct(productAggregate: ProductAggregate): Pair { if (!checkConnection()) return Pair(false, 0L) - val result = productRepository.addProduct(product) + val result = productRepository.addProduct(productAggregate) val (isSuccess, newProductRemoteId) = result if (isSuccess) { checkLinkedProductPromo() @@ -2061,22 +2070,22 @@ class ProductDetailViewModel @Inject constructor( productRepository.getProductShippingClassByRemoteId(remoteShippingClassId)?.name ?: viewState.productDraft?.shippingClass ?: "" - private fun updateProductState(productToUpdateFrom: Product) { - val updatedDraft = viewState.productDraft?.let { currentDraft -> - if (storedProduct.value?.isSameProduct(currentDraft) == true) { + private fun updateProductState(productToUpdateFrom: ProductAggregate) { + val updatedDraft = viewState.productAggregateDraft?.let { currentDraft -> + if (storedProductAggregate.value?.isSame(currentDraft) == true) { productToUpdateFrom } else { - productToUpdateFrom.mergeProduct(currentDraft) + productToUpdateFrom.merge(currentDraft) } } ?: productToUpdateFrom - loadProductTaxAndShippingClassDependencies(updatedDraft) + loadProductTaxAndShippingClassDependencies(updatedDraft.product) viewState = viewState.copy( - productDraft = updatedDraft, + productAggregateDraft = updatedDraft, auxiliaryState = ProductDetailViewState.AuxiliaryState.None ) - storedProduct.value = productToUpdateFrom + storedProductAggregate.value = productToUpdateFrom } private fun loadProductTaxAndShippingClassDependencies(product: Product) { @@ -2097,6 +2106,7 @@ class ProductDetailViewModel @Inject constructor( imageUploadsJob?.cancel() imageUploadsJob = launch { draftChanges + .map { it?.product } .distinctUntilChanged { old, new -> old?.remoteId == new?.remoteId } .map { getRemoteProductId() } .filter { productId -> productId != DEFAULT_ADD_NEW_PRODUCT_ID || isAddNewProductFlow } @@ -2504,7 +2514,7 @@ class ProductDetailViewModel @Inject constructor( fun onDuplicateProduct() { launch { tracker.track(AnalyticsEvent.PRODUCT_DETAIL_DUPLICATE_BUTTON_TAPPED) - viewState.productDraft?.let { product -> + viewState.productAggregateDraft?.let { product -> triggerEvent(ShowDuplicateProductInProgress) val result = duplicateProduct(product) @@ -2578,7 +2588,7 @@ class ProductDetailViewModel @Inject constructor( } private fun hasSubscriptionExpirationChanges(): Boolean { - return storedProduct.value?.subscription?.length != viewState.productDraft?.subscription?.length + return storedProductAggregate.value?.subscription?.length != viewState.subscriptionDraft?.length } fun onProductCategorySearchQueryChanged(query: String) { @@ -2685,18 +2695,18 @@ class ProductDetailViewModel @Inject constructor( /** * [productDraft] is used for the UI. Any updates to the fields in the UI would update this model. - * [storedProduct.value] is the [Product] model that is fetched from the API and available in the local db. + * [storedProductAggregate.value] is the [Product] model that is fetched from the API and available in the local db. * This is read only and is not updated in any way. It is used in the product detail screen, to check * if we need to display the UPDATE menu button (which is only displayed if there are changes made to * any of the product fields). * - * When the user first enters the product detail screen, the [productDraft] and [storedProduct.value] are the same. + * When the user first enters the product detail screen, the [productDraft] and [storedProductAggregate.value] are the same. * When a change is made to the product in the UI, the [productDraft] model is updated with whatever change * has been made in the UI. */ @Parcelize data class ProductDetailViewState( - val productDraft: Product? = null, + val productAggregateDraft: ProductAggregate? = null, val auxiliaryState: AuxiliaryState = AuxiliaryState.None, val uploadingImageUris: List? = null, val isProgressDialogShown: Boolean? = null, @@ -2706,9 +2716,23 @@ class ProductDetailViewModel @Inject constructor( val isVariationListEmpty: Boolean? = null, val areImagesAvailable: Boolean ) : Parcelable { + val productDraft + get() = productAggregateDraft?.product + val subscriptionDraft + get() = productAggregateDraft?.subscription val draftPassword get() = productDraft?.password + fun copy(productDraft: Product?) = copy( + productAggregateDraft = productDraft?.let { + productAggregateDraft?.copy(product = it) ?: ProductAggregate(product = it) + } + ) + + fun copy(subscriptionDraft: SubscriptionDetails) = copy( + productAggregateDraft = productAggregateDraft?.copy(subscription = subscriptionDraft) + ) + @Parcelize sealed class AuxiliaryState : Parcelable { @Parcelize diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/util/FeatureFlag.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/util/FeatureFlag.kt index 12e807610cf..29d65305d67 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/util/FeatureFlag.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/util/FeatureFlag.kt @@ -28,15 +28,15 @@ enum class FeatureFlag { WC_SHIPPING_BANNER, BETTER_CUSTOMER_SEARCH_M2, ORDER_CREATION_AUTO_TAX_RATE, - REVAMP_WOO_SHIPPING, - OBJECTIVE_SECTION -> PackageUtils.isDebugBuild() + REVAMP_WOO_SHIPPING -> PackageUtils.isDebugBuild() NEW_SHIPPING_SUPPORT, INBOX, SHOW_INBOX_CTA, GOOGLE_ADS_M1, CUSTOM_FIELDS, - ENDLESS_CAMPAIGNS_SUPPORT -> true + ENDLESS_CAMPAIGNS_SUPPORT, + OBJECTIVE_SECTION -> true } } } diff --git a/WooCommerce/src/main/res/navigation/nav_graph_blaze_campaign_creation.xml b/WooCommerce/src/main/res/navigation/nav_graph_blaze_campaign_creation.xml index b89e972fc52..616f16d53c6 100644 --- a/WooCommerce/src/main/res/navigation/nav_graph_blaze_campaign_creation.xml +++ b/WooCommerce/src/main/res/navigation/nav_graph_blaze_campaign_creation.xml @@ -105,6 +105,15 @@ android:id="@+id/action_blazeCampaignCreationEditAdFragment_to_productImagePickerFragment" app:destination="@id/productImagePickerFragment" /> + + + - diff --git a/WooCommerce/src/main/res/navigation/nav_graph_orders.xml b/WooCommerce/src/main/res/navigation/nav_graph_orders.xml index a7efab48b9c..3c51d403aa8 100644 --- a/WooCommerce/src/main/res/navigation/nav_graph_orders.xml +++ b/WooCommerce/src/main/res/navigation/nav_graph_orders.xml @@ -309,6 +309,9 @@ app:argType="org.wordpress.android.fluxc.model.metadata.MetaDataParentItemType" android:defaultValue="ORDER" /> + + + + diff --git a/WooCommerce/src/main/res/values-night/colors_base.xml b/WooCommerce/src/main/res/values-night/colors_base.xml index 452dae09fa7..fe5d437c0ae 100644 --- a/WooCommerce/src/main/res/values-night/colors_base.xml +++ b/WooCommerce/src/main/res/values-night/colors_base.xml @@ -231,5 +231,6 @@ --> @color/woo_black_90_alpha_038 @color/woo_white_alpha_012 + @color/woo_purple_90 diff --git a/WooCommerce/src/main/res/values/colors_base.xml b/WooCommerce/src/main/res/values/colors_base.xml index 8589722e4fe..a19755ca585 100644 --- a/WooCommerce/src/main/res/values/colors_base.xml +++ b/WooCommerce/src/main/res/values/colors_base.xml @@ -301,6 +301,7 @@ @color/woo_gray_6 @color/woo_gray_0 @color/woo_gray_0 + @color/woo_purple_0 + Select objective %s + Good for: %s]]> + Save my selection for future campaigns + diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/media/ProductImagesUploadWorkerTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/media/ProductImagesUploadWorkerTest.kt index e765c7bd877..e4af37273e3 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/media/ProductImagesUploadWorkerTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/media/ProductImagesUploadWorkerTest.kt @@ -13,6 +13,7 @@ import com.woocommerce.android.media.ProductImagesUploadWorker.Event.ProductUpda import com.woocommerce.android.media.ProductImagesUploadWorker.Event.ProductUpdateEvent.ProductUpdateSucceeded import com.woocommerce.android.media.ProductImagesUploadWorker.Event.ProductUploadsCompleted import com.woocommerce.android.media.ProductImagesUploadWorker.Work +import com.woocommerce.android.model.Product import com.woocommerce.android.model.toAppModel import com.woocommerce.android.ui.products.ProductTestUtils import com.woocommerce.android.ui.products.details.ProductDetailRepository @@ -222,7 +223,7 @@ class ProductImagesUploadWorkerTest : BaseUnitTest() { fun `when update product succeeds, then send an event`() = testBlocking { val product = ProductTestUtils.generateProduct(REMOTE_PRODUCT_ID) whenever(productDetailRepository.fetchAndGetProduct(REMOTE_PRODUCT_ID)).thenReturn(product) - whenever(productDetailRepository.updateProduct(any())).thenReturn(Pair(true, null)) + whenever(productDetailRepository.updateProduct(any())).thenReturn(Pair(true, null)) val eventsList = mutableListOf() val job = launch { @@ -239,7 +240,7 @@ class ProductImagesUploadWorkerTest : BaseUnitTest() { fun `when update product fails, then retry three times`() = testBlocking { val product = ProductTestUtils.generateProduct(REMOTE_PRODUCT_ID) whenever(productDetailRepository.fetchAndGetProduct(REMOTE_PRODUCT_ID)).thenReturn(product) - whenever(productDetailRepository.updateProduct(any())).thenReturn(Pair(false, null)) + whenever(productDetailRepository.updateProduct(any())).thenReturn(Pair(false, null)) worker.enqueueWork(Work.UpdateProduct(REMOTE_PRODUCT_ID, listOf(UPLOADED_MEDIA))) @@ -252,7 +253,7 @@ class ProductImagesUploadWorkerTest : BaseUnitTest() { fun `when update product fails, then send an event`() = testBlocking { val product = ProductTestUtils.generateProduct(REMOTE_PRODUCT_ID) whenever(productDetailRepository.fetchAndGetProduct(REMOTE_PRODUCT_ID)).thenReturn(product) - whenever(productDetailRepository.updateProduct(any())).thenReturn(Pair(false, null)) + whenever(productDetailRepository.updateProduct(any())).thenReturn(Pair(false, null)) val eventsList = mutableListOf() val job = launch { diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/customfields/list/CustomFieldsViewModelTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/customfields/list/CustomFieldsViewModelTest.kt index c4f19fd831a..d83c920593d 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/customfields/list/CustomFieldsViewModelTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/customfields/list/CustomFieldsViewModelTest.kt @@ -491,7 +491,7 @@ class CustomFieldsViewModelTest : BaseUnitTest() { val customField = CustomField( id = 1, key = "key", - value = WCMetaDataValue.fromRawString("{\"key\": \"value\"}") + value = WCMetaDataValue("{\"key\": \"value\"}") ) setup { whenever(repository.observeDisplayableCustomFields(PARENT_ITEM_ID)).thenReturn(flowOf(listOf(customField))) @@ -514,7 +514,7 @@ class CustomFieldsViewModelTest : BaseUnitTest() { val customField = CustomField( id = 1, key = "key", - value = WCMetaDataValue.fromRawString("{\"key\": \"value\"}") + value = WCMetaDataValue("{\"key\": \"value\"}") ) setup { whenever(repository.observeDisplayableCustomFields(PARENT_ITEM_ID)).thenReturn(flowOf(listOf(customField))) diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/dashboard/DashboardViewModelTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/dashboard/DashboardViewModelTest.kt index 4dd97e25738..7969b54ad8f 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/dashboard/DashboardViewModelTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/dashboard/DashboardViewModelTest.kt @@ -307,4 +307,17 @@ class DashboardViewModelTest : BaseUnitTest() { val newWidgetsCard = widgets.first { it is DashboardViewModel.DashboardWidgetUiModel.NewWidgetsCard } assertThat(newWidgetsCard.isVisible).isFalse() } + + @Test + fun `given site is WPCom suspended, when visitor stats placeholder, then hide Jetpack benefits banner`() = testBlocking { + setup { + whenever(selectedSite.observe()) + .thenReturn(flowOf(SiteModel().apply { origin = SiteModel.ORIGIN_WPAPI })) + whenever(appPrefsWrapper.isSiteWPComSuspended).thenReturn(true) + } + + val jetpackBenefitsBannerState = viewModel.jetpackBenefitsBannerState.getOrAwaitValue() + + assertThat(jetpackBenefitsBannerState).isNull() + } } diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/login/sitecredentials/LoginSiteCredentialsViewModelTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/login/sitecredentials/LoginSiteCredentialsViewModelTest.kt index 03961ae494a..8376c4cbfcf 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/login/sitecredentials/LoginSiteCredentialsViewModelTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/login/sitecredentials/LoginSiteCredentialsViewModelTest.kt @@ -37,7 +37,6 @@ import org.mockito.kotlin.whenever import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType -import org.wordpress.android.fluxc.network.UserAgent import org.wordpress.android.fluxc.network.rest.wpapi.Nonce import org.wordpress.android.fluxc.network.rest.wpapi.WPAPINetworkError import org.wordpress.android.login.LoginAnalyticsListener @@ -74,7 +73,6 @@ class LoginSiteCredentialsViewModelTest : BaseUnitTest() { private val loginAnalyticsListener: LoginAnalyticsListener = mock() private val analyticsTracker: AnalyticsTrackerWrapper = mock() private val appPrefs: AppPrefsWrapper = mock() - private val userAgent: UserAgent = mock() private val resourceProvider: ResourceProvider = mock { on { getString(any()) } doAnswer { it.arguments[0].toString() } } @@ -97,7 +95,6 @@ class LoginSiteCredentialsViewModelTest : BaseUnitTest() { applicationPasswordsNotifier = applicationPasswordsNotifier, analyticsTracker = analyticsTracker, appPrefs = appPrefs, - userAgent = userAgent, applicationPasswordsClientId = clientId, resourceProvider = resourceProvider ) @@ -112,7 +109,7 @@ class LoginSiteCredentialsViewModelTest : BaseUnitTest() { }.last() assertThat(state).isEqualTo( - LoginSiteCredentialsViewModel.ViewState.NativeLoginViewState( + LoginSiteCredentialsViewModel.ViewState( siteUrl = siteAddressWithoutSchemeAndSuffix, username = "", password = "" @@ -120,33 +117,13 @@ class LoginSiteCredentialsViewModelTest : BaseUnitTest() { ) } - @Test - fun `given shown login error dialog, when user chooses wp-admin login, then show login webview`() = testBlocking { - setup { - whenever(wpApiSiteRepository.getSiteByLocalId(testSite.id)).thenReturn( - testSite.apply { applicationPasswordsAuthorizeUrl = urlAuthBase } - ) - } - - val state = viewModel.viewState.runAndCaptureValues { - viewModel.onStartWebAuthorizationClick() - }.last() - - assertThat(state).isEqualTo( - LoginSiteCredentialsViewModel.ViewState.WebAuthorizationViewState( - authorizationUrl = urlAuthFull, - userAgent = userAgent - ) - ) - } - @Test fun `when changing username, then update state`() = testBlocking { setup() val state = viewModel.viewState.runAndCaptureValues { viewModel.onUsernameChanged(testUsername) - }.last() as LoginSiteCredentialsViewModel.ViewState.NativeLoginViewState + }.last() assertThat(state.username).isEqualTo(testUsername) } @@ -157,7 +134,7 @@ class LoginSiteCredentialsViewModelTest : BaseUnitTest() { val state = viewModel.viewState.runAndCaptureValues { viewModel.onUsernameChanged(testPassword) - }.last() as LoginSiteCredentialsViewModel.ViewState.NativeLoginViewState + }.last() assertThat(state.username).isEqualTo(testPassword) } @@ -168,7 +145,7 @@ class LoginSiteCredentialsViewModelTest : BaseUnitTest() { val state = viewModel.viewState.runAndCaptureValues { viewModel.onUsernameChanged("") - }.last() as LoginSiteCredentialsViewModel.ViewState.NativeLoginViewState + }.last() assertThat(state.isValid).isFalse() } @@ -179,7 +156,7 @@ class LoginSiteCredentialsViewModelTest : BaseUnitTest() { val state = viewModel.viewState.runAndCaptureValues { viewModel.onPasswordChanged("") - }.last() as LoginSiteCredentialsViewModel.ViewState.NativeLoginViewState + }.last() assertThat(state.isValid).isFalse() } @@ -244,7 +221,7 @@ class LoginSiteCredentialsViewModelTest : BaseUnitTest() { viewModel.onUsernameChanged(testUsername) viewModel.onPasswordChanged(testPassword) viewModel.onContinueClick() - }.last() as LoginSiteCredentialsViewModel.ViewState.NativeLoginViewState + }.last() assertThat(state.errorDialogMessage).isEqualTo(expectedError.errorMessage) verify(analyticsTracker).track( @@ -315,7 +292,7 @@ class LoginSiteCredentialsViewModelTest : BaseUnitTest() { } @Test - fun `given application pwd disabled and wp-login-php accessible, when submitting native login, then show error screen`() = testBlocking { + fun `given application pwd disabled and wp-login-php accessible, when submitting login, then show error screen`() = testBlocking { setup { whenever(wpApiSiteRepository.checkIfUserIsEligible(testSite)).thenReturn(Result.failure(Exception())) } @@ -331,19 +308,6 @@ class LoginSiteCredentialsViewModelTest : BaseUnitTest() { .isEqualTo(ShowApplicationPasswordsUnavailableScreen(siteAddress, isJetpackConnected)) } - @Test - fun `given application pwd disabled and wp-login-php inaccessible, when choosing webview login, then show error`() = testBlocking { - setup() - - viewModel.viewState.observeForTesting { - viewModel.onStartWebAuthorizationClick() - applicationPasswordsUnavailableEvents.tryEmit(mock()) - } - - assertThat(viewModel.event.value) - .isEqualTo(ShowApplicationPasswordsUnavailableScreen(siteAddress, isJetpackConnected)) - } - @Test fun `give user role fetch fails, when submitting login, then show a snackbar`() = testBlocking { setup { diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/orders/OrderDetailViewModelTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/orders/OrderDetailViewModelTest.kt index 1182b58a429..82cc76776e6 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/orders/OrderDetailViewModelTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/orders/OrderDetailViewModelTest.kt @@ -27,6 +27,7 @@ import com.woocommerce.android.tools.ProductImageMap import com.woocommerce.android.tools.SelectedSite import com.woocommerce.android.ui.common.giftcard.GiftCardRepository import com.woocommerce.android.ui.orders.OrderNavigationTarget.PreviewReceipt +import com.woocommerce.android.ui.orders.OrderNavigationTarget.StartWooShippingLabelCreationFlow import com.woocommerce.android.ui.orders.creation.shipping.GetShippingMethodsWithOtherValue import com.woocommerce.android.ui.orders.creation.shipping.RefreshShippingMethods import com.woocommerce.android.ui.orders.creation.shipping.ShippingLineDetails @@ -41,6 +42,7 @@ import com.woocommerce.android.ui.orders.details.OrderDetailsTransactionLauncher import com.woocommerce.android.ui.orders.details.OrderProduct import com.woocommerce.android.ui.orders.details.OrderProductMapper import com.woocommerce.android.ui.orders.details.ShippingLabelOnboardingRepository +import com.woocommerce.android.ui.orders.details.ShippingLabelOnboardingRepository.ShippingLabelSupport import com.woocommerce.android.ui.payments.cardreader.payment.CardReaderPaymentCollectibilityChecker import com.woocommerce.android.ui.payments.receipt.PaymentReceiptHelper import com.woocommerce.android.ui.payments.tracking.PaymentsFlowTracker @@ -120,7 +122,7 @@ class OrderDetailViewModelTest : BaseUnitTest() { } private val paymentCollectibilityChecker: CardReaderPaymentCollectibilityChecker = mock() private val shippingLabelOnboardingRepository: ShippingLabelOnboardingRepository = mock { - doReturn(true).whenever(it).isShippingPluginReady + doReturn(ShippingLabelSupport.WCS_SUPPORTED).whenever(it).shippingPluginSupport } private val savedState = OrderDetailFragmentArgs( @@ -643,7 +645,8 @@ class OrderDetailViewModelTest : BaseUnitTest() { doReturn(Unit).whenever(orderDetailRepository).fetchSLCreationEligibility(order.id) doReturn(true).whenever(orderDetailRepository).isOrderEligibleForSLCreation(order.id) - doReturn(true).whenever(shippingLabelOnboardingRepository).isShippingPluginReady + doReturn(ShippingLabelSupport.WCS_SUPPORTED) + .whenever(shippingLabelOnboardingRepository).shippingPluginSupport val shippingLabels = ArrayList() viewModel.shippingLabels.observeForever { @@ -684,7 +687,8 @@ class OrderDetailViewModelTest : BaseUnitTest() { doReturn(Unit).whenever(orderDetailRepository).fetchSLCreationEligibility(order.id) doReturn(true).whenever(orderDetailRepository).isOrderEligibleForSLCreation(order.id) - doReturn(true).whenever(shippingLabelOnboardingRepository).isShippingPluginReady + doReturn(ShippingLabelSupport.WCS_SUPPORTED) + .whenever(shippingLabelOnboardingRepository).shippingPluginSupport val shippingLabels = ArrayList() viewModel.shippingLabels.observeForever { @@ -997,7 +1001,8 @@ class OrderDetailViewModelTest : BaseUnitTest() { doReturn(emptyList()).whenever(orderDetailRepository).fetchOrderRefunds(any()) doReturn(emptyList()).whenever(orderDetailRepository).fetchProductsByRemoteIds(any()) doReturn(false).whenever(addonsRepository).containsAddonsFrom(any()) - doReturn(true).whenever(shippingLabelOnboardingRepository).isShippingPluginReady + doReturn(ShippingLabelSupport.WCS_SUPPORTED) + .whenever(shippingLabelOnboardingRepository).shippingPluginSupport var isCreateShippingLabelButtonVisible: Boolean? = null viewModel.viewStateData.observeForever { _, new -> @@ -1014,7 +1019,8 @@ class OrderDetailViewModelTest : BaseUnitTest() { testBlocking { doReturn(order).whenever(orderDetailRepository).getOrderById(any()) doReturn(order).whenever(orderDetailRepository).fetchOrderById(any()) - doReturn(false).whenever(shippingLabelOnboardingRepository).isShippingPluginReady + doReturn(ShippingLabelSupport.NOT_SUPPORTED) + .whenever(shippingLabelOnboardingRepository).shippingPluginSupport doReturn(true).whenever(orderDetailRepository).fetchOrderNotes(any()) doReturn(RequestResult.SUCCESS).whenever(orderDetailRepository).fetchOrderShipmentTrackingList(any()) doReturn(emptyList()).whenever(orderDetailRepository).fetchOrderRefunds(any()) @@ -1059,7 +1065,8 @@ class OrderDetailViewModelTest : BaseUnitTest() { @Test fun `hide shipping label creation if wcs plugin is not installed`() = testBlocking { - doReturn(false).whenever(shippingLabelOnboardingRepository).isShippingPluginReady + doReturn(ShippingLabelSupport.NOT_SUPPORTED) + .whenever(shippingLabelOnboardingRepository).shippingPluginSupport doReturn(order).whenever(orderDetailRepository).getOrderById(any()) doReturn(order).whenever(orderDetailRepository).fetchOrderById(any()) doReturn(true).whenever(orderDetailRepository).fetchOrderNotes(any()) @@ -1689,7 +1696,8 @@ class OrderDetailViewModelTest : BaseUnitTest() { @Test fun `when service plugin is installed and active, then fetch plugin data`() = testBlocking { - doReturn(true).whenever(shippingLabelOnboardingRepository).isShippingPluginReady + doReturn(ShippingLabelSupport.WCS_SUPPORTED) + .whenever(shippingLabelOnboardingRepository).shippingPluginSupport doReturn(order).whenever(orderDetailRepository).getOrderById(any()) doReturn(true).whenever(orderDetailRepository).fetchOrderNotes(any()) doReturn(true).whenever(addonsRepository).containsAddonsFrom(any()) @@ -1703,7 +1711,7 @@ class OrderDetailViewModelTest : BaseUnitTest() { @Test fun `when service plugin is NOT active, then DON'T fetch plugin data`() = testBlocking { - doReturn(false).whenever(shippingLabelOnboardingRepository).isShippingPluginReady + doReturn(ShippingLabelSupport.NOT_SUPPORTED).whenever(shippingLabelOnboardingRepository).shippingPluginSupport doReturn(order).whenever(orderDetailRepository).getOrderById(any()) doReturn(true).whenever(orderDetailRepository).fetchOrderNotes(any()) doReturn(true).whenever(addonsRepository).containsAddonsFrom(any()) @@ -1717,7 +1725,7 @@ class OrderDetailViewModelTest : BaseUnitTest() { @Test fun `when service plugin is NOT installed, then DON'T fetch plugin data`() = testBlocking { - doReturn(false).whenever(shippingLabelOnboardingRepository).isShippingPluginReady + doReturn(ShippingLabelSupport.NOT_SUPPORTED).whenever(shippingLabelOnboardingRepository).shippingPluginSupport doReturn(order).whenever(orderDetailRepository).getOrderById(any()) doReturn(true).whenever(orderDetailRepository).fetchOrderNotes(any()) doReturn(true).whenever(addonsRepository).containsAddonsFrom(any()) @@ -1788,7 +1796,7 @@ class OrderDetailViewModelTest : BaseUnitTest() { @Test fun `when there is no info about the plugins, then optimistically fetch plugin data`() = testBlocking { - doReturn(true).whenever(shippingLabelOnboardingRepository).isShippingPluginReady + doReturn(ShippingLabelSupport.WCS_SUPPORTED).whenever(shippingLabelOnboardingRepository).shippingPluginSupport doReturn(order).whenever(orderDetailRepository).getOrderById(any()) doReturn(true).whenever(orderDetailRepository).fetchOrderNotes(any()) doReturn(true).whenever(addonsRepository).containsAddonsFrom(any()) @@ -2387,4 +2395,36 @@ class OrderDetailViewModelTest : BaseUnitTest() { mapOf(AnalyticsTracker.KEY_SHIPPING_LINES_COUNT to shippingLines.size) ) } + + @Test + fun `when woo shipping is installed, then navigate to the new shipping flow`() = testBlocking { + doReturn(order).whenever(orderDetailRepository).getOrderById(any()) + doReturn(true).whenever(addonsRepository).containsAddonsFrom(any()) + doReturn(true).whenever(orderDetailRepository).fetchOrderNotes(any()) + doReturn(ShippingLabelSupport.WC_SHIPPING_SUPPORTED) + .whenever(shippingLabelOnboardingRepository).shippingPluginSupport + + createViewModel() + + viewModel.start() + viewModel.onCreateShippingLabelButtonTapped() + + assertThat(viewModel.event.value).isInstanceOf(StartWooShippingLabelCreationFlow::class.java) + } + + @Test + fun `when woo shipping and tax is installed, then navigate to the legacy shipping flow`() = testBlocking { + doReturn(order).whenever(orderDetailRepository).getOrderById(any()) + doReturn(true).whenever(addonsRepository).containsAddonsFrom(any()) + doReturn(true).whenever(orderDetailRepository).fetchOrderNotes(any()) + doReturn(ShippingLabelSupport.WCS_SUPPORTED) + .whenever(shippingLabelOnboardingRepository).shippingPluginSupport + + createViewModel() + + viewModel.start() + viewModel.onCreateShippingLabelButtonTapped() + + assertThat(viewModel.event.value).isInstanceOf(OrderNavigationTarget.StartShippingLabelCreationFlow::class.java) + } } diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/orders/details/ShippingLabelOnboardingRepositoryTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/orders/details/ShippingLabelOnboardingRepositoryTest.kt index ed5fff2ff84..f39ac45aa50 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/orders/details/ShippingLabelOnboardingRepositoryTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/orders/details/ShippingLabelOnboardingRepositoryTest.kt @@ -7,6 +7,7 @@ import com.woocommerce.android.tools.SelectedSite import com.woocommerce.android.ui.orders.OrderTestUtils import com.woocommerce.android.ui.orders.details.ShippingLabelOnboardingRepository.Companion.SUPPORTED_WCS_COUNTRY import com.woocommerce.android.ui.orders.details.ShippingLabelOnboardingRepository.Companion.SUPPORTED_WCS_CURRENCY +import com.woocommerce.android.ui.orders.details.ShippingLabelOnboardingRepository.ShippingLabelSupport import com.woocommerce.android.viewmodel.BaseUnitTest import kotlinx.coroutines.ExperimentalCoroutinesApi import org.assertj.core.api.Assertions.assertThat @@ -129,7 +130,7 @@ class ShippingLabelOnboardingRepositoryTest : BaseUnitTest() { givenWCShippingPlugin(installed = false, active = false) // When - val isShippingPluginReady = sut.isShippingPluginReady + val isShippingPluginReady = sut.shippingPluginSupport.isSupported() // Then assertThat(isShippingPluginReady).isTrue @@ -142,12 +143,26 @@ class ShippingLabelOnboardingRepositoryTest : BaseUnitTest() { givenWCShippingPlugin(installed = true, active = true) // When - val isShippingPluginReady = sut.isShippingPluginReady + val isShippingPluginReady = sut.shippingPluginSupport.isSupported() // Then assertThat(isShippingPluginReady).isTrue } + @Test + fun `Given WC legacy shipping is ready and Shipping plugin is, then use Shipping plugin`() { + // Given + givenWCLegacyShippingPlugin(installed = true, active = true) + givenWCShippingPlugin(installed = true, active = true) + + // When + val isShippingPluginReady = sut.shippingPluginSupport.isSupported() + + // Then + assertThat(isShippingPluginReady).isTrue + assertThat(sut.shippingPluginSupport).isEqualTo(ShippingLabelSupport.WC_SHIPPING_SUPPORTED) + } + @Test fun `Given both WC legacy and shipping plugins are not ready, then isShippingPluginReady is false`() { // Given @@ -155,7 +170,7 @@ class ShippingLabelOnboardingRepositoryTest : BaseUnitTest() { givenWCShippingPlugin(installed = false, active = false) // When - val isShippingPluginReady = sut.isShippingPluginReady + val isShippingPluginReady = sut.shippingPluginSupport.isSupported() // Then assertThat(isShippingPluginReady).isFalse diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/payments/cardreader/LearnMoreUrlProviderTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/payments/cardreader/LearnMoreUrlProviderTest.kt index d7637962955..3218383ae1e 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/payments/cardreader/LearnMoreUrlProviderTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/payments/cardreader/LearnMoreUrlProviderTest.kt @@ -62,7 +62,7 @@ class LearnMoreUrlProviderTest { } @Test - fun `given preferred plugin WcPay, when providing learn more url for COD, then WcPay COD url returned`() { + fun `given preferred plugin WcPay, when providing learn more url for COD, then COD url returned`() { // GIVEN whenever(appPrefsWrapper.getCardReaderPreferredPlugin(any(), any(), any())) .thenReturn(WOOCOMMERCE_PAYMENTS) @@ -75,7 +75,7 @@ class LearnMoreUrlProviderTest { } @Test - fun `given preferred plugin null, when providing learn more url for COD, then WcPay COD learn more url returned`() { + fun `given preferred plugin null, when providing learn more url for COD, then COD learn more url returned`() { // GIVEN whenever(appPrefsWrapper.getCardReaderPreferredPlugin(any(), any(), any())) .thenReturn(null) @@ -88,7 +88,7 @@ class LearnMoreUrlProviderTest { } @Test - fun `given preferred plugin Stripe, when providing learn more url for COD, then Stripe COD url returned`() { + fun `given preferred plugin Stripe, when providing learn more url for COD, then COD learn more url returned`() { // GIVEN whenever(appPrefsWrapper.getCardReaderPreferredPlugin(any(), any(), any())) .thenReturn(STRIPE_EXTENSION_GATEWAY) @@ -97,6 +97,6 @@ class LearnMoreUrlProviderTest { val res = provider.provideLearnMoreUrlFor(CASH_ON_DELIVERY) // THEN - assertThat(res).isEqualTo(AppUrls.STRIPE_LEARN_MORE_ABOUT_PAYMENTS_CASH_ON_DELIVERY) + assertThat(res).isEqualTo(AppUrls.WOOCOMMERCE_LEARN_MORE_ABOUT_PAYMENTS_CASH_ON_DELIVERY) } } diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/DuplicateProductTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/DuplicateProductTest.kt index 62728b9a521..b1e61288b4b 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/DuplicateProductTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/DuplicateProductTest.kt @@ -1,6 +1,6 @@ package com.woocommerce.android.ui.products -import com.woocommerce.android.model.Product +import com.woocommerce.android.model.ProductAggregate import com.woocommerce.android.model.ProductVariation import com.woocommerce.android.ui.products.details.ProductDetailRepository import com.woocommerce.android.ui.products.variations.VariationRepository @@ -41,24 +41,25 @@ class DuplicateProductTest : BaseUnitTest() { @Test fun `should duplicate a product and set expected properties`() = testBlocking { // given - val productToDuplicate = ProductTestUtils.generateProduct().copy(sku = "not an empty value") + val productToDuplicate = ProductAggregate(ProductTestUtils.generateProduct().copy(sku = "not an empty value")) productDetailRepository.stub { - onBlocking { addProduct(any()) } doReturn Pair(true, 123) + onBlocking { addProduct(any()) } doReturn Pair(true, 123) } // when sut.invoke(productToDuplicate) // then - val duplicationRequestCapture = argumentCaptor() + val duplicationRequestCapture = argumentCaptor() verify(productDetailRepository).addProduct(duplicationRequestCapture.capture()) assertThat(duplicationRequestCapture.firstValue) .matches { - it.remoteId == 0L && it.name == "copied name" && it.sku == "" && it.status == ProductStatus.DRAFT + it.remoteId == 0L && it.product.name == "copied name" && + it.product.sku == "" && it.product.status == ProductStatus.DRAFT } .usingRecursiveComparison() - .ignoringFields("remoteId", "name", "sku", "status") + .ignoringFields("product.remoteId", "product.name", "product.sku", "product.status") .isEqualTo(productToDuplicate) } @@ -66,10 +67,10 @@ class DuplicateProductTest : BaseUnitTest() { fun `should duplicate a variable product and keep all properties of variations except sku and remoteProductId`() = testBlocking { // given - val productToDuplicate = ProductTestUtils.generateProduct().copy(numVariations = 15) + val productToDuplicate = ProductAggregate(ProductTestUtils.generateProduct().copy(numVariations = 15)) val duplicatedProductId = 456L productDetailRepository.stub { - onBlocking { addProduct(any()) } doReturn Pair(true, duplicatedProductId) + onBlocking { addProduct(any()) } doReturn Pair(true, duplicatedProductId) } val variationsOfProductToDuplicate = diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/SubscriptionDetailsMapperTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/SubscriptionDetailsMapperTest.kt index fb50393fb38..5639c0606df 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/SubscriptionDetailsMapperTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/SubscriptionDetailsMapperTest.kt @@ -1,16 +1,18 @@ package com.woocommerce.android.ui.products import com.woocommerce.android.model.SubscriptionDetailsMapper +import com.woocommerce.android.model.SubscriptionPaymentSyncDate import com.woocommerce.android.model.SubscriptionPeriod import com.woocommerce.android.viewmodel.BaseUnitTest import kotlinx.coroutines.ExperimentalCoroutinesApi import org.assertj.core.api.Assertions.assertThat import org.junit.Test +import org.wordpress.android.fluxc.model.WCProductModel +import org.wordpress.android.fluxc.model.metadata.WCMetaData import java.math.BigDecimal @OptIn(ExperimentalCoroutinesApi::class) class SubscriptionDetailsMapperTest : BaseUnitTest() { - @Test fun `when metadata is valid then a SubscriptionDetails is returned`() { val result = SubscriptionDetailsMapper.toAppModel(successMetadata) @@ -49,6 +51,57 @@ class SubscriptionDetailsMapperTest : BaseUnitTest() { } } + @Test + fun `when sync date contains both a day and month, then parse it successfully`() { + val metadata = """ [ { + "id": 5182, + "key": ${WCProductModel.SubscriptionMetadataKeys.SUBSCRIPTION_PAYMENT_SYNC_DATE}, + "value": { + "day": 1, + "month": 9 + } + }] + """ + val result = SubscriptionDetailsMapper.toAppModel(metadata) + assertThat(result).isNotNull + assertThat(result!!.paymentsSyncDate).isEqualTo( + SubscriptionPaymentSyncDate.MonthDay(month = 9, day = 1) + ) + } + + @Test + fun `when sync data day is 0, then parse it successfully`() { + val metadata = """ [ { + "id": 5182, + "key": ${WCProductModel.SubscriptionMetadataKeys.SUBSCRIPTION_PAYMENT_SYNC_DATE}, + "value": { + "day": 0, + "month": 0 + } + }] + """ + val result = SubscriptionDetailsMapper.toAppModel(metadata) + assertThat(result).isNotNull + assertThat(result!!.paymentsSyncDate).isEqualTo( + SubscriptionPaymentSyncDate.None + ) + } + + @Test + fun `when sync date contains only a day, then parse it successfully`() { + val metadata = """ [ { + "id": 5182, + "key": ${WCProductModel.SubscriptionMetadataKeys.SUBSCRIPTION_PAYMENT_SYNC_DATE}, + "value": 1 + }] + """ + val result = SubscriptionDetailsMapper.toAppModel(metadata) + assertThat(result).isNotNull + assertThat(result!!.paymentsSyncDate).isEqualTo( + SubscriptionPaymentSyncDate.Day(1) + ) + } + /** * price = 60, * period = month, @@ -59,77 +112,74 @@ class SubscriptionDetailsMapperTest : BaseUnitTest() { * trialLength = 2, * oneTimeShipping = yes */ - private val successMetadata = """ [ { - "id": 5182, - "key": "_subscription_payment_sync_date", - "value": "0" - }, - { - "id": 5183, - "key": "_subscription_price", - "value": "60" - }, - { - "id": 5187, - "key": "_subscription_trial_length", - "value": "2" - }, - { - "id": 5188, - "key": "_subscription_sign_up_fee", - "value": "5" - }, - { - "id": 5189, - "key": "_subscription_period", - "value": "month" - }, - { - "id": 5190, - "key": "_subscription_period_interval", - "value": "1" - }, - { - "id": 5191, - "key": "_subscription_length", - "value": "0" - }, - { - "id": 5192, - "key": "_subscription_trial_period", - "value": "day" - }, - { - "id": 5193, - "key": "_subscription_limit", - "value": "no" - }, - { - "id": 5194, - "key": "_subscription_one_time_shipping", - "value": "yes" - } ] - """ + private val successMetadata = listOf( + WCMetaData( + id = 5182, + key = WCProductModel.SubscriptionMetadataKeys.SUBSCRIPTION_PAYMENT_SYNC_DATE, + value = "0" + ), + WCMetaData( + id = 5183, + key = WCProductModel.SubscriptionMetadataKeys.SUBSCRIPTION_PRICE, + value = "60" + ), + WCMetaData( + id = 5187, + key = WCProductModel.SubscriptionMetadataKeys.SUBSCRIPTION_TRIAL_LENGTH, + value = "2" + ), + WCMetaData( + id = 5188, + key = WCProductModel.SubscriptionMetadataKeys.SUBSCRIPTION_SIGN_UP_FEE, + value = "5" + ), + WCMetaData( + id = 5189, + key = WCProductModel.SubscriptionMetadataKeys.SUBSCRIPTION_PERIOD, + value = "month" + ), + WCMetaData( + id = 5190, + key = WCProductModel.SubscriptionMetadataKeys.SUBSCRIPTION_PERIOD_INTERVAL, + value = "1" + ), + WCMetaData( + id = 5191, + key = WCProductModel.SubscriptionMetadataKeys.SUBSCRIPTION_LENGTH, + value = "0" + ), + WCMetaData( + id = 5192, + key = WCProductModel.SubscriptionMetadataKeys.SUBSCRIPTION_TRIAL_PERIOD, + value = "day" + ), + WCMetaData( + id = 5194, + key = WCProductModel.SubscriptionMetadataKeys.SUBSCRIPTION_ONE_TIME_SHIPPING, + value = "yes" + ) + ) /** * Metadata with no subscription key */ - private val noSubscriptionKeysMetadata = """ [ { - "id": 5182, - "key": "sync_date", - "value": "0" - }, - { - "id": 5183, - "key": "price", - "value": "60" - }, - { - "id": 5187, - "key": "trial_length", - "value": "2" - }] - """ + private val noSubscriptionKeysMetadata = listOf( + WCMetaData( + id = 5182, + key = "sync_date", + value = "0" + ), + WCMetaData( + id = 5183, + key = "price", + value = "60" + ), + WCMetaData( + id = 5187, + key = "trial_length", + value = "2" + ) + ) /** * price = 60, @@ -141,25 +191,26 @@ class SubscriptionDetailsMapperTest : BaseUnitTest() { * trialLength = 2, * oneTimeShipping = */ - private val successMetadataPartial = """ [ { - "id": 5182, - "key": "_subscription_payment_sync_date", - "value": "0" - }, - { - "id": 5183, - "key": "_subscription_price", - "value": "60" - }, - { - "id": 5187, - "key": "_subscription_trial_length", - "value": "2" - }, - { - "id": 5188, - "key": "_subscription_sign_up_fee", - "value": "5" - }] - """ + private val successMetadataPartial = listOf( + WCMetaData( + id = 5182, + key = WCProductModel.SubscriptionMetadataKeys.SUBSCRIPTION_PAYMENT_SYNC_DATE, + value = "0" + ), + WCMetaData( + id = 5183, + key = WCProductModel.SubscriptionMetadataKeys.SUBSCRIPTION_PRICE, + value = "60" + ), + WCMetaData( + id = 5187, + key = WCProductModel.SubscriptionMetadataKeys.SUBSCRIPTION_TRIAL_LENGTH, + value = "2" + ), + WCMetaData( + id = 5188, + key = WCProductModel.SubscriptionMetadataKeys.SUBSCRIPTION_SIGN_UP_FEE, + value = "5" + ) + ) } diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/ai/preview/AiProductPreviewViewModelTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/ai/preview/AiProductPreviewViewModelTest.kt index df762f4ee72..1e4b9c965c4 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/ai/preview/AiProductPreviewViewModelTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/ai/preview/AiProductPreviewViewModelTest.kt @@ -5,6 +5,7 @@ import com.woocommerce.android.analytics.AnalyticsTrackerWrapper import com.woocommerce.android.model.Image import com.woocommerce.android.model.Product import com.woocommerce.android.ui.products.ai.AIProductModel +import com.woocommerce.android.ui.products.ai.AiProductSaveResult import com.woocommerce.android.ui.products.ai.BuildProductPreviewProperties import com.woocommerce.android.ui.products.ai.SaveAiGeneratedProduct import com.woocommerce.android.ui.products.ai.components.ImageAction @@ -20,9 +21,11 @@ import org.assertj.core.api.Assertions.assertThat import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argThat import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.mock +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import java.util.Date @@ -42,16 +45,10 @@ class AiProductPreviewViewModelTest : BaseUnitTest() { Result.success(SAMPLE_PRODUCT) } } - private val uploadImage: UploadImage = mock { - onBlocking { invoke(any()) } doSuspendableAnswer { - delay(100) - Result.success(SAMPLE_UPLOADED_IMAGE) - } - } private val saveAiGeneratedProduct: SaveAiGeneratedProduct = mock { onBlocking { invoke(any(), anyOrNull()) } doSuspendableAnswer { delay(100) - Result.success(1L) + AiProductSaveResult.Success(1L) } } private val analyticsTracker: AnalyticsTrackerWrapper = mock() @@ -73,7 +70,6 @@ class AiProductPreviewViewModelTest : BaseUnitTest() { savedStateHandle = args.toSavedStateHandle(), buildProductPreviewProperties = buildProductPreviewProperties, generateProductWithAI = generateProductWithAI, - uploadImage = uploadImage, analyticsTracker = analyticsTracker, saveAiGeneratedProduct = saveAiGeneratedProduct, resourceProvider = resourceProvider, @@ -226,30 +222,6 @@ class AiProductPreviewViewModelTest : BaseUnitTest() { assertThat(event).isInstanceOf(MultiLiveEvent.Event.ShowUndoSnackbar::class.java) } - @Test - fun `given a local image, when the user taps on save, then upload the image`() = testBlocking { - setup( - args = AiProductPreviewFragmentArgs( - productFeatures = PRODUCT_FEATURES, - image = Image.LocalImage("path") - ) - ) - - val viewState = viewModel.state.runAndCaptureValues { - advanceUntilIdle() - viewModel.onSaveProductAsDraft() - advanceUntilIdle() - }.last() - - verify(uploadImage).invoke(Image.LocalImage("path")) - val successState = viewState as AiProductPreviewViewModel.State.Success - assertThat(successState.imageState).isEqualTo( - AiProductPreviewViewModel.ImageState( - image = Image.WPMediaLibraryImage(SAMPLE_UPLOADED_IMAGE) - ) - ) - } - @Test fun `given a local image, when the image upload fails, then show an error`() = testBlocking { setup( @@ -258,7 +230,8 @@ class AiProductPreviewViewModelTest : BaseUnitTest() { image = Image.LocalImage("path") ) ) { - whenever(uploadImage.invoke(any())).thenReturn(Result.failure(Exception())) + whenever(saveAiGeneratedProduct.invoke(any(), any())) + .thenReturn(AiProductSaveResult.Failure.UploadImageFailure) } val viewState = viewModel.state.runAndCaptureValues { @@ -274,6 +247,29 @@ class AiProductPreviewViewModelTest : BaseUnitTest() { } } + @Test + fun `given a local image uploaded successfully, when retrying after an error, then don't reupload the image`() = + testBlocking { + setup( + args = AiProductPreviewFragmentArgs( + productFeatures = PRODUCT_FEATURES, + image = Image.LocalImage("path") + ) + ) { + whenever(saveAiGeneratedProduct.invoke(any(), anyOrNull())) + .thenReturn(AiProductSaveResult.Failure.Generic(Image.WPMediaLibraryImage(SAMPLE_UPLOADED_IMAGE))) + .thenReturn(AiProductSaveResult.Success(1L)) + } + + advanceUntilIdle() + viewModel.onSaveProductAsDraft() + advanceUntilIdle() + viewModel.onSaveProductAsDraft() + + verify(saveAiGeneratedProduct, times(1)).invoke(any(), argThat { this is Image.LocalImage }) + verify(saveAiGeneratedProduct, times(1)).invoke(any(), argThat { this is Image.WPMediaLibraryImage }) + } + @Test fun `when product is saved successfully, then navigate to the product details`() = testBlocking { setup() @@ -290,7 +286,12 @@ class AiProductPreviewViewModelTest : BaseUnitTest() { @Test fun `when product saving fails, then show an error`() = testBlocking { setup { - whenever(saveAiGeneratedProduct.invoke(any(), anyOrNull())).thenReturn(Result.failure(Exception())) + whenever( + saveAiGeneratedProduct.invoke( + any(), + anyOrNull() + ) + ).thenReturn(AiProductSaveResult.Failure.Generic()) } val viewState = viewModel.state.runAndCaptureValues { diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/details/ProductDetailBottomSheetBuilderTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/details/ProductDetailBottomSheetBuilderTest.kt index 4486a5316cc..ebcf167f9dc 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/details/ProductDetailBottomSheetBuilderTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/details/ProductDetailBottomSheetBuilderTest.kt @@ -1,5 +1,6 @@ package com.woocommerce.android.ui.products.details +import com.woocommerce.android.model.ProductAggregate import com.woocommerce.android.ui.customfields.CustomFieldsRepository import com.woocommerce.android.ui.products.ProductNavigationTarget import com.woocommerce.android.ui.products.ProductTestUtils @@ -39,7 +40,7 @@ class ProductDetailBottomSheetBuilderTest : BaseUnitTest() { whenever(customFieldsRepository.hasDisplayableCustomFields(any())).thenReturn(true) val product = ProductTestUtils.generateProduct(productId = 1L) - val result = sut.buildBottomSheetList(product) + val result = sut.buildBottomSheetList(ProductAggregate(product)) assertThat(result).noneMatch { it.type == ProductDetailBottomSheetBuilder.ProductDetailBottomSheetType.CUSTOM_FIELDS @@ -51,7 +52,7 @@ class ProductDetailBottomSheetBuilderTest : BaseUnitTest() { whenever(customFieldsRepository.hasDisplayableCustomFields(any())).thenReturn(false) val product = ProductTestUtils.generateProduct(productId = 1L) - val result = sut.buildBottomSheetList(product) + val result = sut.buildBottomSheetList(ProductAggregate(product)) val customFieldsItem = result.single { it.type == ProductDetailBottomSheetBuilder.ProductDetailBottomSheetType.CUSTOM_FIELDS @@ -62,7 +63,7 @@ class ProductDetailBottomSheetBuilderTest : BaseUnitTest() { @Test fun `when product is not saved in server, then hide the custom fields item`() = testBlocking { val product = ProductTestUtils.generateProduct(productId = ProductDetailViewModel.DEFAULT_ADD_NEW_PRODUCT_ID) - val result = sut.buildBottomSheetList(product) + val result = sut.buildBottomSheetList(ProductAggregate(product)) assertThat(result).noneMatch { it.type == ProductDetailBottomSheetBuilder.ProductDetailBottomSheetType.CUSTOM_FIELDS diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/details/ProductDetailCardBuilderTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/details/ProductDetailCardBuilderTest.kt index 68a7b2e49ac..fd46cdc4549 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/details/ProductDetailCardBuilderTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/details/ProductDetailCardBuilderTest.kt @@ -2,6 +2,7 @@ package com.woocommerce.android.ui.products.details import com.woocommerce.android.R import com.woocommerce.android.model.Product +import com.woocommerce.android.model.ProductAggregate import com.woocommerce.android.tools.SelectedSite import com.woocommerce.android.ui.blaze.IsBlazeEnabled import com.woocommerce.android.ui.customfields.CustomFieldsRepository @@ -83,7 +84,7 @@ class ProductDetailCardBuilderTest : BaseUnitTest() { height = 0F ) - val cards = sut.buildPropertyCards(productStub, "") + val cards = sut.buildPropertyCards(ProductAggregate(productStub), "") Assertions.assertThat(cards).isNotEmpty cards.find { it.type == ProductPropertyCard.Type.SECONDARY } @@ -110,7 +111,7 @@ class ProductDetailCardBuilderTest : BaseUnitTest() { ) var foundAttributesCard = false - val cards = sut.buildPropertyCards(productStub, "") + val cards = sut.buildPropertyCards(ProductAggregate(productStub), "") Assertions.assertThat(cards).isNotEmpty cards.find { it.type == ProductPropertyCard.Type.SECONDARY } @@ -134,7 +135,7 @@ class ProductDetailCardBuilderTest : BaseUnitTest() { ) var foundQuantityRulesCard = false - val cards = sut.buildPropertyCards(productStub, "") + val cards = sut.buildPropertyCards(ProductAggregate(productStub), "") Assertions.assertThat(cards).isNotEmpty cards.find { it.type == ProductPropertyCard.Type.SECONDARY } @@ -156,7 +157,7 @@ class ProductDetailCardBuilderTest : BaseUnitTest() { whenever(customFieldsRepository.hasDisplayableCustomFields(any())) doReturn false productStub = ProductTestUtils.generateProduct(productId = 1L) - val cards = sut.buildPropertyCards(productStub, "") + val cards = sut.buildPropertyCards(ProductAggregate(productStub), "") val properties = cards.first { it.type == ProductPropertyCard.Type.SECONDARY }.properties val customFieldsCard = properties.find { @@ -171,7 +172,7 @@ class ProductDetailCardBuilderTest : BaseUnitTest() { whenever(customFieldsRepository.hasDisplayableCustomFields(any())) doReturn true productStub = ProductTestUtils.generateProduct(productId = 1L) - val cards = sut.buildPropertyCards(productStub, "") + val cards = sut.buildPropertyCards(ProductAggregate(productStub), "") val properties = cards.first { it.type == ProductPropertyCard.Type.SECONDARY }.properties val customFieldsCard = properties.find { @@ -184,7 +185,7 @@ class ProductDetailCardBuilderTest : BaseUnitTest() { @Test fun `when a new is not saved on the server, then hide the custom fields card`() = testBlocking { productStub = ProductTestUtils.generateProduct(productId = ProductDetailViewModel.DEFAULT_ADD_NEW_PRODUCT_ID) - val cards = sut.buildPropertyCards(productStub, "") + val cards = sut.buildPropertyCards(ProductAggregate(productStub), "") val properties = cards.first { it.type == ProductPropertyCard.Type.SECONDARY }.properties val customFieldsCard = properties.find { diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/details/ProductDetailViewModelGenerateVariationFlowTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/details/ProductDetailViewModelGenerateVariationFlowTest.kt index c2917f0d18a..72e062fef57 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/details/ProductDetailViewModelGenerateVariationFlowTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/details/ProductDetailViewModelGenerateVariationFlowTest.kt @@ -7,6 +7,7 @@ import com.woocommerce.android.analytics.AnalyticsTracker import com.woocommerce.android.analytics.AnalyticsTrackerWrapper import com.woocommerce.android.media.MediaFilesRepository import com.woocommerce.android.media.ProductImagesServiceWrapper +import com.woocommerce.android.model.ProductAggregate import com.woocommerce.android.model.RequestResult import com.woocommerce.android.model.VariantOption import com.woocommerce.android.tools.NetworkStatus @@ -92,7 +93,7 @@ class ProductDetailViewModelGenerateVariationFlowTest : BaseUnitTest() { doReturn(true).whenever(networkStatus).isConnected() productRepository = mock { - onBlocking { fetchAndGetProduct(PRODUCT_REMOTE_ID) } doReturn product + onBlocking { fetchAndGetProductAggregate(PRODUCT_REMOTE_ID) } doReturn ProductAggregate(product) } variationRepository = mock { diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/details/ProductDetailViewModelTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/details/ProductDetailViewModelTest.kt index 259f9869833..b846c22e2cd 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/details/ProductDetailViewModelTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/details/ProductDetailViewModelTest.kt @@ -8,6 +8,7 @@ import com.woocommerce.android.analytics.AnalyticsTrackerWrapper import com.woocommerce.android.extensions.takeIfNotEqualTo import com.woocommerce.android.media.MediaFilesRepository import com.woocommerce.android.media.ProductImagesServiceWrapper +import com.woocommerce.android.model.ProductAggregate import com.woocommerce.android.model.ProductAttribute import com.woocommerce.android.model.ProductVariation import com.woocommerce.android.tools.NetworkStatus @@ -124,7 +125,7 @@ class ProductDetailViewModelTest : BaseUnitTest() { private val prefsWrapper: AppPrefsWrapper = mock() private val productUtils = ProductUtils() - private val product = ProductTestUtils.generateProduct(PRODUCT_REMOTE_ID) + private val productAggregate = ProductAggregate(ProductTestUtils.generateProduct(PRODUCT_REMOTE_ID)) private val productWithTagsAndCategories = ProductTestUtils.generateProductWithTagsAndCategories(PRODUCT_REMOTE_ID) private val offlineProduct = ProductTestUtils.generateProduct(OFFLINE_PRODUCT_REMOTE_ID) private val productCategories = ProductTestUtils.generateProductCategories() @@ -141,7 +142,7 @@ class ProductDetailViewModelTest : BaseUnitTest() { } private val productWithParameters = ProductDetailViewModel.ProductDetailViewState( - productDraft = product, + productAggregateDraft = productAggregate, auxiliaryState = ProductDetailViewModel.ProductDetailViewState.AuxiliaryState.None, uploadingImageUris = emptyList(), showBottomSheetButton = true, @@ -152,8 +153,8 @@ class ProductDetailViewModelTest : BaseUnitTest() { ProductPropertyCard( type = ProductPropertyCard.Type.PRIMARY, properties = listOf( - ProductProperty.Editable(R.string.product_detail_title_hint, product.name), - ProductProperty.ComplexProperty(R.string.product_description, product.description) + ProductProperty.Editable(R.string.product_detail_title_hint, productAggregate.product.name), + ProductProperty.ComplexProperty(R.string.product_description, productAggregate.product.description) ) ), ProductPropertyCard( @@ -181,8 +182,8 @@ class ProductDetailViewModelTest : BaseUnitTest() { ), ProductProperty.RatingBar( R.string.product_reviews, - resources.getString(R.string.product_ratings_count, product.ratingCount), - product.averageRating, + resources.getString(R.string.product_ratings_count, productAggregate.product.ratingCount), + productAggregate.product.averageRating, R.drawable.ic_reviews ), ProductProperty.PropertyGroup( @@ -227,7 +228,7 @@ class ProductDetailViewModelTest : BaseUnitTest() { ), ProductProperty.ComplexProperty( R.string.product_short_description, - product.shortDescription, + productAggregate.product.shortDescription, R.drawable.ic_gridicons_align_left ), ProductProperty.ComplexProperty( @@ -296,7 +297,7 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `Displays the product detail properties correctly`() = testBlocking { doReturn(true).whenever(networkStatus).isConnected() - doReturn(productWithTagsAndCategories).whenever(productRepository).getProductAsync(any()) + doReturn(ProductAggregate(productWithTagsAndCategories)).whenever(productRepository).getProductAggregate(any()) viewModel.productDetailViewStateData.observeForever { _, _ -> } @@ -312,8 +313,8 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `Displays the product detail view correctly`() = testBlocking { - doReturn(product).whenever(productRepository).getProductAsync(any()) - doReturn(product).whenever(productRepository).fetchAndGetProduct(any()) + doReturn(productAggregate).whenever(productRepository).getProductAggregate(any()) + doReturn(productAggregate).whenever(productRepository).fetchAndGetProductAggregate(any()) var productData: ProductDetailViewModel.ProductDetailViewState? = null viewModel.productDetailViewStateData.observeForever { _, new -> productData = new } @@ -325,12 +326,12 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `given nothing returned from repo, when view model started, the error status emitted`() = testBlocking { - whenever(productRepository.fetchAndGetProduct(PRODUCT_REMOTE_ID)).thenReturn(null) - whenever(productRepository.getProductAsync(PRODUCT_REMOTE_ID)).thenReturn(null) + whenever(productRepository.fetchAndGetProductAggregate(PRODUCT_REMOTE_ID)).thenReturn(null) + whenever(productRepository.getProductAggregate(PRODUCT_REMOTE_ID)).thenReturn(null) viewModel.start() - verify(productRepository, times(1)).fetchAndGetProduct(PRODUCT_REMOTE_ID) + verify(productRepository, times(1)).fetchAndGetProductAggregate(PRODUCT_REMOTE_ID) Assertions.assertThat(viewModel.getProduct().productDraft).isNull() Assertions.assertThat(viewModel.getProduct().auxiliaryState).isEqualTo( @@ -343,15 +344,15 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `given nothing returned from repo with INVALID_PRODUCT_ID error, when view model started, the error status emitted with invalid id text`() = testBlocking { - whenever(productRepository.fetchAndGetProduct(PRODUCT_REMOTE_ID)).thenReturn(null) - whenever(productRepository.getProductAsync(PRODUCT_REMOTE_ID)).thenReturn(null) + whenever(productRepository.fetchAndGetProductAggregate(PRODUCT_REMOTE_ID)).thenReturn(null) + whenever(productRepository.getProductAggregate(PRODUCT_REMOTE_ID)).thenReturn(null) whenever(productRepository.lastFetchProductErrorType).thenReturn( WCProductStore.ProductErrorType.INVALID_PRODUCT_ID ) viewModel.start() - verify(productRepository, times(1)).fetchAndGetProduct(PRODUCT_REMOTE_ID) + verify(productRepository, times(1)).fetchAndGetProductAggregate(PRODUCT_REMOTE_ID) Assertions.assertThat(viewModel.getProduct().productDraft).isNull() Assertions.assertThat(viewModel.getProduct().auxiliaryState).isEqualTo( @@ -363,7 +364,7 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `Do not fetch product from api when not connected`() = testBlocking { - doReturn(offlineProduct).whenever(productRepository).getProductAsync(any()) + doReturn(ProductAggregate(offlineProduct)).whenever(productRepository).getProductAggregate(any()) doReturn(false).whenever(networkStatus).isConnected() var snackbar: MultiLiveEvent.Event.ShowSnackbar? = null @@ -373,16 +374,16 @@ class ProductDetailViewModelTest : BaseUnitTest() { viewModel.start() - verify(productRepository, times(1)).getProductAsync(PRODUCT_REMOTE_ID) - verify(productRepository, times(0)).fetchAndGetProduct(any()) + verify(productRepository, times(1)).getProductAggregate(PRODUCT_REMOTE_ID) + verify(productRepository, times(0)).fetchAndGetProductAggregate(any()) Assertions.assertThat(snackbar).isEqualTo(MultiLiveEvent.Event.ShowSnackbar(R.string.offline_error)) } @Test fun `Shows and hides product detail skeleton correctly`() = testBlocking { - doReturn(null).whenever(productRepository).getProductAsync(any()) - doReturn(product).whenever(productRepository).fetchAndGetProduct(any()) + doReturn(null).whenever(productRepository).getProductAggregate(any()) + doReturn(productAggregate).whenever(productRepository).fetchAndGetProductAggregate(any()) val auxiliaryStates = ArrayList() viewModel.productDetailViewStateData.observeForever { old, new -> @@ -402,8 +403,8 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `Displays the updated product detail view correctly`() = testBlocking { - doReturn(product).whenever(productRepository).getProductAsync(any()) - doReturn(product).whenever(productRepository).fetchAndGetProduct(any()) + doReturn(productAggregate).whenever(productRepository).getProductAggregate(any()) + doReturn(productAggregate).whenever(productRepository).fetchAndGetProductAggregate(any()) var productData: ProductDetailViewModel.ProductDetailViewState? = null viewModel.productDetailViewStateData.observeForever { _, new -> productData = new } @@ -420,8 +421,8 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `When update product price is null, product detail view displayed correctly`() = testBlocking { - doReturn(product).whenever(productRepository).getProductAsync(any()) - doReturn(product).whenever(productRepository).fetchAndGetProduct(any()) + doReturn(productAggregate).whenever(productRepository).getProductAggregate(any()) + doReturn(productAggregate).whenever(productRepository).fetchAndGetProductAggregate(any()) var productData: ProductDetailViewModel.ProductDetailViewState? = null viewModel.productDetailViewStateData.observeForever { _, new -> productData = new } @@ -443,8 +444,8 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `When update product price is zero, product detail view displayed correctly`() = testBlocking { - doReturn(product).whenever(productRepository).getProductAsync(any()) - doReturn(product).whenever(productRepository).fetchAndGetProduct(any()) + doReturn(productAggregate).whenever(productRepository).getProductAggregate(any()) + doReturn(productAggregate).whenever(productRepository).fetchAndGetProductAggregate(any()) var productData: ProductDetailViewModel.ProductDetailViewState? = null viewModel.productDetailViewStateData.observeForever { _, new -> productData = new } @@ -466,7 +467,7 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `Displays update menu action if product is edited`() = testBlocking { - doReturn(product).whenever(productRepository).getProductAsync(any()) + doReturn(productAggregate).whenever(productRepository).getProductAggregate(any()) viewModel.productDetailViewStateData.observeForever { _, _ -> } viewModel.productDetailViewStateData.observeForever { _, _ -> } @@ -485,8 +486,8 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `Displays progress dialog when product is edited`() = testBlocking { - doReturn(product).whenever(productRepository).getProductAsync(any()) - doReturn(Pair(false, null)).whenever(productRepository).updateProduct(any()) + doReturn(productAggregate).whenever(productRepository).getProductAggregate(any()) + doReturn(Pair(false, null)).whenever(productRepository).updateProduct(any()) val isProgressDialogShown = ArrayList() viewModel.productDetailViewStateData.observeForever { old, new -> @@ -504,7 +505,7 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `Do not update product when not connected`() = testBlocking { - doReturn(product).whenever(productRepository).getProductAsync(any()) + doReturn(productAggregate).whenever(productRepository).getProductAggregate(any()) doReturn(false).whenever(networkStatus).isConnected() var snackbar: MultiLiveEvent.Event.ShowSnackbar? = null @@ -519,15 +520,16 @@ class ProductDetailViewModelTest : BaseUnitTest() { viewModel.onSaveButtonClicked() - verify(productRepository, times(0)).updateProduct(any()) + verify(productRepository, times(0)).updateProduct(any()) Assertions.assertThat(snackbar).isEqualTo(MultiLiveEvent.Event.ShowSnackbar(R.string.offline_error)) Assertions.assertThat(productData?.isProgressDialogShown).isFalse() } @Test fun `Display error message on generic update product error`() = testBlocking { - doReturn(product).whenever(productRepository).getProductAsync(any()) - doReturn(Pair(false, WCProductStore.ProductError())).whenever(productRepository).updateProduct(any()) + doReturn(productAggregate).whenever(productRepository).getProductAggregate(any()) + doReturn(Pair(false, WCProductStore.ProductError())).whenever(productRepository) + .updateProduct(any()) var snackbar: MultiLiveEvent.Event.ShowSnackbar? = null viewModel.event.observeForever { @@ -541,7 +543,7 @@ class ProductDetailViewModelTest : BaseUnitTest() { viewModel.onSaveButtonClicked() - verify(productRepository, times(1)).updateProduct(any()) + verify(productRepository, times(1)).updateProduct(any()) Assertions.assertThat(snackbar) .isEqualTo(MultiLiveEvent.Event.ShowSnackbar(R.string.product_detail_update_product_error)) Assertions.assertThat(productData?.isProgressDialogShown).isFalse() @@ -550,7 +552,7 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `Display error message on min-max quantities update product error`() = testBlocking { val displayErrorMessage = "This is an error message" - doReturn(product).whenever(productRepository).getProductAsync(any()) + doReturn(productAggregate).whenever(productRepository).getProductAggregate(any()) doReturn( Pair( false, @@ -560,7 +562,7 @@ class ProductDetailViewModelTest : BaseUnitTest() { ) ) ) - .whenever(productRepository).updateProduct(any()) + .whenever(productRepository).updateProduct(any()) var showUpdateProductError: ProductDetailViewModel.ShowUpdateProductError? = null viewModel.event.observeForever { @@ -574,7 +576,7 @@ class ProductDetailViewModelTest : BaseUnitTest() { viewModel.onSaveButtonClicked() - verify(productRepository, times(1)).updateProduct(any()) + verify(productRepository, times(1)).updateProduct(any()) Assertions.assertThat(showUpdateProductError) .isEqualTo(ProductDetailViewModel.ShowUpdateProductError(displayErrorMessage)) Assertions.assertThat(productData?.isProgressDialogShown).isFalse() @@ -582,8 +584,8 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `Display success message on update product success`() = testBlocking { - doReturn(product).whenever(productRepository).getProductAsync(any()) - doReturn(Pair(true, null)).whenever(productRepository).updateProduct(any()) + doReturn(productAggregate).whenever(productRepository).getProductAggregate(any()) + doReturn(Pair(true, null)).whenever(productRepository).updateProduct(any()) var successSnackbarShown = false viewModel.event.observeForever { @@ -602,20 +604,20 @@ class ProductDetailViewModelTest : BaseUnitTest() { viewModel.onSaveButtonClicked() - verify(productRepository, times(1)).updateProduct(any()) - verify(productRepository, times(2)).getProductAsync(PRODUCT_REMOTE_ID) + verify(productRepository, times(1)).updateProduct(any()) + verify(productRepository, times(2)).getProductAggregate(PRODUCT_REMOTE_ID) Assertions.assertThat(successSnackbarShown).isTrue() Assertions.assertThat(productData?.isProgressDialogShown).isFalse Assertions.assertThat(hasChanges).isFalse() - Assertions.assertThat(productData?.productDraft).isEqualTo(product) + Assertions.assertThat(productData?.productAggregateDraft).isEqualTo(productAggregate) } @Test fun `Correctly sorts the Product Categories By their Parent Ids and by name`() { testBlocking { val sortedByNameAndParent = viewModel.sortAndStyleProductCategories( - product, + productAggregate.product, productCategories ).toList() Assertions.assertThat(sortedByNameAndParent[0].category).isEqualTo(productCategories[0]) @@ -680,7 +682,7 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `Should update view state with not null sale end date when sale is scheduled`() = testBlocking { viewModel.productDetailViewStateData.observeForever { _, _ -> } - doReturn(product).whenever(productRepository).getProductAsync(any()) + doReturn(productAggregate).whenever(productRepository).getProductAggregate(any()) viewModel.start() viewModel.updateProductDraft(saleEndDate = SALE_END_DATE, isSaleScheduled = true) @@ -691,20 +693,22 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `Should update with stored product sale end date when sale is not scheduled`() = testBlocking { viewModel.productDetailViewStateData.observeForever { _, _ -> } - doReturn(product).whenever(productRepository).getProductAsync(any()) + doReturn(productAggregate).whenever(productRepository).getProductAggregate(any()) viewModel.start() viewModel.updateProductDraft(saleEndDate = SALE_END_DATE, isSaleScheduled = false) - Assertions.assertThat(productsDraft?.saleEndDateGmt).isEqualTo(product.saleEndDateGmt) + Assertions.assertThat(productsDraft?.saleEndDateGmt).isEqualTo(productAggregate.product.saleEndDateGmt) } @Test fun `Should update sale end date when sale schedule is unknown but stored product sale is scheduled`() = testBlocking { viewModel.productDetailViewStateData.observeForever { _, _ -> } - val storedProduct = product.copy(isSaleScheduled = true) - doReturn(storedProduct).whenever(productRepository).getProductAsync(any()) + val storedProductAggregate = productAggregate.copy( + product = productAggregate.product.copy(isSaleScheduled = true) + ) + doReturn(storedProductAggregate).whenever(productRepository).getProductAggregate(any()) viewModel.start() viewModel.updateProductDraft(saleEndDate = SALE_END_DATE, isSaleScheduled = null) @@ -715,11 +719,13 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `Should update with null sale end date and stored product has scheduled sale`() = testBlocking { viewModel.productDetailViewStateData.observeForever { _, _ -> } - val storedProduct = product.copy( - saleEndDateGmt = SALE_END_DATE, - isSaleScheduled = true + val storedProductAggregate = productAggregate.copy( + product = productAggregate.product.copy( + saleEndDateGmt = SALE_END_DATE, + isSaleScheduled = true + ) ) - doReturn(storedProduct).whenever(productRepository).getProductAsync(any()) + doReturn(storedProductAggregate).whenever(productRepository).getProductAggregate(any()) viewModel.start() viewModel.updateProductDraft(saleEndDate = null) @@ -730,12 +736,14 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `Re-ordering attribute terms is saved correctly`() = testBlocking { viewModel.productDetailViewStateData.observeForever { _, _ -> } - val storedProduct = product.copy( - attributes = ProductTestUtils.generateProductAttributeList() + val storedProductAggregate = productAggregate.copy( + product = productAggregate.product.copy( + attributes = ProductTestUtils.generateProductAttributeList() + ) ) - doReturn(storedProduct).whenever(productRepository).getProductAsync(any()) + doReturn(storedProductAggregate).whenever(productRepository).getProductAggregate(any()) - val attribute = storedProduct.attributes[0] + val attribute = storedProductAggregate.product.attributes[0] val firstTerm = attribute.terms[0] val secondTerm = attribute.terms[1] @@ -772,10 +780,10 @@ class ProductDetailViewModelTest : BaseUnitTest() { ) ) - val storedProduct = product.copy( - attributes = attributes + val storedProductAggregate = productAggregate.copy( + product = productAggregate.product.copy(attributes = attributes) ) - doReturn(storedProduct).whenever(productRepository).getProductAsync(any()) + doReturn(storedProductAggregate).whenever(productRepository).getProductAggregate(any()) viewModel.start() viewModel.renameAttributeInDraft(1, attributeName, newName) @@ -787,7 +795,7 @@ class ProductDetailViewModelTest : BaseUnitTest() { /** * Protection for a race condition bug in Variations. * - * We're requiring [ProductDetailRepository.fetchAndGetProduct] to be called right after + * We're requiring [ProductDetailRepository.fetchAndGetProductAggregate] to be called right after * [VariationRepository.createEmptyVariation] to fix a race condition problem in the Product Details page. The * bug can be reproduced inconsistently by following these steps: * @@ -811,7 +819,7 @@ class ProductDetailViewModelTest : BaseUnitTest() { fun `When generating a variation, the latest Product should be fetched from the site`() = testBlocking { // Given - doReturn(product).whenever(productRepository).getProductAsync(any()) + doReturn(productAggregate).whenever(productRepository).getProductAggregate(any()) var productData: ProductDetailViewModel.ProductDetailViewState? = null viewModel.productDetailViewStateData.observeForever { _, new -> productData = new } @@ -824,16 +832,16 @@ class ProductDetailViewModelTest : BaseUnitTest() { Assertions.assertThat(productData?.productDraft?.numVariations).isZero() doReturn(mock()).whenever(variationRepository).createEmptyVariation(any()) - doReturn(product.copy(numVariations = 1_914)).whenever(productRepository) - .fetchAndGetProduct(eq(product.remoteId)) + doReturn(productAggregate.copy(product = productAggregate.product.copy(numVariations = 1_914))) + .whenever(productRepository).fetchAndGetProductAggregate(eq(productAggregate.product.remoteId)) // When viewModel.onGenerateVariationClicked() // Then - verify(variationRepository, times(1)).createEmptyVariation(eq(product)) + verify(variationRepository, times(1)).createEmptyVariation(eq(productAggregate.product)) // Prove that we fetched from the API. - verify(productRepository, times(1)).fetchAndGetProduct(eq(product.remoteId)) + verify(productRepository, times(1)).fetchAndGetProductAggregate(eq(productAggregate.remoteId)) // The VM state should have been updated with the _fetched_ product's numVariations Assertions.assertThat(productData?.productDraft?.numVariations).isEqualTo(1_914) @@ -843,8 +851,8 @@ class ProductDetailViewModelTest : BaseUnitTest() { fun `when there image upload errors, then show a snackbar`() = testBlocking { val errorEvents = MutableSharedFlow>() doReturn(errorEvents).whenever(mediaFileUploadHandler).observeCurrentUploadErrors(PRODUCT_REMOTE_ID) - doReturn(product).whenever(productRepository).fetchAndGetProduct(any()) - doReturn(product).whenever(productRepository).getProductAsync(any()) + doReturn(productAggregate).whenever(productRepository).fetchAndGetProductAggregate(any()) + doReturn(productAggregate).whenever(productRepository).getProductAggregate(any()) val errorMessage = "message" doReturn(errorMessage).whenever(resources).getString(any(), anyVararg()) @@ -871,8 +879,8 @@ class ProductDetailViewModelTest : BaseUnitTest() { fun `when image uploads gets cleared, then auto-dismiss the snackbar`() = testBlocking { val errorEvents = MutableSharedFlow>() doReturn(errorEvents).whenever(mediaFileUploadHandler).observeCurrentUploadErrors(PRODUCT_REMOTE_ID) - doReturn(product).whenever(productRepository).fetchAndGetProduct(any()) - doReturn(product).whenever(productRepository).getProductAsync(any()) + doReturn(productAggregate).whenever(productRepository).fetchAndGetProductAggregate(any()) + doReturn(productAggregate).whenever(productRepository).getProductAggregate(any()) viewModel.start() errorEvents.emit(emptyList()) @@ -882,7 +890,7 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `Publish option not shown when product is published except addProduct flow`() = testBlocking { - doReturn(product).whenever(productRepository).getProductAsync(any()) + doReturn(productAggregate).whenever(productRepository).getProductAggregate(any()) viewModel.productDetailViewStateData.observeForever { _, _ -> } var menuButtonsState: ProductDetailViewModel.MenuButtonsState? = null viewModel.menuButtonsState.observeForever { menuButtonsState = it } @@ -894,7 +902,7 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `Publish option not shown when product is published privately except addProduct flow`() = testBlocking { - doReturn(product).whenever(productRepository).getProductAsync(any()) + doReturn(productAggregate).whenever(productRepository).getProductAggregate(any()) viewModel.productDetailViewStateData.observeForever { _, _ -> } var menuButtonsState: ProductDetailViewModel.MenuButtonsState? = null viewModel.menuButtonsState.observeForever { menuButtonsState = it } @@ -906,7 +914,7 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `Publish option shown when product is Draft`() = testBlocking { - doReturn(product).whenever(productRepository).getProductAsync(any()) + doReturn(productAggregate).whenever(productRepository).getProductAggregate(any()) viewModel.productDetailViewStateData.observeForever { _, _ -> } var menuButtonsState: ProductDetailViewModel.MenuButtonsState? = null @@ -919,7 +927,7 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `Publish option shown when product is Pending Review`() = testBlocking { - doReturn(product).whenever(productRepository).getProductAsync(any()) + doReturn(productAggregate).whenever(productRepository).getProductAggregate(any()) viewModel.productDetailViewStateData.observeForever { _, _ -> } var menuButtonsState: ProductDetailViewModel.MenuButtonsState? = null @@ -933,7 +941,7 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `Save option shown when product has changes except add product flow irrespective of product statuses`() = testBlocking { - doReturn(product).whenever(productRepository).getProductAsync(any()) + doReturn(productAggregate).whenever(productRepository).getProductAggregate(any()) viewModel.productDetailViewStateData.observeForever { _, _ -> } var menuButtonsState: ProductDetailViewModel.MenuButtonsState? = null @@ -941,7 +949,7 @@ class ProductDetailViewModelTest : BaseUnitTest() { viewModel.start() // Trigger changes - viewModel.updateProductDraft(title = product.name + "2") + viewModel.updateProductDraft(title = productAggregate.product.name + "2") Assertions.assertThat(menuButtonsState?.saveOption).isTrue() } @@ -949,7 +957,11 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `when restoring saved state, then re-fetch stored product to correctly calculate hasChanges`() = testBlocking { // Make sure draft product has different data than draft product - doReturn(product.copy(name = product.name + "test")).whenever(productRepository).getProductAsync(any()) + doReturn( + productAggregate.copy( + product = productAggregate.product.copy(name = productAggregate.product.name + "test") + ) + ).whenever(productRepository).getProductAggregate(any()) savedState.set(ProductDetailViewModel.ProductDetailViewState::class.java.name, productWithParameters) viewModel.productDetailViewStateData.observeForever { _, _ -> } @@ -964,10 +976,8 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `given regular price set, when updating inventory, then price remains unchanged`() = testBlocking { doReturn( - product.copy( - regularPrice = BigDecimal(99) - ) - ).whenever(productRepository).getProductAsync(any()) + productAggregate.copy(product = productAggregate.product.copy(regularPrice = BigDecimal(99))) + ).whenever(productRepository).getProductAggregate(any()) viewModel.productDetailViewStateData.observeForever { _, _ -> } viewModel.start() @@ -979,10 +989,8 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `given sale price set, when updating attributes, then price remains unchanged`() = testBlocking { doReturn( - product.copy( - salePrice = BigDecimal(99) - ) - ).whenever(productRepository).getProductAsync(any()) + productAggregate.copy(product = productAggregate.product.copy(salePrice = BigDecimal(99))) + ).whenever(productRepository).getProductAggregate(any()) viewModel.productDetailViewStateData.observeForever { _, _ -> } viewModel.start() @@ -994,10 +1002,8 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `given regular price greater than 0, when setting price to 0, then price is set to zero`() = testBlocking { doReturn( - product.copy( - regularPrice = BigDecimal(99) - ) - ).whenever(productRepository).getProductAsync(any()) + productAggregate.copy(product = productAggregate.product.copy(regularPrice = BigDecimal(99))) + ).whenever(productRepository).getProductAggregate(any()) viewModel.productDetailViewStateData.observeForever { _, _ -> } viewModel.start() @@ -1009,10 +1015,8 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `given sale price greater than 0, when setting price to 0, then price is set to zero`() = testBlocking { doReturn( - product.copy( - regularPrice = BigDecimal(99) - ) - ).whenever(productRepository).getProductAsync(any()) + productAggregate.copy(product = productAggregate.product.copy(regularPrice = BigDecimal(99))) + ).whenever(productRepository).getProductAggregate(any()) viewModel.productDetailViewStateData.observeForever { _, _ -> } viewModel.start() @@ -1024,10 +1028,8 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `given regular price greater than 0, when setting price to null, then price is set to null`() = testBlocking { doReturn( - product.copy( - regularPrice = BigDecimal(99) - ) - ).whenever(productRepository).getProductAsync(any()) + productAggregate.copy(product = productAggregate.product.copy(regularPrice = BigDecimal(99))) + ).whenever(productRepository).getProductAggregate(any()) viewModel.productDetailViewStateData.observeForever { _, _ -> } viewModel.start() @@ -1039,10 +1041,8 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `given sale price greater than 0, when setting price to null, then price is set to null`() = testBlocking { doReturn( - product.copy( - regularPrice = BigDecimal(99) - ) - ).whenever(productRepository).getProductAsync(any()) + productAggregate.copy(product = productAggregate.product.copy(regularPrice = BigDecimal(99))) + ).whenever(productRepository).getProductAggregate(any()) viewModel.productDetailViewStateData.observeForever { _, _ -> } viewModel.start() @@ -1059,7 +1059,7 @@ class ProductDetailViewModelTest : BaseUnitTest() { images = uris ).toSavedStateHandle() - doReturn(product).whenever(productRepository).getProductAsync(any()) + doReturn(productAggregate).whenever(productRepository).getProductAggregate(any()) mediaFileUploadHandler = mock { on { it.observeCurrentUploadErrors(any()) } doReturn emptyFlow() @@ -1095,66 +1095,70 @@ class ProductDetailViewModelTest : BaseUnitTest() { } @Test - fun `given tablet, when loaded remote products, then PRODUCT_DETAIL_LOADED tracked with regular horizontal class`() = testBlocking { - // GIVEN - whenever(isWindowClassLargeThanCompact()).thenReturn(true) + fun `given tablet, when loaded remote products, then PRODUCT_DETAIL_LOADED tracked with regular horizontal class`() = + testBlocking { + // GIVEN + whenever(isWindowClassLargeThanCompact()).thenReturn(true) - // WHEN - setup() + // WHEN + setup() - // THEN - verify(tracker).track( - eq(AnalyticsEvent.PRODUCT_DETAIL_LOADED), - eq(mapOf("horizontal_size_class" to "regular")) - ) - } + // THEN + verify(tracker).track( + eq(AnalyticsEvent.PRODUCT_DETAIL_LOADED), + eq(mapOf("horizontal_size_class" to "regular")) + ) + } @Test - fun `given not tablet, when loaded remote products, then PRODUCT_DETAIL_LOADED tracked with compact horizontal class`() = testBlocking { - // GIVEN - whenever(isWindowClassLargeThanCompact()).thenReturn(false) + fun `given not tablet, when loaded remote products, then PRODUCT_DETAIL_LOADED tracked with compact horizontal class`() = + testBlocking { + // GIVEN + whenever(isWindowClassLargeThanCompact()).thenReturn(false) - // WHEN - setup() + // WHEN + setup() - // THEN - verify(tracker, times(2)).track( - eq(AnalyticsEvent.PRODUCT_DETAIL_LOADED), - eq(mapOf("horizontal_size_class" to "compact")) - ) - } + // THEN + verify(tracker, times(2)).track( + eq(AnalyticsEvent.PRODUCT_DETAIL_LOADED), + eq(mapOf("horizontal_size_class" to "compact")) + ) + } @Test - fun `given product updated successfuly, when onPublishButtonClicked, then ProductUpdated event emitted`() = testBlocking { - // GIVEN - whenever(productRepository.getProductAsync(any())).thenReturn(product) - whenever(productRepository.updateProduct(any())).thenReturn(Pair(true, null)) - viewModel.start() + fun `given product updated successfuly, when onPublishButtonClicked, then ProductUpdated event emitted`() = + testBlocking { + // GIVEN + whenever(productRepository.getProductAggregate(any())).thenReturn(productAggregate) + whenever(productRepository.updateProduct(any())).thenReturn(Pair(true, null)) + viewModel.start() - // WHEN - viewModel.onPublishButtonClicked() + // WHEN + viewModel.onPublishButtonClicked() - // THEN - Assertions.assertThat(viewModel.event.value).isEqualTo(ProductDetailViewModel.ProductUpdated) - } + // THEN + Assertions.assertThat(viewModel.event.value).isEqualTo(ProductDetailViewModel.ProductUpdated) + } @Test - fun `given selected site is private, when product detail is opened, then images are not available`() = testBlocking { - // GIVEN - whenever(selectedSite.get()).thenReturn(SiteModel().apply { setIsPrivate(true) }) - savedState = ProductDetailFragmentArgs(ProductDetailFragment.Mode.ShowProduct(PRODUCT_REMOTE_ID)) - .toSavedStateHandle() + fun `given selected site is private, when product detail is opened, then images are not available`() = + testBlocking { + // GIVEN + whenever(selectedSite.get()).thenReturn(SiteModel().apply { setIsPrivate(true) }) + savedState = ProductDetailFragmentArgs(ProductDetailFragment.Mode.ShowProduct(PRODUCT_REMOTE_ID)) + .toSavedStateHandle() - setup() - viewModel.start() + setup() + viewModel.start() - // WHEN - var productData: ProductDetailViewModel.ProductDetailViewState? = null - viewModel.productDetailViewStateData.observeForever { _, new -> productData = new } + // WHEN + var productData: ProductDetailViewModel.ProductDetailViewState? = null + viewModel.productDetailViewStateData.observeForever { _, new -> productData = new } - // THEN - Assertions.assertThat(productData?.areImagesAvailable).isFalse() - } + // THEN + Assertions.assertThat(productData?.areImagesAvailable).isFalse() + } @Test fun `given selected site is public, when product detail is opened, then images are available`() = testBlocking { @@ -1170,70 +1174,75 @@ class ProductDetailViewModelTest : BaseUnitTest() { } @Test - fun `given product password API uses CORE, when product details are fetched, then use password from the model`() = testBlocking { - // GIVEN - val password = "password" - whenever(determineProductPasswordApi.invoke()).thenReturn(ProductPasswordApi.CORE) - whenever(productRepository.getProductAsync(any())).thenReturn(product.copy(password = password)) + fun `given product password API uses CORE, when product details are fetched, then use password from the model`() = + testBlocking { + // GIVEN + val password = "password" + whenever(determineProductPasswordApi.invoke()).thenReturn(ProductPasswordApi.CORE) + whenever(productRepository.getProductAggregate(any())) + .thenReturn(productAggregate.copy(product = productAggregate.product.copy(password = password))) - // WHEN - viewModel.start() - val viewState = viewModel.productDetailViewStateData.liveData.getOrAwaitValue() + // WHEN + viewModel.start() + val viewState = viewModel.productDetailViewStateData.liveData.getOrAwaitValue() - // THEN - Assertions.assertThat(viewState.draftPassword).isEqualTo(password) - verify(productRepository, never()).fetchProductPassword(any()) - } + // THEN + Assertions.assertThat(viewState.draftPassword).isEqualTo(password) + verify(productRepository, never()).fetchProductPassword(any()) + } @Test - fun `given product password API uses WPCOM, when product details are fetched, then fetch password from the API`() = testBlocking { - // GIVEN - val password = "password" - whenever(determineProductPasswordApi.invoke()).thenReturn(ProductPasswordApi.WPCOM) - whenever(productRepository.getProductAsync(any())).thenReturn(product) - whenever(productRepository.fetchProductPassword(any())).thenReturn(password) + fun `given product password API uses WPCOM, when product details are fetched, then fetch password from the API`() = + testBlocking { + // GIVEN + val password = "password" + whenever(determineProductPasswordApi.invoke()).thenReturn(ProductPasswordApi.WPCOM) + whenever(productRepository.getProductAggregate(any())).thenReturn(productAggregate) + whenever(productRepository.fetchProductPassword(any())).thenReturn(password) - // WHEN - viewModel.start() - val viewState = viewModel.productDetailViewStateData.liveData.getOrAwaitValue() + // WHEN + viewModel.start() + val viewState = viewModel.productDetailViewStateData.liveData.getOrAwaitValue() - // THEN - Assertions.assertThat(viewState.draftPassword).isEqualTo(password) - verify(productRepository).fetchProductPassword(any()) - } + // THEN + Assertions.assertThat(viewState.draftPassword).isEqualTo(password) + verify(productRepository).fetchProductPassword(any()) + } @Test - fun `given product password API uses WPCOM, when product is saved, then update password using WPCOM API`() = testBlocking { - // GIVEN - val password = "password" - whenever(determineProductPasswordApi.invoke()).thenReturn(ProductPasswordApi.WPCOM) - whenever(productRepository.getProductAsync(any())).thenReturn(product) - whenever(productRepository.fetchProductPassword(any())).thenReturn(password) - whenever(productRepository.updateProduct(any())).thenReturn(Pair(true, null)) - - // WHEN - viewModel.start() - viewModel.updateProductVisibility(ProductVisibility.PASSWORD_PROTECTED, "newPassword") - viewModel.onSaveButtonClicked() + fun `given product password API uses WPCOM, when product is saved, then update password using WPCOM API`() = + testBlocking { + // GIVEN + val password = "password" + whenever(determineProductPasswordApi.invoke()).thenReturn(ProductPasswordApi.WPCOM) + whenever(productRepository.getProductAggregate(any())).thenReturn(productAggregate) + whenever(productRepository.fetchProductPassword(any())).thenReturn(password) + whenever(productRepository.updateProduct(any())).thenReturn(Pair(true, null)) + + // WHEN + viewModel.start() + viewModel.updateProductVisibility(ProductVisibility.PASSWORD_PROTECTED, "newPassword") + viewModel.onSaveButtonClicked() - // THEN - verify(productRepository).updateProductPassword(eq(product.remoteId), eq("newPassword")) - } + // THEN + verify(productRepository).updateProductPassword(eq(productAggregate.remoteId), eq("newPassword")) + } @Test - fun `given product password API is not supported, when product details are fetched, then password is empty`() = testBlocking { - // GIVEN - whenever(determineProductPasswordApi.invoke()).thenReturn(ProductPasswordApi.UNSUPPORTED) - whenever(productRepository.getProductAsync(any())).thenReturn(product) + fun `given product password API is not supported, when product details are fetched, then password is empty`() = + testBlocking { + // GIVEN + whenever(determineProductPasswordApi.invoke()).thenReturn(ProductPasswordApi.UNSUPPORTED) + whenever(productRepository.getProductAggregate(any())).thenReturn(productAggregate) - // WHEN - viewModel.start() - val viewState = viewModel.productDetailViewStateData.liveData.getOrAwaitValue() + // WHEN + viewModel.start() + val viewState = viewModel.productDetailViewStateData.liveData.getOrAwaitValue() - // THEN - Assertions.assertThat(viewState.draftPassword).isNull() - verify(productRepository, never()).fetchProductPassword(any()) - } + // THEN + Assertions.assertThat(viewState.draftPassword).isNull() + verify(productRepository, never()).fetchProductPassword(any()) + } private val productsDraft get() = viewModel.productDetailViewStateData.liveData.value?.productDraft diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/details/ProductDetailViewModel_AddFlowTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/details/ProductDetailViewModel_AddFlowTest.kt index 6658f9f3187..cec29d45126 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/details/ProductDetailViewModel_AddFlowTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/details/ProductDetailViewModel_AddFlowTest.kt @@ -7,6 +7,7 @@ import com.woocommerce.android.analytics.AnalyticsTrackerWrapper import com.woocommerce.android.media.MediaFilesRepository import com.woocommerce.android.media.ProductImagesServiceWrapper import com.woocommerce.android.model.Product +import com.woocommerce.android.model.ProductAggregate import com.woocommerce.android.tools.NetworkStatus import com.woocommerce.android.tools.SelectedSite import com.woocommerce.android.ui.blaze.IsBlazeEnabled @@ -222,8 +223,8 @@ class ProductDetailViewModel_AddFlowTest : BaseUnitTest() { @Test fun `Display success message on add product success`() = testBlocking { // given - doReturn(product).whenever(productRepository).getProductAsync(any()) - doReturn(Pair(true, 1L)).whenever(productRepository).addProduct(any()) + doReturn(ProductAggregate(product)).whenever(productRepository).getProductAggregate(any()) + doReturn(Pair(true, 1L)).whenever(productRepository).addProduct(any()) var successSnackbarShown = false viewModel.event.observeForever { @@ -244,7 +245,7 @@ class ProductDetailViewModel_AddFlowTest : BaseUnitTest() { viewModel.onPublishButtonClicked() // then - verify(productRepository, times(1)).getProductAsync(1L) + verify(productRepository, times(1)).getProductAggregate(1L) Assertions.assertThat(successSnackbarShown).isTrue() Assertions.assertThat(productData?.isProgressDialogShown).isFalse() @@ -255,7 +256,7 @@ class ProductDetailViewModel_AddFlowTest : BaseUnitTest() { @Test fun `Display error message on add product failed`() = testBlocking { // given - doReturn(Pair(false, 0L)).whenever(productRepository).addProduct(any()) + doReturn(Pair(false, 0L)).whenever(productRepository).addProduct(any()) var successSnackbarShown = false viewModel.event.observeForever { @@ -308,8 +309,8 @@ class ProductDetailViewModel_AddFlowTest : BaseUnitTest() { fun `Display correct message on updating a freshly added product`() = testBlocking { // given - doReturn(product).whenever(productRepository).getProductAsync(any()) - doReturn(Pair(true, 1L)).whenever(productRepository).addProduct(any()) + doReturn(ProductAggregate(product)).whenever(productRepository).getProductAggregate(any()) + doReturn(Pair(true, 1L)).whenever(productRepository).addProduct(any()) var successSnackbarShown = false viewModel.event.observeForever { @@ -330,7 +331,7 @@ class ProductDetailViewModel_AddFlowTest : BaseUnitTest() { viewModel.onPublishButtonClicked() // then - verify(productRepository, times(1)).getProductAsync(1L) + verify(productRepository, times(1)).getProductAggregate(1L) Assertions.assertThat(successSnackbarShown).isTrue() Assertions.assertThat(productData?.isProgressDialogShown).isFalse() @@ -338,10 +339,10 @@ class ProductDetailViewModel_AddFlowTest : BaseUnitTest() { Assertions.assertThat(productData?.productDraft).isEqualTo(product) // when - doReturn(Pair(true, null)).whenever(productRepository).updateProduct(any()) + doReturn(Pair(true, null)).whenever(productRepository).updateProduct(any()) viewModel.onPublishButtonClicked() - verify(productRepository, times(1)).updateProduct(any()) + verify(productRepository, times(1)).updateProduct(any()) viewModel.event.observeForever { if (it is ShowSnackbar && it.message == R.string.product_detail_save_product_success) { @@ -398,8 +399,8 @@ class ProductDetailViewModel_AddFlowTest : BaseUnitTest() { @Test fun `when a new product is saved, then assign the new id to ongoing image uploads`() = testBlocking { - doReturn(Pair(true, PRODUCT_REMOTE_ID)).whenever(productRepository).addProduct(any()) - doReturn(product).whenever(productRepository).getProductAsync(any()) + doReturn(Pair(true, PRODUCT_REMOTE_ID)).whenever(productRepository).addProduct(any()) + doReturn(product).whenever(productRepository).getProductAggregate(any()) savedState = ProductDetailFragmentArgs( mode = ProductDetailFragment.Mode.AddNewProduct ).toSavedStateHandle() @@ -453,8 +454,8 @@ class ProductDetailViewModel_AddFlowTest : BaseUnitTest() { @Test fun `given a product is under creation, when clicking on save product, then assign uploads to the new id`() = testBlocking { - doReturn(Pair(true, PRODUCT_REMOTE_ID)).whenever(productRepository).addProduct(any()) - doReturn(product).whenever(productRepository).getProductAsync(any()) + doReturn(Pair(true, PRODUCT_REMOTE_ID)).whenever(productRepository).addProduct(any()) + doReturn(product).whenever(productRepository).getProductAggregate(any()) viewModel.productDetailViewStateData.observeForever { _, _ -> } viewModel.start() @@ -493,7 +494,7 @@ class ProductDetailViewModel_AddFlowTest : BaseUnitTest() { @Test fun `given product status is draft, when save is clicked, then save product with correct status`() = testBlocking { - whenever(productRepository.addProduct(any())).thenAnswer { it.arguments.first() as Product } + whenever(productRepository.addProduct(any())).thenAnswer { it.arguments.first() as Product } var viewState: ProductDetailViewModel.ProductDetailViewState? = null viewModel.productDetailViewStateData.observeForever { _, new -> viewState = new } diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/inventory/ScanToUpdateInventoryViewModelTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/inventory/ScanToUpdateInventoryViewModelTest.kt index 2f6f0fa4445..e414ae85f6a 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/inventory/ScanToUpdateInventoryViewModelTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/inventory/ScanToUpdateInventoryViewModelTest.kt @@ -6,6 +6,7 @@ import com.woocommerce.android.R import com.woocommerce.android.analytics.AnalyticsEvent import com.woocommerce.android.analytics.AnalyticsTracker import com.woocommerce.android.analytics.AnalyticsTrackerWrapper +import com.woocommerce.android.model.Product import com.woocommerce.android.model.ProductVariation import com.woocommerce.android.model.UiString import com.woocommerce.android.ui.orders.creation.CodeScannerStatus @@ -214,7 +215,7 @@ class ScanToUpdateInventoryViewModelTest : BaseUnitTest() { GoogleBarcodeFormatMapper.BarcodeFormat.FormatEAN8 ) ) - whenever(productRepo.updateProduct(any())).thenReturn(Pair(true, null)) + whenever(productRepo.updateProduct(any())).thenReturn(Pair(true, null)) whenever( resourceProvider.getString( R.string.scan_to_update_inventory_success_snackbar, @@ -248,7 +249,7 @@ class ScanToUpdateInventoryViewModelTest : BaseUnitTest() { GoogleBarcodeFormatMapper.BarcodeFormat.FormatEAN8 ) ) - whenever(productRepo.updateProduct(any())).thenReturn(Pair(true, null)) + whenever(productRepo.updateProduct(any())).thenReturn(Pair(true, null)) whenever( resourceProvider.getString( R.string.scan_to_update_inventory_success_snackbar, @@ -280,7 +281,7 @@ class ScanToUpdateInventoryViewModelTest : BaseUnitTest() { whenever(fetchProductBySKU(any(), any())).thenReturn(Result.success(product)) whenever(productRepo.getProduct(productId)).thenReturn(product) - whenever(productRepo.updateProduct(any())).thenReturn(Pair(true, null)) + whenever(productRepo.updateProduct(any())).thenReturn(Pair(true, null)) whenever( resourceProvider.getString( eq(R.string.scan_to_update_inventory_success_snackbar), diff --git a/build.gradle b/build.gradle index c69d3cafc87..b5df01ef33e 100644 --- a/build.gradle +++ b/build.gradle @@ -3,15 +3,18 @@ import io.gitlab.arturbosch.detekt.Detekt import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { - id 'io.gitlab.arturbosch.detekt' - id 'com.automattic.android.measure-builds' - id "com.autonomousapps.dependency-analysis" - id 'com.android.application' apply false - id 'org.jetbrains.kotlin.android' apply false - id 'com.google.dagger.hilt.android' apply false - id 'androidx.navigation.safeargs.kotlin' apply false - id 'com.google.gms.google-services' apply false - id 'com.google.devtools.ksp' apply false + alias(libs.plugins.detekt) + alias(libs.plugins.automattic.measure.builds) + alias(libs.plugins.dependency.analysis) + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.android.test) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.parcelize) apply false + alias(libs.plugins.google.dagger.hilt) apply false + alias(libs.plugins.androidx.navigation.safeargs) apply false + alias(libs.plugins.google.services) apply false + alias(libs.plugins.ksp) apply false } measureBuilds { @@ -22,7 +25,7 @@ measureBuilds { } allprojects { - apply plugin: 'io.gitlab.arturbosch.detekt' + apply plugin: libs.plugins.detekt.get().pluginId repositories { google() @@ -81,7 +84,7 @@ detektAll.configure { } dependencies { - detektPlugins "io.gitlab.arturbosch.detekt:detekt-formatting:$gradle.ext.detektVersion" + detektPlugins(libs.detekt.formatting) } task clean(type: Delete) { @@ -99,63 +102,6 @@ tasks.register("installGitHooks", Copy) { fileMode 0777 } -ext { - fluxCVersion = '3107-f51c7f6c7ce02a207df0b45e074941225850b6ae' - glideVersion = '4.16.0' - coilVersion = '2.1.0' - constraintLayoutVersion = '1.2.0' - libaddressinputVersion = '0.0.2' - eventBusVersion = '3.3.1' - googlePlayCoreVersion = '1.10.3' - googlePlayWearableVersion = '18.1.0' - coroutinesVersion = '1.8.1' - lifecycleVersion = '2.7.0' - aztecVersion = 'v2.1.4' - flipperVersion = '0.176.1' - stateMachineVersion = '0.2.0' - coreKtxVersion = '1.13.1' - appCompatVersion = '1.4.2' - materialVersion = '1.12.0' - transitionVersion = '1.5.1' - hiltJetpackVersion = '1.1.0' - wordPressUtilsVersion = '3.5.0' - mediapickerVersion = '0.3.1' - wordPressLoginVersion = '154-7c872152838eadf9f3c2d10073bf5e9cf1489bf0' - aboutAutomatticVersion = '0.0.6' - automatticTracksVersion = '5.0.0' - workManagerVersion = '2.7.1' - billingVersion = '5.0.0' - stripeTerminalVersion = '3.7.1' - mlkitBarcodeScanningVersion = '17.2.0' - mlkitTextRecognitionVersion = '16.0.0' - androidxCameraVersion = '1.2.3' - guavaVersion = '33.1.0-android' - protobufVersion = '3.25.3' - gravatarVersion = '0.2.0' - wearHorologistVersion = '0.6.10' - securityLintVersion = '1.0.1' - - // Apache - commonsText = '1.10.0' - commonsIO = '2.11.0' - httpClientAndroidVersion = '4.3.5.1' - - // Compose and its module versions need to be consistent with each other (for example 'compose-theme-adapter') - composeBOMVersion = "2024.04.00" - composeCompilerVersion = "1.5.9" - composeAccompanistVersion = "0.32.0" - wearComposeVersion = "1.3.1" - - // Testing - jUnitVersion = '4.13.2' - jUnitExtVersion = '1.1.5' - androidxTestCoreVersion = '1.4.0' - assertjVersion = '3.24.1' - espressoVersion = '3.4.0' - mockitoKotlinVersion = '4.0.0' - mockitoVersion = '4.6.1' -} - // Onboarding and dev env setup tasks task checkBundler(type: Exec) { doFirst { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 00000000000..7b3e841e095 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,250 @@ +[versions] +agp = '8.5.1' +android-billingclient = '5.0.0' +android-desugar = '2.0.4' +android-security-lint = '1.0.1' +androidx-activity = '1.8.0' +androidx-appcompat = '1.4.2' +androidx-arch-core = '2.1.0' +androidx-browser = '1.5.0' +androidx-camera = '1.2.3' +androidx-cardview = '1.0.0' +androidx-compose-bom = '2024.04.00' +androidx-compose-compiler = '1.5.9' +androidx-constraintlayout-compose = '1.0.1' +androidx-constraintlayout-main = '2.1.4' +androidx-core-main = '1.13.1' +androidx-core-splashscreen = '1.0.1' +androidx-datastore = '1.1.0' +androidx-fragment = '1.8.2' +androidx-hilt = '1.2.0' +androidx-lifecycle = '2.7.0' +androidx-navigation = '2.7.7' +androidx-preference = '1.2.1' +androidx-recyclerview-main = '1.3.2' +androidx-recyclerview-selection = '1.1.0' +androidx-test-espresso = '3.4.0' +androidx-test-ext = '1.1.5' +androidx-test-main = '1.4.0' +androidx-test-uiautomator = '2.2.0' +androidx-transition = '1.5.1' +androidx-wear-compose = '1.3.1' +androidx-wear-tiles = '1.3.0' +androidx-wear-tooling = '1.0.0' +androidx-wear-watchface = '1.2.1' +androidx-work = '2.7.1' +apache-commons-text = '1.10.0' +apache-http-client-android = '4.3.5.1' +automattic-about = '0.0.6' +automattic-measure-builds = '2.1.2' +automattic-tracks = '5.0.0' +assertj = '3.24.1' +bumptech-glide = '4.16.0' +cashapp-turbine = '1.0.0' +coil = '2.1.0' +commons-fileupload = '1.5' +commons-io = '2.11.0' +dependency-analysis = '1.28.0' +detekt = '1.23.5' +facebook-flipper = '0.176.1' +facebook-shimmer = '0.5.0' +facebook-soloader = '0.10.4' +fastlane-screengrab = '2.1.1' +fladle = '0.17.5' +google-dagger = "2.50" +google-firebase-bom = '32.7.1' +google-gson = '2.10.1' +google-guava = '33.1.0-android' +google-horologist = '0.6.10' +google-material = '1.12.0' +google-mlkit-barcode-scanning = '17.2.0' +google-mlkit-text-recognition = '16.0.0' +google-play-app-update = '2.1.0' +google-play-review = '2.0.1' +google-play-services-auth = '20.2.0' +google-play-services-code-scanner = '16.1.0' +google-play-services-wearable = '18.1.0' +google-protobuf-plugin = '0.9.4' +google-protobuf-library = '3.25.3' +google-services = '4.4.0' +google-zxing = '3.5.3' +gravatar = '0.2.0' +jackson-databind = '2.12.7.1' +jetty-webapp = '9.4.51.v20230217' +json-path = '2.9.0' +junit = '4.13.2' +kotlin = '1.9.22' +kotlinx-coroutines = '1.8.1' +ksp = '1.9.22-1.0.17' +mockito-inline = '4.6.1' +mockito-kotlin = '4.0.0' +mpandroidchart = 'v3.1.0' +photoview = '2.3.0' +sentry = '4.10.0' +squareup-leakcanary = '2.14' +stripe-terminal = '3.7.1' +tinder-statemachine = '0.2.0' +wiremock = '2.26.3' +wordpress-aztec = 'v2.1.4' +wordpress-fluxc = 'trunk-5dfbbc993d2472f6aad2b9f357021506ecfe7152' +wordpress-login = 'trunk-cb4a9fe62e9371d14fff30a3abd7e91b5e8d1623' +wordpress-libaddressinput = '0.0.2' +wordpress-mediapicker = '0.3.1' +wordpress-utils = '3.5.0' +zendesk = '5.0.8' + +[libraries] +android-billingclient-ktx = { group = "com.android.billingclient", name = "billing-ktx", version.ref = "android-billingclient" } +android-desugar = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "android-desugar" } +android-security-lint = { group = "com.android.security.lint", name = "lint", version.ref = "android-security-lint" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose" } +androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "androidx-activity" } +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidx-appcompat" } +androidx-arch-core-testing = { group = "androidx.arch.core", name = "core-testing", version.ref = "androidx-arch-core" } +androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "androidx-browser" } +androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "androidx-camera" } +androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "androidx-camera" } +androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "androidx-camera" } +androidx-cardview = { group = "androidx.cardview", name = "cardview", version.ref = "androidx-cardview" } +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "androidx-compose-bom" } +androidx-compose-animation-main = { group = "androidx.compose.animation", name = "animation" } +androidx-compose-material-main = { group = "androidx.compose.material", name = "material" } +androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } +androidx-compose-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata" } +androidx-compose-ui-main = { group = "androidx.compose.ui", name = "ui" } +androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } +androidx-compose-ui-text-google-fonts = { group = "androidx.compose.ui", name = "ui-text-google-fonts" } +androidx-compose-ui-tooling-main = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-constraintlayout-compose = { group = "androidx.constraintlayout", name = "constraintlayout-compose", version.ref = "androidx-constraintlayout-compose" } +androidx-constraintlayout-main = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "androidx-constraintlayout-main" } +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core-main" } +androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "androidx-core-splashscreen" } +androidx-datastore-main = { group = "androidx.datastore", name = "datastore", version.ref = "androidx-datastore" } +androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "androidx-datastore" } +androidx-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "androidx-fragment" } +androidx-hilt-common = { group = "androidx.hilt", name = "hilt-common", version.ref = "androidx-hilt" } +androidx-hilt-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "androidx-hilt" } +androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "androidx-hilt" } +androidx-hilt-navigation-fragment = { group = "androidx.hilt", name = "hilt-navigation-fragment", version.ref = "androidx-hilt" } +androidx-hilt-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "androidx-hilt" } +androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "androidx-lifecycle" } +androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "androidx-lifecycle" } +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } +androidx-lifecycle-viewmodel-savedstate = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-savedstate", version.ref = "androidx-lifecycle" } +androidx-navigation-common = { group = "androidx.navigation", name = "navigation-common", version.ref = "androidx-navigation" } +androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "androidx-navigation" } +androidx-navigation-fragment = { group = "androidx.navigation", name = "navigation-fragment", version.ref = "androidx-navigation" } +androidx-navigation-runtime = { group = "androidx.navigation", name = "navigation-runtime", version.ref = "androidx-navigation" } +androidx-navigation-ui = { group = "androidx.navigation", name = "navigation-ui", version.ref = "androidx-navigation" } +androidx-preference-main = { group = "androidx.preference", name = "preference", version.ref = "androidx-preference" } +androidx-preference-ktx = { group = "androidx.preference", name = "preference-ktx", version.ref = "androidx-preference" } +androidx-recyclerview-main = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "androidx-recyclerview-main" } +androidx-recyclerview-selection = { group = "androidx.recyclerview", name = "recyclerview-selection", version.ref = "androidx-recyclerview-selection" } +androidx-test-espresso-contrib = { group = "androidx.test.espresso", name = "espresso-contrib", version.ref = "androidx-test-espresso" } +androidx-test-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidx-test-espresso" } +androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext" } +androidx-test-main-core = { group = "androidx.test", name = "core", version.ref = "androidx-test-main" } +androidx-test-main-rules = { group = "androidx.test", name = "rules", version.ref = "androidx-test-main" } +androidx-test-main-runner = { group = "androidx.test", name = "runner", version.ref = "androidx-test-main" } +androidx-test-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "androidx-test-uiautomator" } +androidx-transition = { group = "androidx.transition", name = "transition", version.ref = "androidx-transition" } +androidx-wear-compose-material = { group = "androidx.wear.compose", name = "compose-material", version.ref = "androidx-wear-compose" } +androidx-wear-compose-foundation = { group = "androidx.wear.compose", name = "compose-foundation", version.ref = "androidx-wear-compose" } +androidx-wear-tiles-main = { group = "androidx.wear.tiles", name = "tiles", version.ref = "androidx-wear-tiles" } +androidx-wear-tiles-material = { group = "androidx.wear.tiles", name = "tiles-material", version.ref = "androidx-wear-tiles" } +androidx-wear-tooling-preview = { group = "androidx.wear", name = "wear-tooling-preview", version.ref = "androidx-wear-tooling" } +androidx-wear-watchface-complications-data-source-ktx = { group = "androidx.wear.watchface", name = "watchface-complications-data-source-ktx", version.ref = "androidx-wear-watchface" } +androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "androidx-work" } +apache-commons-text = { group = "org.apache.commons", name = "commons-text", version.ref = "apache-commons-text" } +apache-http-client-android = { group = "org.apache.httpcomponents", name = "httpclient-android", version.ref = "apache-http-client-android" } +assertj-core = { group = "org.assertj", name = "assertj-core", version.ref = "assertj" } +automattic-about = { group = "com.automattic", name = "about", version.ref = "automattic-about" } +automattic-tracks-android = { group = "com.automattic", name = "Automattic-Tracks-Android", version.ref = "automattic-tracks" } +automattic-tracks-experimentation = { group = "com.automattic.tracks", name = "experimentation", version.ref = "automattic-tracks" } +automattic-tracks-crashlogging = { group = "com.automattic.tracks", name = "crashlogging", version.ref = "automattic-tracks" } +bumptech-glide-main = { group = "com.github.bumptech.glide", name = "glide", version.ref = "bumptech-glide" } +bumptech-glide-compiler = { group = "com.github.bumptech.glide", name = "compiler", version.ref = "bumptech-glide" } +bumptech-glide-volley-integration = { group = "com.github.bumptech.glide", name = "volley-integration", version.ref = "bumptech-glide" } +cashapp-turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "cashapp-turbine" } +coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } +coil-svg = { group = "io.coil-kt", name = "coil-svg", version.ref = "coil" } +commons-fileupload = { group = "commons-fileupload", name = "commons-fileupload", version.ref = "commons-fileupload" } +commons-io = { group = "commons-io", name = "commons-io", version.ref = "commons-io" } +detekt-formatting = { group = "io.gitlab.arturbosch.detekt", name = "detekt-formatting", version.ref = "detekt" } +facebook-flipper-main = { group = "com.facebook.flipper", name = "flipper", version.ref = "facebook-flipper" } +facebook-flipper-network-plugin = { group = "com.facebook.flipper", name = "flipper-network-plugin", version.ref = "facebook-flipper" } +facebook-shimmer = { group = "com.facebook.shimmer", name = "shimmer", version.ref = "facebook-shimmer" } +facebook-soloader = { group = "com.facebook.soloader", name = "soloader", version.ref = "facebook-soloader" } +fastlane-screengrab = { group = "tools.fastlane", name = "screengrab", version.ref = "fastlane-screengrab" } +google-dagger-android-processor = { group = "com.google.dagger", name = "dagger-android-processor", version.ref = "google-dagger" } +google-dagger-android-support = { group = "com.google.dagger", name = "dagger-android-support", version.ref = "google-dagger" } +google-dagger-hilt-android-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "google-dagger" } +google-dagger-hilt-android-main = { group = "com.google.dagger", name = "hilt-android", version.ref = "google-dagger" } +google-dagger-hilt-android-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "google-dagger" } +google-dagger-hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "google-dagger" } +google-firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics" } +google-firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "google-firebase-bom" } +google-firebase-config = { group = "com.google.firebase", name = "firebase-config" } +google-firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging" } +google-gson = { group = "com.google.code.gson", name = "gson", version.ref = "google-gson" } +google-guava = { group = "com.google.guava", name = "guava", version.ref = "google-guava" } +google-horologist-compose-layout = { group = "com.google.android.horologist", name = "horologist-compose-layout", version.ref = "google-horologist" } +google-horologist-compose-tools = { group = "com.google.android.horologist", name = "horologist-compose-tools", version.ref = "google-horologist" } +google-horologist-tiles = { group = "com.google.android.horologist", name = "horologist-tiles", version.ref = "google-horologist" } +google-material = { group = "com.google.android.material", name = "material", version.ref = "google-material" } +google-mlkit-barcode-scanning = { group = "com.google.mlkit", name = "barcode-scanning", version.ref = "google-mlkit-barcode-scanning" } +google-mlkit-text-recognition-main = { group = "com.google.mlkit", name = "text-recognition", version.ref = "google-mlkit-text-recognition" } +google-mlkit-text-recognition-japanese = { group = "com.google.android.gms", name = "play-services-mlkit-text-recognition-japanese", version.ref = "google-mlkit-text-recognition" } +google-mlkit-text-recognition-chinese = { group = "com.google.android.gms", name = "play-services-mlkit-text-recognition-chinese", version.ref = "google-mlkit-text-recognition" } +google-mlkit-text-recognition-korean = { group = "com.google.android.gms", name = "play-services-mlkit-text-recognition-korean", version.ref = "google-mlkit-text-recognition" } +google-play-app-update = { group = "com.google.android.play", name = "app-update", version.ref = "google-play-app-update" } +google-play-review = { group = "com.google.android.play", name = "review", version.ref = "google-play-review" } +google-play-services-auth = { group = "com.google.android.gms", name = "play-services-auth", version.ref = "google-play-services-auth" } +google-play-services-code-scanner = { group = "com.google.android.gms", name = "play-services-code-scanner", version.ref = "google-play-services-code-scanner" } +google-play-services-wearable = { group = "com.google.android.gms", name = "play-services-wearable", version.ref = "google-play-services-wearable" } +google-protobuf-javalite = { group = "com.google.protobuf", name = "protobuf-javalite", version.ref = "google-protobuf-library" } +google-protobuf-protoc = { group = "com.google.protobuf", name = "protoc", version.ref = "google-protobuf-library" } +google-zxing-core = { group = "com.google.zxing", name = "core", version.ref = "google-zxing" } +gravatar = { group = "com.gravatar", name = "gravatar", version.ref = "gravatar" } +jackson-databind = { group = "com.fasterxml.jackson.core", name = "jackson-databind", version.ref = "jackson-databind" } +jetty-webapp = { group = "org.eclipse.jetty", name = "jetty-webapp", version.ref = "jetty-webapp" } +json-path = { group = "com.jayway.jsonpath", name = "json-path", version.ref = "json-path" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +kotlin-test-junit = { group = "org.jetbrains.kotlin", name = "kotlin-test-junit", version.ref = "kotlin" } +kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } +kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } +kotlinx-coroutines-play-services = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-play-services", version.ref = "kotlinx-coroutines" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } +mockito-inline = { group = "org.mockito", name = "mockito-inline", version.ref = "mockito-inline" } +mockito-kotlin = { group = "org.mockito.kotlin", name = "mockito-kotlin", version.ref = "mockito-kotlin" } +mpandroidchart = { group = "com.github.PhilJay", name = "MPAndroidChart", version.ref = "mpandroidchart" } +photoview = { group = "com.github.chrisbanes", name = "PhotoView", version.ref = "photoview" } +squareup-leakcanary-android = { group = "com.squareup.leakcanary", name = "leakcanary-android", version.ref = "squareup-leakcanary" } +stripe-terminal-localmobile = { group = "com.stripe", name = "stripeterminal-localmobile", version.ref = "stripe-terminal" } +stripe-terminal-core = { group = "com.stripe", name = "stripeterminal-core", version.ref = "stripe-terminal" } +tinder-statemachine = { group = "com.tinder.statemachine", name = "statemachine", version.ref = "tinder-statemachine" } +wiremock = { group = "com.github.tomakehurst", name = "wiremock", version.ref = "wiremock" } +wordpress-libaddressinput-common = { group = "org.wordpress", name = "libaddressinput.common", version.ref = "wordpress-libaddressinput" } +wordpress-aztec-main = { group = "org.wordpress", name = "aztec", version.ref = "wordpress-aztec" } +wordpress-aztec-glide-loader = { group = "org.wordpress.aztec", name = "glide-loader", version.ref = "wordpress-aztec" } +wordpress-utils = { group = "org.wordpress", name = "utils", version.ref = "wordpress-utils" } +zendesk-support = { group = "com.zendesk", name = "support", version.ref = "zendesk" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } +android-test = { id = "com.android.test", version.ref = "agp" } +androidx-navigation-safeargs = { id = "androidx.navigation.safeargs.kotlin", version.ref = "androidx-navigation" } +automattic-measure-builds = { id = "com.automattic.android.measure-builds", version.ref = "automattic-measure-builds" } +dependency-analysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependency-analysis" } +detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } +fladle = { id = "com.osacky.fladle", version.ref = "fladle" } +google-dagger-hilt = { id = "com.google.dagger.hilt.android", version.ref = "google-dagger" } +google-protobuf = { id = "com.google.protobuf", version.ref = "google-protobuf-plugin" } +google-services = { id = "com.google.gms.google-services", version.ref = "google-services" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +sentry = { id = "io.sentry.android.gradle", version.ref = "sentry" } diff --git a/libs/cardreader/build.gradle b/libs/cardreader/build.gradle index e727516d5f3..6a1203bf7e9 100644 --- a/libs/cardreader/build.gradle +++ b/libs/cardreader/build.gradle @@ -1,7 +1,7 @@ plugins { - id 'com.android.library' - id 'org.jetbrains.kotlin.android' - id 'org.jetbrains.kotlin.plugin.parcelize' + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.parcelize) } android { @@ -27,16 +27,16 @@ android { } dependencies { - implementation "com.stripe:stripeterminal-localmobile:$stripeTerminalVersion" - implementation "com.stripe:stripeterminal-core:$stripeTerminalVersion" + implementation(libs.stripe.terminal.localmobile) + implementation(libs.stripe.terminal.core) // Coroutines - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion" - - testImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion" - testImplementation "org.mockito:mockito-inline:$mockitoVersion" - testImplementation "org.jetbrains.kotlin:kotlin-test-junit:$gradle.ext.kotlinVersion" - testImplementation "org.assertj:assertj-core:$assertjVersion" - testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion" + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.coroutines.android) + + testImplementation(libs.mockito.kotlin) + testImplementation(libs.mockito.inline) + testImplementation(libs.kotlin.test.junit) + testImplementation(libs.assertj.core) + testImplementation(libs.kotlinx.coroutines.test) } diff --git a/libs/commons/build.gradle b/libs/commons/build.gradle index b52e2241fd6..a0d38063d6a 100644 --- a/libs/commons/build.gradle +++ b/libs/commons/build.gradle @@ -1,6 +1,6 @@ plugins { - id 'com.android.library' - id 'org.jetbrains.kotlin.android' + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) } android { diff --git a/libs/iap/build.gradle b/libs/iap/build.gradle index 54032e4795a..0fcddd4cc9c 100644 --- a/libs/iap/build.gradle +++ b/libs/iap/build.gradle @@ -1,6 +1,6 @@ plugins { - id 'com.android.library' - id 'org.jetbrains.kotlin.android' + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) } android { @@ -26,16 +26,16 @@ android { } dependencies { - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion" - - implementation("com.android.billingclient:billing-ktx:$billingVersion") - implementation "androidx.appcompat:appcompat:$appCompatVersion" - - testImplementation "junit:junit:$jUnitVersion" - testImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion" - testImplementation "org.mockito:mockito-inline:$mockitoVersion" - testImplementation "org.jetbrains.kotlin:kotlin-test-junit:$gradle.ext.kotlinVersion" - testImplementation "org.assertj:assertj-core:$assertjVersion" - testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion" + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.coroutines.android) + + implementation(libs.android.billingclient.ktx) + implementation(libs.androidx.appcompat) + + testImplementation(libs.junit) + testImplementation(libs.mockito.kotlin) + testImplementation(libs.mockito.inline) + testImplementation(libs.kotlin.test.junit) + testImplementation(libs.assertj.core) + testImplementation(libs.kotlinx.coroutines.test) } diff --git a/local-builds.gradle-example b/local-builds.gradle-example index b77bf9bb268..8a58586e77a 100644 --- a/local-builds.gradle-example +++ b/local-builds.gradle-example @@ -17,5 +17,4 @@ ext { //localFluxCPath = "../WordPress-FluxC-Android" //localLoginFlowPath = "../WordPress-Login-Flow-Android" //localMediaPickerPath = "../WordPress-MediaPicker-Android" - //localWooCommerceSharedPath = "../WooCommerce-Shared/libraries/android" } diff --git a/quicklogin/build.gradle b/quicklogin/build.gradle index d650e446700..c2d921eba06 100644 --- a/quicklogin/build.gradle +++ b/quicklogin/build.gradle @@ -1,6 +1,6 @@ plugins { - id 'com.android.test' - id 'org.jetbrains.kotlin.android' + alias(libs.plugins.android.test) + alias(libs.plugins.kotlin.android) } repositories { @@ -78,14 +78,14 @@ android { } dependencies { - implementation "androidx.lifecycle:lifecycle-process:$lifecycleVersion" + implementation(libs.androidx.lifecycle.process) - implementation "androidx.test.uiautomator:uiautomator:2.2.0" - implementation "junit:junit:$jUnitVersion" - implementation "androidx.test.ext:junit:$jUnitExtVersion" - implementation "androidx.test:runner:$androidxTestCoreVersion" - implementation "androidx.test:rules:$androidxTestCoreVersion" - implementation "androidx.test:core:$androidxTestCoreVersion" + implementation(libs.androidx.test.uiautomator) + implementation(libs.junit) + implementation(libs.androidx.test.ext.junit) + implementation(libs.androidx.test.main.runner) + implementation(libs.androidx.test.main.rules) + implementation(libs.androidx.test.main.core) } if (project.hasProperty("debugStoreFile")) { diff --git a/settings.gradle b/settings.gradle index 0e819471080..794b7eb05e3 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,17 +1,4 @@ pluginManagement { - gradle.ext.agpVersion = '8.5.1' - gradle.ext.googleServicesVersion = '4.4.0' - gradle.ext.daggerVersion = '2.50' - gradle.ext.detektVersion = '1.23.5' - gradle.ext.kotlinVersion = '1.9.22' - gradle.ext.kspVersion = '1.9.22-1.0.17' - gradle.ext.measureBuildsVersion = '2.1.2' - gradle.ext.navigationVersion = '2.7.7' - gradle.ext.sentryVersion = '4.10.0' - gradle.ext.protobufVersion = '0.9.4' - gradle.ext.dependencyAnalysisVersion = '1.28.0' - gradle.ext.fladleVersion = '0.17.5' - repositories { google() exclusiveContent { @@ -27,24 +14,6 @@ pluginManagement { } gradlePluginPortal() } - - plugins { - id 'androidx.navigation.safeargs.kotlin' version gradle.ext.navigationVersion - id 'com.android.application' version gradle.ext.agpVersion - id 'com.android.library' version gradle.ext.agpVersion - id 'com.automattic.android.measure-builds' version gradle.ext.measureBuildsVersion - id 'com.google.gms.google-services' version gradle.ext.googleServicesVersion - id 'io.gitlab.arturbosch.detekt' version gradle.ext.detektVersion - id 'io.sentry.android.gradle' version gradle.ext.sentryVersion - id 'org.jetbrains.kotlin.android' version gradle.ext.kotlinVersion - id 'org.jetbrains.kotlin.jvm' version gradle.ext.kotlinVersion - id 'org.jetbrains.kotlin.plugin.parcelize' version gradle.ext.kotlinVersion - id 'com.google.dagger.hilt.android' version gradle.ext.daggerVersion - id 'com.google.devtools.ksp' version gradle.ext.kspVersion - id "com.google.protobuf" version gradle.ext.protobufVersion - id "com.autonomousapps.dependency-analysis" version gradle.ext.dependencyAnalysisVersion - id "com.osacky.fladle" version gradle.ext.fladleVersion - } } plugins {