diff --git a/.github/workflows/run-danger.yml b/.github/workflows/run-danger.yml index d61c242186c..856ab8cea46 100644 --- a/.github/workflows/run-danger.yml +++ b/.github/workflows/run-danger.yml @@ -2,10 +2,12 @@ name: ☢️ Danger on: pull_request: - types: [opened, synchronize, edited, review_requested, review_request_removed, labeled, unlabeled, milestoned, demilestoned] + types: [opened, reopened, ready_for_review, synchronize, edited, labeled, unlabeled, milestoned, demilestoned] jobs: dangermattic: - uses: Automattic/dangermattic/.github/workflows/reusable-run-danger.yml@trunk + # runs on draft PRs only for opened / synchronize events + if: ${{ (github.event.pull_request.draft == false) || (github.event.pull_request.draft == true && contains(fromJSON('["opened", "synchronize"]'), github.event.action)) }} + uses: Automattic/dangermattic/.github/workflows/reusable-run-danger.yml@v1.0.0 secrets: github-token: ${{ secrets.DANGERMATTIC_GITHUB_TOKEN }} diff --git a/.github/workflows/validate-issues.yml b/.github/workflows/validate-issues.yml index 9a8cd5330d6..f32e364b7eb 100644 --- a/.github/workflows/validate-issues.yml +++ b/.github/workflows/validate-issues.yml @@ -9,8 +9,8 @@ jobs: uses: Automattic/dangermattic/.github/workflows/reusable-check-labels-on-issues.yml@v1.0.0 with: label-format-list: '[ - "^type: *$", - "^feature: *$" + "^type: .+", + "^feature: .+" ]' label-error-message: '🚫 Please add a type label (e.g. **type: enhancement**) and a feature label (e.g. **feature: stats**) to this issue.' label-success-message: 'Thanks for reporting! 👍' diff --git a/CHANGELOG.md b/CHANGELOG.md index 11df9536f90..7b4dd4f5260 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ +## 17.4 +We're excited to offer support for third-party Passkey providers. This update is designed to provide WordPress.com users with more flexibility and convenience in how they manage their account's security. Get ready to say goodbye to password fatigue and hello to a smoother, secure login experience! + ## 17.3 We've enhanced the shipping label creation flow to ensure a smoother and more intuitive experience. This improvement aims to streamline your order fulfillment process, making it faster and more efficient. Please continue sending us feedback – we are listening! diff --git a/Dangerfile b/Dangerfile index cfa6cfacb71..fb5fd9e2ca4 100644 --- a/Dangerfile +++ b/Dangerfile @@ -1,42 +1,56 @@ # frozen_string_literal: true -def release_branch? - danger.github.branch_for_base.start_with?('release/') || danger.github.branch_for_base.start_with?('hotfix/') -end - -def main_branch? - danger.github.branch_for_base == 'trunk' -end +github.dismiss_out_of_range_messages -def wip_feature? - has_wip_label = github.pr_labels.any? { |label| label.include?('WIP') } - has_wip_title = github.pr_title.include?('WIP') +# `files: []` forces rubocop to scan all files, not just the ones modified in the PR +rubocop.lint(files: [], force_exclusion: true, inline_comment: true, fail_on_inline_comment: true, include_cop_names: true) - has_wip_label || has_wip_title -end +manifest_pr_checker.check_gemfile_lock_updated -return if github.pr_labels.include?('Releases') +android_release_checker.check_release_notes_and_play_store_strings -github.dismiss_out_of_range_messages +android_strings_checker.check_strings_do_not_refer_resource -manifest_pr_checker.check_gemfile_lock_updated +# skip remaining checks if we're in a release-process PR +if github.pr_labels.include?('Releases') + message('This PR has the `Releases` label: some checks will be skipped.') + return +end -labels_checker.check( - do_not_merge_labels: ['status: do not merge'], - required_labels: [//], - required_labels_error: 'PR requires at least one label.' +common_release_checker.check_internal_release_notes_changed(report_type: :message) + +tracks_checker.check_tracks_changes( + tracks_files: [ + 'AnalyticsTracker.kt', + 'AnalyticsEvent.kt', + 'LoginAnalyticsTracker.kt' + ], + tracks_usage_matchers: [ + /AnalyticsTracker\.track/ + ], + tracks_label: 'category: tracks' ) -view_changes_need_screenshots.view_changes_need_screenshots +view_changes_checker.check pr_size_checker.check_diff_size( - file_selector: ->(path) { !path.include?('/src/test') }, - max_size: 300 + max_size: 300, + file_selector: ->(path) { !path.include?('/src/test') } ) android_unit_test_checker.check_missing_tests -# skip check for draft PRs and for WIP features unless the PR is against the main branch or release branch -milestone_checker.check_milestone_due_date(days_before_due: 2) unless github.pr_draft? || (wip_feature? && !(release_branch? || main_branch?)) +# skip remaining checks if the PR is still a Draft +if github.pr_draft? + message('This PR is still a Draft: some checks will be skipped.') + return +end + +labels_checker.check( + do_not_merge_labels: ['status: do not merge'], + required_labels: [//], + required_labels_error: 'PR requires at least one label.' +) -rubocop.lint(inline_comment: true, fail_on_inline_comment: true, include_cop_names: true) +# runs the milestone check if this is not a WIP feature and the PR is against the main branch or the release branch +milestone_checker.check_milestone_due_date(days_before_due: 2) if (github_utils.main_branch? || github_utils.release_branch?) && !github_utils.wip_feature? diff --git a/Gemfile b/Gemfile index 9a8fd99671d..4bdca7c0fc5 100644 --- a/Gemfile +++ b/Gemfile @@ -2,10 +2,10 @@ source 'https://rubygems.org' -gem 'danger-dangermattic', git: 'https://github.com/Automattic/dangermattic' +gem 'danger-dangermattic', '~> 1.0' gem 'fastlane', '~> 2.216' gem 'nokogiri' -gem 'rubocop', '~> 1.56' +gem 'rubocop', '~> 1.60' ### Fastlane Plugins diff --git a/Gemfile.lock b/Gemfile.lock index 3d8a1823ad4..f911fa69be4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,16 +1,3 @@ -GIT - remote: https://github.com/Automattic/dangermattic - revision: 06a54db4f546d20c0465e4d144049d061a2a1e20 - specs: - danger-dangermattic (0.0.1) - danger (~> 9.3) - danger-junit (~> 1.0) - danger-plugin-api (~> 1.0) - danger-rubocop (~> 0.11) - danger-swiftlint (~> 0.29) - danger-xcode_summary (~> 1.0) - rubocop (~> 1.56) - GEM remote: https://rubygems.org/ specs: @@ -26,7 +13,7 @@ GEM minitest (>= 5.1) mutex_m tzinfo (~> 2.0) - addressable (2.8.5) + addressable (2.8.6) public_suffix (>= 2.0.2, < 6.0) artifactory (3.0.15) ast (2.4.2) @@ -66,7 +53,7 @@ GEM connection_pool (2.4.1) cork (0.3.0) colored2 (~> 3.1) - danger (9.3.2) + danger (9.4.3) claide (~> 1.0) claide-plugins (>= 0.9.2) colored2 (~> 3.1) @@ -77,8 +64,16 @@ GEM kramdown (~> 2.3) kramdown-parser-gfm (~> 1.0) no_proxy_fix - octokit (~> 6.0) + octokit (>= 4.0) terminal-table (>= 1, < 4) + danger-dangermattic (1.0.0) + danger (~> 9.4) + danger-junit (~> 1.0) + danger-plugin-api (~> 1.0) + danger-rubocop (~> 0.12) + danger-swiftlint (~> 0.35) + danger-xcode_summary (~> 1.0) + rubocop (~> 1.60) danger-junit (1.0.2) danger (> 2.0) ox (~> 2.0) @@ -87,10 +82,10 @@ GEM danger-rubocop (0.12.0) danger rubocop (~> 1.0) - danger-swiftlint (0.33.0) + danger-swiftlint (0.35.0) danger rake (> 10) - thor (~> 0.19) + thor (~> 1.0.0) danger-xcode_summary (1.2.0) danger-plugin-api (~> 1.0) xcresult (~> 0.2) @@ -122,7 +117,7 @@ GEM faraday-em_http (1.0.0) faraday-em_synchrony (1.0.0) faraday-excon (1.1.0) - faraday-http-cache (2.5.0) + faraday-http-cache (2.5.1) faraday (>= 0.8) faraday-httpclient (1.0.1) faraday-multipart (1.0.4) @@ -193,7 +188,7 @@ GEM rake-compiler (~> 1.0) xcodeproj (~> 1.22) gh_inspector (1.1.3) - git (1.18.0) + git (1.19.1) addressable (~> 2.8) rchardet (~> 1.8) google-apis-androidpublisher_v3 (0.53.0) @@ -241,7 +236,7 @@ GEM concurrent-ruby (~> 1.0) java-properties (0.3.0) jmespath (1.6.2) - json (2.6.3) + json (2.7.1) jwt (2.7.1) kramdown (2.4.0) rexml @@ -253,16 +248,16 @@ GEM mini_portile2 (2.8.5) minitest (5.20.0) multi_json (1.15.0) - multipart-post (2.3.0) + multipart-post (2.4.0) mutex_m (0.1.2) nanaimo (0.3.0) nap (1.1.0) naturally (2.2.1) no_proxy_fix (0.1.2) - nokogiri (1.15.4) + nokogiri (1.16.2) mini_portile2 (~> 2.8.2) racc (~> 1.4) - nokogiri (1.15.4-arm64-darwin) + nokogiri (1.16.2-arm64-darwin) racc (~> 1.4) octokit (6.1.1) faraday (>= 1, < 3) @@ -272,8 +267,8 @@ GEM optparse (0.1.1) os (1.1.4) ox (2.14.17) - parallel (1.23.0) - parser (3.2.2.4) + parallel (1.24.0) + parser (3.3.0.5) ast (~> 2.4.1) racc plist (3.7.0) @@ -287,7 +282,7 @@ GEM rake-compiler (1.2.5) rake rchardet (1.8.0) - regexp_parser (2.8.2) + regexp_parser (2.9.0) representable (3.2.0) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) @@ -296,15 +291,15 @@ GEM rexml (3.2.6) rmagick (4.3.0) rouge (2.0.7) - rubocop (1.57.2) + rubocop (1.60.2) json (~> 2.3) language_server-protocol (>= 3.17.0) parallel (~> 1.10) - parser (>= 3.2.2.4) + parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 1.8, < 3.0) rexml (>= 3.2.5, < 4.0) - rubocop-ast (>= 1.28.1, < 2.0) + rubocop-ast (>= 1.30.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 3.0) rubocop-ast (1.30.0) @@ -327,7 +322,7 @@ GEM terminal-notifier (2.0.0) terminal-table (3.0.2) unicode-display_width (>= 1.1.1, < 3) - thor (0.20.3) + thor (1.0.1) trailblazer-option (0.1.2) tty-cursor (0.7.1) tty-screen (0.8.1) @@ -357,12 +352,12 @@ PLATFORMS ruby DEPENDENCIES - danger-dangermattic! + danger-dangermattic (~> 1.0) fastlane (~> 2.216) fastlane-plugin-wpmreleasetoolkit (~> 9.2) nokogiri rmagick (~> 4.1) - rubocop (~> 1.56) + rubocop (~> 1.60) BUNDLED WITH 2.4.19 diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index ed9f9b4d711..3d798d81f2e 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -1,18 +1,33 @@ *** PLEASE FOLLOW THIS FORMAT: [] [] +17.5 +----- +- [*] [Internal] As side effect of adding tablet support, toolbar on the product list screen is not collapsible anymore [https://github.com/woocommerce/woocommerce-android/pull/10844] + + 17.4 ----- - [*] [Internal] Added the "Shipping Tax" display in the order creation form to enhance tax detail visibility, ensuring comprehensive financial tracking. [https://github.com/woocommerce/woocommerce-android/pull/10794] +- [**] Added support to use third-party passkey providers and other devices passkeys as a WordPress.com login option [https://github.com/woocommerce/woocommerce-android/pull/10647] 17.3 ----- + +17.3.1 +----- +- [***] Fixed a critical bug causing a crash on Order Detail screen with WooCommerce v8.7.0.10. [https://github.com/woocommerce/woocommerce-android/pull/10871] 17.3 ----- - [*] [Internal] Enhanced user experience in shipping label creation with automatic scrolling to the first invalid field upon form submission failure [https://github.com/woocommerce/woocommerce-android/pull/10657] - [*] [Internal] Enhanced product variation delete confirmation dialog visibility and functionality across device rotations [https://github.com/woocommerce/woocommerce-android/pull/10664] - [*] [Internal] Added a text along with the receipts file when it is shared [https://github.com/woocommerce/woocommerce-android/pull/10681] +- [**] Fixed navigation issues [https://github.com/woocommerce/woocommerce-android/pull/10775] + +17.2.1 +----- +- [**] Fixed navigation bug causing app to crash in some scenarios [https://github.com/woocommerce/woocommerce-android/pull/10786] 17.2 ----- diff --git a/WooCommerce/build.gradle b/WooCommerce/build.gradle index aee1163b234..b735bcacdf8 100644 --- a/WooCommerce/build.gradle +++ b/WooCommerce/build.gradle @@ -353,7 +353,7 @@ dependencies { implementation "org.jetbrains.kotlinx:kotlinx-coroutines-play-services:$coroutinesVersion" testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion" - testImplementation 'app.cash.turbine:turbine:0.8.0' + testImplementation 'app.cash.turbine:turbine:1.0.0' implementation "org.apache.commons:commons-text:$commonsText" implementation "commons-io:commons-io:$commonsIO" diff --git a/WooCommerce/metadata/PlayStoreStrings.pot b/WooCommerce/metadata/PlayStoreStrings.pot index c7c3ef4802a..7b99c24484f 100644 --- a/WooCommerce/metadata/PlayStoreStrings.pot +++ b/WooCommerce/metadata/PlayStoreStrings.pot @@ -11,16 +11,16 @@ msgstr "" "Project-Id-Version: Release Notes & Play Store Descriptions\n" #. translators: Release notes for this version to be displayed in the Play Store. Limit to 500 characters including spaces and commas! -msgctxt "release_note_173" +msgctxt "release_note_174" msgid "" -"17.3:\n" -"We've enhanced the shipping label creation flow to ensure a smoother and more intuitive experience. This improvement aims to streamline your order fulfillment process, making it faster and more efficient. Please continue sending us feedback – we are listening!\n" +"17.4:\n" +"We're excited to offer support for third-party Passkey providers. This update is designed to provide WordPress.com users with more flexibility and convenience in how they manage their account's security. Get ready to say goodbye to password fatigue and hello to a smoother, secure login experience!\n" msgstr "" -msgctxt "release_note_172" +msgctxt "release_note_173" msgid "" -"17.2:\n" -"This version includes optimizations for speed and reliability. We are committed to continuously improving the app, making managing your online store more efficient and hassle-free.\n" +"17.3:\n" +"We've enhanced the shipping label creation flow to ensure a smoother and more intuitive experience. This improvement aims to streamline your order fulfillment process, making it faster and more efficient. Please continue sending us feedback – we are listening!\n" msgstr "" #. translators: Short description of the app to be displayed in the Play Store. Limit to 80 characters including spaces and commas! diff --git a/WooCommerce/metadata/release_notes.txt b/WooCommerce/metadata/release_notes.txt index 670d796b947..2b65b241d4d 100644 --- a/WooCommerce/metadata/release_notes.txt +++ b/WooCommerce/metadata/release_notes.txt @@ -1 +1 @@ -We've enhanced the shipping label creation flow to ensure a smoother and more intuitive experience. This improvement aims to streamline your order fulfillment process, making it faster and more efficient. Please continue sending us feedback – we are listening! +We're excited to offer support for third-party Passkey providers. This update is designed to provide WordPress.com users with more flexibility and convenience in how they manage their account's security. Get ready to say goodbye to password fatigue and hello to a smoother, secure login experience! diff --git a/WooCommerce/src/androidTest/kotlin/com/woocommerce/android/e2e/tests/ui/ProductsRealAPI.kt b/WooCommerce/src/androidTest/kotlin/com/woocommerce/android/e2e/tests/ui/ProductsRealAPI.kt index 5b6579548f2..c0dcf78ae36 100644 --- a/WooCommerce/src/androidTest/kotlin/com/woocommerce/android/e2e/tests/ui/ProductsRealAPI.kt +++ b/WooCommerce/src/androidTest/kotlin/com/woocommerce/android/e2e/tests/ui/ProductsRealAPI.kt @@ -11,7 +11,6 @@ import com.woocommerce.android.e2e.helpers.useMockedAPI import com.woocommerce.android.e2e.screens.TabNavComponent import com.woocommerce.android.e2e.screens.login.WelcomeScreen import com.woocommerce.android.e2e.screens.products.ProductListScreen -import com.woocommerce.android.e2e.screens.shared.FilterScreen import com.woocommerce.android.ui.login.LoginActivity import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest @@ -68,9 +67,6 @@ class ProductsRealAPI : TestBase() { ProductListScreen() .leaveSearchMode() - FilterScreen() - .leaveFilterScreenToProducts() - WelcomeScreen .logoutIfNeeded(composeTestRule) } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/extensions/StringExt.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/extensions/StringExt.kt index 7d47cdce4db..ab0b75e0ef5 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/extensions/StringExt.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/extensions/StringExt.kt @@ -60,10 +60,15 @@ fun String.semverCompareTo(otherVersion: String): Int { val thisVersionTokens = substringBefore("-").split(".").map { Integer.parseInt(it) } val otherVersionTokens = otherVersion.substringBefore("-").split(".").map { Integer.parseInt(it) } - thisVersionTokens.forEachIndexed { index, token -> - if (token > otherVersionTokens[index]) { + val maxLength = maxOf(thisVersionTokens.size, otherVersionTokens.size) + + for (index in 0 until maxLength) { + val thisToken = thisVersionTokens.getOrElse(index) { 0 } + val otherToken = otherVersionTokens.getOrElse(index) { 0 } + + if (thisToken > otherToken) { return 1 - } else if (token < otherVersionTokens[index]) { + } else if (thisToken < otherToken) { return -1 } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/media/MediaFilesRepository.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/media/MediaFilesRepository.kt index 768fac5c962..fe6807a8aa2 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/media/MediaFilesRepository.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/media/MediaFilesRepository.kt @@ -2,6 +2,7 @@ package com.woocommerce.android.media import android.content.Context import android.net.Uri +import com.woocommerce.android.OnChangedException import com.woocommerce.android.R import com.woocommerce.android.extensions.isNotNullOrEmpty import com.woocommerce.android.media.MediaFilesRepository.UploadResult.UploadFailure @@ -10,6 +11,7 @@ import com.woocommerce.android.tools.SelectedSite import com.woocommerce.android.util.CoroutineDispatchers import com.woocommerce.android.util.WooLog import com.woocommerce.android.util.WooLog.T +import com.woocommerce.android.util.dispatchAndAwait import com.woocommerce.android.viewmodel.ResourceProvider import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.channels.ProducerScope @@ -28,6 +30,8 @@ import org.wordpress.android.fluxc.Dispatcher import org.wordpress.android.fluxc.generated.MediaActionBuilder import org.wordpress.android.fluxc.model.MediaModel import org.wordpress.android.fluxc.store.MediaStore +import org.wordpress.android.fluxc.store.MediaStore.MediaPayload +import org.wordpress.android.fluxc.store.MediaStore.OnMediaChanged import org.wordpress.android.fluxc.store.MediaStore.OnMediaUploaded import org.wordpress.android.mediapicker.MediaPickerUtils import org.wordpress.android.util.MediaUtils @@ -43,6 +47,23 @@ class MediaFilesRepository @Inject constructor( private val resourceProvider: ResourceProvider, private val mediaPickerUtils: MediaPickerUtils ) { + suspend fun fetchWordPressMedia(mediaId: Long): Result { + val result = dispatcher.dispatchAndAwait( + action = MediaActionBuilder.newFetchMediaAction( + MediaPayload( + selectedSite.get(), + MediaModel(selectedSite.get().localId().value, mediaId) + ) + ) + ) + + return if (result.isError) { + Result.failure(OnChangedException(result.error)) + } else { + Result.success(result.mediaList.first()) + } + } + suspend fun fetchMedia(localUri: String): MediaModel? { return withContext(dispatchers.io) { val mediaModel = FileUploadUtils.mediaModelFromLocalUri( diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/media/ProductImagesNotificationHandler.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/media/ProductImagesNotificationHandler.kt index 58b4fa08978..706df75c3b9 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/media/ProductImagesNotificationHandler.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/media/ProductImagesNotificationHandler.kt @@ -14,6 +14,7 @@ import com.woocommerce.android.R import com.woocommerce.android.model.Product import com.woocommerce.android.ui.media.MediaFileUploadHandler.ProductImageUploadData import com.woocommerce.android.ui.media.MediaUploadErrorListFragmentArgs +import com.woocommerce.android.ui.products.ProductDetailFragment import com.woocommerce.android.ui.products.ProductDetailFragmentArgs import com.woocommerce.android.util.StringUtils import org.wordpress.android.util.SystemServiceFactory @@ -190,7 +191,11 @@ class ProductImagesNotificationHandler @Inject constructor( NavDeepLinkBuilder(context) .setGraph(R.navigation.nav_graph_main) .setDestination(R.id.productDetailFragment) - .setArguments(ProductDetailFragmentArgs(remoteProductId = productId).toBundle()) + .setArguments( + ProductDetailFragmentArgs( + mode = ProductDetailFragment.Mode.ShowProduct(productId) + ).toBundle() + ) .createPendingIntent() /** diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/support/help/HelpOrigin.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/support/help/HelpOrigin.kt index a8f169d6c40..a58d135c867 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/support/help/HelpOrigin.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/support/help/HelpOrigin.kt @@ -29,7 +29,8 @@ enum class HelpOrigin(private val stringValue: String) { DOMAIN_CHANGE("origin:domain-change"), UPGRADES("origin:upgrades"), ACCOUNT_DELETION("origin:account-deletion"), - ORDERS_LIST("origin:orders-list"); + ORDERS_LIST("origin:orders-list"), + BLAZE_CAMPAIGN_CREATION("origin:blaze-native-campaign-creation"); override fun toString(): String { return stringValue diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/analytics/hub/AnalyticsHubFragment.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/analytics/hub/AnalyticsHubFragment.kt index 6f4093563e3..203ca23e248 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/analytics/hub/AnalyticsHubFragment.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/analytics/hub/AnalyticsHubFragment.kt @@ -1,9 +1,14 @@ package com.woocommerce.android.ui.analytics.hub import android.os.Bundle +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem import android.view.View +import androidx.core.view.MenuProvider import androidx.core.view.isVisible import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController @@ -21,6 +26,7 @@ import com.woocommerce.android.ui.analytics.ranges.StatsTimeRangeSelection.Selec import com.woocommerce.android.ui.base.BaseFragment import com.woocommerce.android.ui.feedback.SurveyType import com.woocommerce.android.util.ChromeCustomTabUtils +import com.woocommerce.android.util.FeatureFlag import com.woocommerce.android.viewmodel.MultiLiveEvent import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.launchIn @@ -51,6 +57,9 @@ class AnalyticsHubFragment : BaseFragment(R.layout.fragment_analytics) { super.onViewCreated(view, savedInstanceState) bind(view) setupResultHandlers(viewModel) + if (FeatureFlag.EXPANDED_ANALYTIC_HUB_M2.isEnabled()) { + setupMenu() + } viewLifecycleOwner.lifecycleScope.launch { viewModel.viewState.flowWithLifecycle(lifecycle).collect { newState -> handleStateChange(newState) } @@ -77,6 +86,7 @@ class AnalyticsHubFragment : BaseFragment(R.layout.fragment_analytics) { is AnalyticsViewEvent.OpenUrl -> ChromeCustomTabUtils.launchUrl(requireContext(), event.url) is AnalyticsViewEvent.OpenWPComWebView -> findNavController() .navigate(NavGraphMainDirections.actionGlobalWPComWebViewFragment(urlToLoad = event.url)) + is AnalyticsViewEvent.OpenDatePicker -> showDateRangePicker(event.fromMillis, event.toMillis) is AnalyticsViewEvent.OpenDateRangeSelector -> openDateRangeSelector() is AnalyticsViewEvent.SendFeedback -> sendFeedback() @@ -164,4 +174,26 @@ class AnalyticsHubFragment : BaseFragment(R.layout.fragment_analytics) { .actionGlobalFeedbackSurveyFragment(SurveyType.ANALYTICS_HUB) .apply { findNavController().navigateSafely(this) } } + + private fun setupMenu() { + requireActivity().addMenuProvider( + object : MenuProvider { + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.menu_analytics_settings, menu) + } + + override fun onMenuItemSelected(item: MenuItem): Boolean { + if (item.itemId == R.id.menu_settings) { + findNavController() + .navigateSafely(AnalyticsHubFragmentDirections.actionAnalyticsToAnalyticsSettings()) + return true + } + + return false + } + }, + viewLifecycleOwner, + Lifecycle.State.RESUMED + ) + } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/analytics/hub/settings/AnalyticsHubSettingFragment.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/analytics/hub/settings/AnalyticsHubSettingFragment.kt new file mode 100644 index 00000000000..d24e9d32e75 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/analytics/hub/settings/AnalyticsHubSettingFragment.kt @@ -0,0 +1,47 @@ +package com.woocommerce.android.ui.analytics.hub.settings + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import com.woocommerce.android.R +import com.woocommerce.android.ui.base.BaseFragment +import com.woocommerce.android.ui.compose.theme.WooThemeWithBackground +import com.woocommerce.android.ui.main.AppBarStatus +import com.woocommerce.android.viewmodel.MultiLiveEvent + +class AnalyticsHubSettingFragment : BaseFragment() { + + private val viewModel: AnalyticsHubSettingsViewModel by viewModels() + + override val activityAppBarStatus: AppBarStatus = AppBarStatus.Hidden + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + return ComposeView(requireContext()).apply { + id = R.id.analytics_hub_settings_view + + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + + setContent { + WooThemeWithBackground { + AnalyticsHubSettingScreen(viewModel) + } + } + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewModel.event.observe(viewLifecycleOwner) { event -> handleEvent(event) } + } + + private fun handleEvent(event: MultiLiveEvent.Event) { + when (event) { + is MultiLiveEvent.Event.Exit -> findNavController().popBackStack() + } + } +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/analytics/hub/settings/AnalyticsHubSettingScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/analytics/hub/settings/AnalyticsHubSettingScreen.kt new file mode 100644 index 00000000000..002f3aab684 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/analytics/hub/settings/AnalyticsHubSettingScreen.kt @@ -0,0 +1,190 @@ +package com.woocommerce.android.ui.analytics.hub.settings + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.DragHandle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.woocommerce.android.R +import com.woocommerce.android.ui.compose.component.AlertDialog +import com.woocommerce.android.ui.compose.theme.WooThemeWithBackground +import com.woocommerce.android.ui.orders.creation.configuration.SelectionCheck + +@Composable +fun AnalyticsHubSettingScreen(viewModel: AnalyticsHubSettingsViewModel) { + BackHandler(onBack = viewModel::onBackPressed) + Scaffold(topBar = { + TopAppBar( + title = { Text(text = stringResource(id = R.string.manage_analytics)) }, + navigationIcon = { + IconButton(viewModel::onBackPressed) { + Icon( + Icons.Filled.Close, + contentDescription = stringResource(id = R.string.back) + ) + } + }, + backgroundColor = colorResource(id = R.color.color_toolbar), + actions = { + TextButton(viewModel::onSaveChanges) { + Text( + text = stringResource(id = R.string.save).uppercase() + ) + } + }, + ) + }) { padding -> + viewModel.viewStateData.liveData.observeAsState().value?.let { state -> + AnalyticsHubSettingScreen( + cards = state.cards, + onSelectionChange = viewModel::onSelectionChange, + modifier = Modifier.padding(padding) + ) + + if (state.showDismissDialog) { + DiscardChangesDialog( + dismissButton = viewModel::onDismissDiscardChanges, + discardButton = viewModel::onDiscardChanges + ) + } + } + } +} + +@Composable +fun AnalyticsHubSettingScreen( + cards: List, + onSelectionChange: (Long, Boolean) -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.colors.surface) + ) { + Text( + modifier = Modifier.padding(start = 16.dp, top = 24.dp), + text = stringResource(id = R.string.analytic_cards).uppercase(), + style = MaterialTheme.typography.caption, + fontWeight = FontWeight.Bold + ) + + LazyColumn(modifier = Modifier.padding(top = 16.dp)) { + itemsIndexed(cards) { i, card -> + AnalyticCardItem( + showTopDivider = i == 0, + id = card.id, + title = card.title, + isSelected = card.isVisible, + onSelectionChange = onSelectionChange + ) + } + } + } +} + +@Composable +fun AnalyticCardItem( + id: Long, + title: String, + isSelected: Boolean, + onSelectionChange: (Long, Boolean) -> Unit, + modifier: Modifier = Modifier, + showTopDivider: Boolean = false +) { + Column { + if (showTopDivider) Divider() + Row( + modifier = modifier + .clickable { onSelectionChange(id, !isSelected) } + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + SelectionCheck( + isSelected = isSelected, + onSelectionChange = { onSelectionChange(id, !isSelected) } + ) + Text( + text = title, + modifier + .weight(2f) + .padding(horizontal = 16.dp) + ) + Icon( + imageVector = Icons.Filled.DragHandle, + contentDescription = stringResource(id = R.string.drag_handle) + ) + } + Divider() + } +} + +@Composable +fun DiscardChangesDialog( + discardButton: () -> Unit, + dismissButton: () -> Unit +) { + AlertDialog( + onDismissRequest = {}, + text = { + Text(text = stringResource(id = R.string.discard_message)) + }, + confirmButton = { + TextButton(onClick = dismissButton) { + Text(stringResource(id = R.string.keep_editing).uppercase()) + } + }, + dismissButton = { + TextButton(onClick = discardButton) { + Text(stringResource(id = R.string.discard).uppercase()) + } + }, + neutralButton = {} + ) +} + +@Composable +@Preview(name = "Screen", device = Devices.PIXEL_4) +fun AnalyticsHubSettingScreenPreview() { + AnalyticsHubSettingScreen( + listOf( + AnalyticCardConfiguration(1L, "Revenue", true), + AnalyticCardConfiguration(2L, "Orders", true), + AnalyticCardConfiguration(3L, "Stats", false) + ), + onSelectionChange = { _, _ -> } + ) +} + +@Composable +@Preview +fun AnalyticCardItemPreview() { + WooThemeWithBackground { + AnalyticCardItem(id = 1L, title = "Revenue", isSelected = true, onSelectionChange = { _, _ -> }) + } +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/analytics/hub/settings/AnalyticsHubSettingsViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/analytics/hub/settings/AnalyticsHubSettingsViewModel.kt new file mode 100644 index 00000000000..87e8e6f7b23 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/analytics/hub/settings/AnalyticsHubSettingsViewModel.kt @@ -0,0 +1,57 @@ +package com.woocommerce.android.ui.analytics.hub.settings + +import android.os.Parcelable +import androidx.lifecycle.SavedStateHandle +import com.woocommerce.android.viewmodel.LiveDataDelegate +import com.woocommerce.android.viewmodel.MultiLiveEvent +import com.woocommerce.android.viewmodel.ScopedViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.parcelize.Parcelize +import javax.inject.Inject + +@HiltViewModel +class AnalyticsHubSettingsViewModel @Inject constructor(savedState: SavedStateHandle) : ScopedViewModel(savedState) { + val viewStateData = LiveDataDelegate(savedState, AnalyticsHubSettingsViewState()) + private var viewState by viewStateData + fun onBackPressed() { + viewState = viewState.copy(showDismissDialog = true) + } + + fun onDismissDiscardChanges() { + viewState = viewState.copy(showDismissDialog = false) + } + + fun onDiscardChanges() { + triggerEvent(MultiLiveEvent.Event.Exit) + } + + fun onSaveChanges() { + triggerEvent(MultiLiveEvent.Event.Exit) + } + + fun onSelectionChange(id: Long, isSelected: Boolean) { + val updatedList = viewState.cards.map { card -> + if (card.id == id) card.copy(isVisible = isSelected) + else card + } + viewState = viewState.copy(cards = updatedList) + } +} + +@Suppress("MagicNumber") +@Parcelize +data class AnalyticsHubSettingsViewState( + val cards: List = listOf( + AnalyticCardConfiguration(1L, "Revenue", true), + AnalyticCardConfiguration(2L, "Orders", true), + AnalyticCardConfiguration(3L, "Stats", false) + ), + val showDismissDialog: Boolean = false +) : Parcelable + +@Parcelize +data class AnalyticCardConfiguration( + val id: Long, + val title: String, + val isVisible: Boolean +) : Parcelable diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/BlazeCampaignCreationScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/BlazeCampaignCreationScreen.kt index f2e4f646fa9..429ddcd1d96 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/BlazeCampaignCreationScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/BlazeCampaignCreationScreen.kt @@ -7,7 +7,7 @@ import androidx.compose.animation.AnimatedContent import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.animation.with +import androidx.compose.animation.togetherWith import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column @@ -84,7 +84,7 @@ fun BlazeCampaignCreationScreen( ) { paddingValues -> AnimatedContent( targetState = viewState, - transitionSpec = { fadeIn() with fadeOut() } + transitionSpec = { fadeIn() togetherWith fadeOut() } ) { targetState -> when (targetState) { is BlazeCreationViewState.Intro -> BlazeCreationIntroScreen( 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 0072eaa097c..9a959dc9a4a 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 @@ -1,17 +1,27 @@ package com.woocommerce.android.ui.blaze import android.os.Parcelable +import com.woocommerce.android.BuildConfig import com.woocommerce.android.OnChangedException +import com.woocommerce.android.media.MediaFilesRepository import com.woocommerce.android.model.CreditCardType import com.woocommerce.android.tools.SelectedSite import com.woocommerce.android.ui.products.ProductDetailRepository -import com.woocommerce.android.util.TimezoneProvider import com.woocommerce.android.util.WooLog +import com.woocommerce.android.util.joinToUrl +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.retry +import kotlinx.coroutines.flow.transform import kotlinx.parcelize.Parcelize +import org.wordpress.android.fluxc.model.MediaModel import org.wordpress.android.fluxc.model.blaze.BlazeAdForecast import org.wordpress.android.fluxc.model.blaze.BlazeAdSuggestion +import org.wordpress.android.fluxc.model.blaze.BlazeCampaignCreationRequest +import org.wordpress.android.fluxc.model.blaze.BlazeCampaignType import org.wordpress.android.fluxc.model.blaze.BlazePaymentMethod.PaymentMethodInfo +import org.wordpress.android.fluxc.model.blaze.BlazeTargetingParameters import org.wordpress.android.fluxc.store.blaze.BlazeCampaignsStore import java.util.Date import javax.inject.Inject @@ -22,13 +32,14 @@ class BlazeRepository @Inject constructor( private val selectedSite: SelectedSite, private val blazeCampaignsStore: BlazeCampaignsStore, private val productDetailRepository: ProductDetailRepository, - private val timezoneProvider: TimezoneProvider, + private val mediaFilesRepository: MediaFilesRepository ) { companion object { + private const val BLAZE_CAMPAIGN_CREATION_ORIGIN = "wc-android" const val BLAZE_DEFAULT_CURRENCY_CODE = "USD" // For now only USD are supported const val DEFAULT_CAMPAIGN_DURATION = 7 // Days - const val CAMPAIGN_MINIMUM_DAILY_SPEND = 5F // USD - const val CAMPAIGN_MAXIMUM_DAILY_SPEND = 50F // USD + const val CAMPAIGN_MINIMUM_DAILY_SPEND = 5f // USD + const val CAMPAIGN_MAXIMUM_DAILY_SPEND = 50f // USD const val CAMPAIGN_MAX_DURATION = 28 // Days } @@ -119,29 +130,54 @@ class BlazeRepository @Inject constructor( } } - suspend fun getCampaignPreviewDetails(productId: Long): CampaignPreview { + suspend fun generateDefaultCampaignDetails(productId: Long): CampaignDetails { + fun getDefaultBudget() = Budget( + totalBudget = DEFAULT_CAMPAIGN_DURATION * CAMPAIGN_MINIMUM_DAILY_SPEND, + spentBudget = 0f, + currencyCode = BLAZE_DEFAULT_CURRENCY_CODE, + durationInDays = DEFAULT_CAMPAIGN_DURATION, + startDate = Date().apply { time += 1.days.inWholeMilliseconds }, // By default start tomorrow + ) + val product = productDetailRepository.getProduct(productId) ?: productDetailRepository.fetchProductOrLoadFromCache(productId)!! - return CampaignPreview( + return CampaignDetails( productId = productId, - userTimeZone = timezoneProvider.deviceTimezone.displayName, - targetUrl = product.permalink, - urlParams = null, - campaignImageUrl = product.firstImageUrl + tagLine = "", + description = "", + campaignImage = product.images.firstOrNull().let { + if (it != null) BlazeCampaignImage.RemoteImage(it.id, it.source) + else BlazeCampaignImage.None + }, + budget = getDefaultBudget(), + targetingParameters = TargetingParameters(), + destinationParameters = DestinationParameters( + targetUrl = product.permalink, + parameters = emptyMap() + ) ) } suspend fun fetchAdForecast( startDate: Date, campaignDurationDays: Int, - totalBudget: Float + totalBudget: Float, + targetingParameters: TargetingParameters ): Result { val result = blazeCampaignsStore.fetchBlazeAdForecast( - selectedSite.get(), - startDate, - Date(startDate.time + campaignDurationDays.days.inWholeMilliseconds), - totalBudget.roundToInt().toDouble(), + siteModel = selectedSite.get(), + startDate = startDate, + endDate = Date(startDate.time + campaignDurationDays.days.inWholeMilliseconds), + totalBudget = totalBudget.roundToInt().toDouble(), + targetingParameters = targetingParameters.let { + BlazeTargetingParameters( + locations = it.locations.map { location -> location.id }, + languages = it.languages.map { language -> language.code }, + devices = it.devices.map { device -> device.id }, + topics = it.interests.map { interest -> interest.id } + ) + } ) return when { result.isError -> { @@ -195,15 +231,130 @@ class BlazeRepository @Inject constructor( } } + suspend fun createCampaign( + campaignDetails: CampaignDetails, + paymentMethodId: String + ): Result { + val image = prepareCampaignImage(campaignDetails.campaignImage).getOrElse { + return Result.failure( + when (it) { + is MediaFilesRepository.MediaUploadException -> CampaignCreationError.MediaUploadError(it.message) + is OnChangedException -> CampaignCreationError.MediaFetchError(it.message) + else -> it + } + ) + } + + val result = blazeCampaignsStore.createCampaign( + selectedSite.get(), + request = BlazeCampaignCreationRequest( + origin = BLAZE_CAMPAIGN_CREATION_ORIGIN, + originVersion = BuildConfig.VERSION_NAME, + type = BlazeCampaignType.PRODUCT, + paymentMethodId = paymentMethodId, + targetResourceId = campaignDetails.productId, + tagLine = campaignDetails.tagLine, + description = campaignDetails.description, + startDate = campaignDetails.budget.startDate, + endDate = campaignDetails.budget.endDate, + budget = campaignDetails.budget.totalBudget.toDouble(), + targetUrl = campaignDetails.destinationParameters.targetUrl, + urlParams = campaignDetails.destinationParameters.parameters, + mainImage = image, + targetingParameters = campaignDetails.targetingParameters.let { + BlazeTargetingParameters( + locations = it.locations.map { location -> location.id }, + languages = it.languages.map { language -> language.code }, + devices = it.devices.map { device -> device.id }, + topics = it.interests.map { interest -> interest.id } + ) + } + ) + ) + + return when { + result.isError -> { + WooLog.w(WooLog.T.BLAZE, "Failed to create campaign: ${result.error}") + Result.failure(CampaignCreationError.CampaignApiError(result.error.message)) + } + + else -> { + WooLog.d(WooLog.T.BLAZE, "Campaign created successfully") + Result.success(Unit) + } + } + } + + private suspend fun prepareCampaignImage(image: BlazeCampaignImage): Result { + val result = when (image) { + is BlazeCampaignImage.LocalImage -> { + mediaFilesRepository.uploadFile(image.uri) + .transform { + when (it) { + is MediaFilesRepository.UploadResult.UploadSuccess -> emit(Result.success(it.media)) + is MediaFilesRepository.UploadResult.UploadFailure -> throw it.error + else -> { /* Do nothing */ + } + } + } + .retry(1) + .catch { emit(Result.failure(it)) } + .first() + } + + is BlazeCampaignImage.RemoteImage -> mediaFilesRepository.fetchWordPressMedia(image.mediaId) + is BlazeCampaignImage.None -> error("No image provided for Blaze Campaign Creation") + } + + return result.onFailure { + WooLog.w(WooLog.T.BLAZE, "Failed to prepare campaign image: ${it.message}") + } + } + @Parcelize - data class CampaignPreview( + data class CampaignDetails( val productId: Long, - val userTimeZone: String, - val targetUrl: String, - val urlParams: Pair?, - val campaignImageUrl: String?, + val tagLine: String, + val description: String, + val campaignImage: BlazeCampaignImage, + val budget: Budget, + val targetingParameters: TargetingParameters, + val destinationParameters: DestinationParameters ) : Parcelable + sealed interface BlazeCampaignImage : Parcelable { + val uri: String + + @Parcelize + data object None : BlazeCampaignImage { + override val uri: String + get() = "" + } + + @Parcelize + data class LocalImage(override val uri: String) : BlazeCampaignImage + + @Parcelize + data class RemoteImage(val mediaId: Long, override val uri: String) : BlazeCampaignImage + } + + @Parcelize + data class TargetingParameters( + val locations: List = emptyList(), + val languages: List = emptyList(), + val devices: List = emptyList(), + val interests: List = emptyList() + ) : Parcelable + + @Parcelize + data class DestinationParameters( + val targetUrl: String, + val parameters: Map + ) : Parcelable { + val fullUrl: String + get() = parameters.joinToUrl(targetUrl) + } + @Parcelize data class AiSuggestionForAd( val tagLine: String, @@ -217,7 +368,10 @@ class BlazeRepository @Inject constructor( val currencyCode: String, val durationInDays: Int, val startDate: Date, - ) : Parcelable + ) : Parcelable { + val endDate: Date + get() = Date(startDate.time + durationInDays.days.inWholeMilliseconds) + } @Parcelize data class PaymentMethodsData( @@ -249,6 +403,12 @@ class BlazeRepository @Inject constructor( val successUrl: String, val idUrlParameter: String ) : Parcelable + + sealed class CampaignCreationError(message: String?) : Exception(message) { + class MediaUploadError(message: String?) : CampaignCreationError(message) + class MediaFetchError(message: String?) : CampaignCreationError(message) + class CampaignApiError(message: String?) : CampaignCreationError(message) + } } @Parcelize diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/campaigs/BlazeCampaignListFragment.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/campaigs/BlazeCampaignListFragment.kt index 8a65f50080d..4f2e369ea91 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/campaigs/BlazeCampaignListFragment.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/campaigs/BlazeCampaignListFragment.kt @@ -49,7 +49,7 @@ class BlazeCampaignListFragment : BaseFragment() { } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - blazeCampaignCreationDispatcher.attachFragment(this) + blazeCampaignCreationDispatcher.attachFragment(this, BlazeFlowSource.CAMPAIGN_LIST) viewModel.event.observe(viewLifecycleOwner) { event -> when (event) { @@ -69,7 +69,7 @@ class BlazeCampaignListFragment : BaseFragment() { private fun openBlazeCreationFlow() { lifecycleScope.launch { - blazeCampaignCreationDispatcher.startCampaignCreation() + blazeCampaignCreationDispatcher.startCampaignCreation(source = BlazeFlowSource.CAMPAIGN_LIST) } } 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 2a97795e66d..35cba68db23 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 @@ -6,10 +6,14 @@ import androidx.navigation.fragment.findNavController import androidx.navigation.navOptions import com.woocommerce.android.R import com.woocommerce.android.R.string +import com.woocommerce.android.analytics.AnalyticsEvent.BLAZE_ENTRY_POINT_TAPPED +import com.woocommerce.android.analytics.AnalyticsTracker +import com.woocommerce.android.analytics.AnalyticsTrackerWrapper import com.woocommerce.android.extensions.handleResult import com.woocommerce.android.extensions.navigateSafely import com.woocommerce.android.ui.base.BaseFragment import com.woocommerce.android.ui.blaze.BlazeRepository +import com.woocommerce.android.ui.blaze.BlazeUrlsHelper.BlazeFlowSource import com.woocommerce.android.ui.blaze.creation.intro.BlazeCampaignCreationIntroFragmentArgs import com.woocommerce.android.ui.blaze.creation.preview.BlazeCampaignCreationPreviewFragmentArgs import com.woocommerce.android.ui.products.ProductListRepository @@ -31,43 +35,60 @@ import javax.inject.Inject class BlazeCampaignCreationDispatcher @Inject constructor( private val blazeRepository: BlazeRepository, private val productListRepository: ProductListRepository, - private val coroutineDispatchers: CoroutineDispatchers + private val coroutineDispatchers: CoroutineDispatchers, + private val analyticsTracker: AnalyticsTrackerWrapper, ) { private var fragmentReference: WeakReference = WeakReference(null) - fun attachFragment(fragment: BaseFragment) { + fun attachFragment(fragment: BaseFragment, source: BlazeFlowSource) { this.fragmentReference = WeakReference(fragment) fragment.handleResult>(ProductSelectorFragment.PRODUCT_SELECTOR_RESULT) { - this.fragmentReference.get()?.showCampaignPreview(it.first().id) + this.fragmentReference.get()?.showCampaignPreview( + productId = it.first().id, + source = source + ) } } suspend fun startCampaignCreation( + source: BlazeFlowSource, productId: Long? = null, handler: (BlazeCampaignCreationDispatcherEvent) -> Unit = ::handleEvent ) { when { blazeRepository.getMostRecentCampaign() == null -> handler( - BlazeCampaignCreationDispatcherEvent.ShowBlazeCampaignCreationIntro(productId) + BlazeCampaignCreationDispatcherEvent.ShowBlazeCampaignCreationIntro(productId, source) ) - else -> startCampaignCreationWithoutIntro(productId, handler) + else -> { + analyticsTracker.track( + stat = BLAZE_ENTRY_POINT_TAPPED, + properties = mapOf(AnalyticsTracker.KEY_BLAZE_SOURCE to source.trackingName) + ) + startCampaignCreationWithoutIntro(productId, source, handler) + } } } private suspend fun startCampaignCreationWithoutIntro( productId: Long?, + source: BlazeFlowSource, handler: (BlazeCampaignCreationDispatcherEvent) -> Unit ) { val products = getPublishedCachedProducts() when { productId != null -> { - handler(BlazeCampaignCreationDispatcherEvent.ShowBlazeCampaignCreationForm(productId)) + handler(BlazeCampaignCreationDispatcherEvent.ShowBlazeCampaignCreationForm(productId, source)) } products.size == 1 -> { - handler(BlazeCampaignCreationDispatcherEvent.ShowBlazeCampaignCreationForm(products.first().remoteId)) + handler( + BlazeCampaignCreationDispatcherEvent.ShowBlazeCampaignCreationForm( + products.first().remoteId, + source + ) + ) } products.isNotEmpty() -> { @@ -92,27 +113,33 @@ class BlazeCampaignCreationDispatcher @Inject constructor( private fun handleEvent(event: BlazeCampaignCreationDispatcherEvent) { when (event) { is BlazeCampaignCreationDispatcherEvent.ShowBlazeCampaignCreationIntro -> fragmentReference.get() - ?.showIntro(event.productId) + ?.showIntro(event.productId, event.blazeSource) is BlazeCampaignCreationDispatcherEvent.ShowBlazeCampaignCreationForm -> fragmentReference.get() - ?.showCampaignPreview(event.productId) + ?.showCampaignPreview(event.productId, event.blazeSource) is BlazeCampaignCreationDispatcherEvent.ShowProductSelectorScreen -> fragmentReference.get() ?.showProductSelector() } } - private fun BaseFragment.showIntro(productId: Long?) { + private fun BaseFragment.showIntro(productId: Long?, blazeSource: BlazeFlowSource) { findNavController().navigateToBlazeGraph( startDestination = R.id.blazeCampaignCreationIntroFragment, - bundle = BlazeCampaignCreationIntroFragmentArgs(productId ?: -1L).toBundle() + bundle = BlazeCampaignCreationIntroFragmentArgs( + productId = productId ?: -1L, + source = blazeSource + ).toBundle() ) } - private fun BaseFragment.showCampaignPreview(productId: Long) { + private fun BaseFragment.showCampaignPreview(productId: Long, source: BlazeFlowSource) { findNavController().navigateToBlazeGraph( startDestination = R.id.blazeCampaignCreationPreviewFragment, - bundle = BlazeCampaignCreationPreviewFragmentArgs(productId).toBundle() + bundle = BlazeCampaignCreationPreviewFragmentArgs( + productId = productId, + source = source + ).toBundle() ) } @@ -155,13 +182,15 @@ class BlazeCampaignCreationDispatcher @Inject constructor( sealed interface BlazeCampaignCreationDispatcherEvent { data class ShowBlazeCampaignCreationIntro( - val productId: Long? + val productId: Long?, + val blazeSource: BlazeFlowSource ) : BlazeCampaignCreationDispatcherEvent data class ShowBlazeCampaignCreationForm( - val productId: Long + val productId: Long, + val blazeSource: BlazeFlowSource ) : BlazeCampaignCreationDispatcherEvent - object ShowProductSelectorScreen : BlazeCampaignCreationDispatcherEvent + data object ShowProductSelectorScreen : BlazeCampaignCreationDispatcherEvent } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/ad/BlazeCampaignCreationEditAdFragment.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/ad/BlazeCampaignCreationEditAdFragment.kt index 7e0fe7efbeb..ef94c3eaf3f 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/ad/BlazeCampaignCreationEditAdFragment.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/ad/BlazeCampaignCreationEditAdFragment.kt @@ -55,17 +55,13 @@ class BlazeCampaignCreationEditAdFragment : BaseFragment(), MediaPickerResultHan override fun onDeviceMediaSelected(imageUris: List, source: String) { if (imageUris.isNotEmpty()) { - onImageSelected(imageUris.first().toString()) + viewModel.onLocalImageSelected(imageUris.first().toString()) } } override fun onWPMediaSelected(images: List) { if (images.isNotEmpty()) { - onImageSelected(images.first().source) + viewModel.onWPMediaSelected(images.first()) } } - - private fun onImageSelected(mediaUri: String) { - viewModel.onImageChanged(mediaUri) - } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/ad/BlazeCampaignCreationEditAdScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/ad/BlazeCampaignCreationEditAdScreen.kt index ac685141b45..b1fabee6cb3 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/ad/BlazeCampaignCreationEditAdScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/ad/BlazeCampaignCreationEditAdScreen.kt @@ -48,6 +48,7 @@ import com.woocommerce.android.R.dimen import com.woocommerce.android.R.drawable import com.woocommerce.android.R.string import com.woocommerce.android.mediapicker.MediaPickerDialog +import com.woocommerce.android.ui.blaze.BlazeRepository.BlazeCampaignImage import com.woocommerce.android.ui.blaze.creation.ad.BlazeCampaignCreationEditAdViewModel.ViewState import com.woocommerce.android.ui.compose.component.Toolbar import com.woocommerce.android.ui.compose.component.WCOutlinedTextField @@ -295,7 +296,7 @@ private fun AdImageSection(viewState: ViewState, onChangeImageTapped: () -> Unit ) { SubcomposeAsyncImage( model = Builder(LocalContext.current) - .data(viewState.adImageUrl) + .data(viewState.adImage.uri) .crossfade(true) .fallback(drawable.blaze_campaign_product_placeholder) .placeholder(drawable.blaze_campaign_product_placeholder) @@ -365,7 +366,7 @@ fun PreviewCampaignEditAdContent() { WooThemeWithBackground { CampaignEditAdContent( viewState = ViewState( - adImageUrl = "https://rb.gy/gmjuwb" + adImage = BlazeCampaignImage.RemoteImage(0, "https://rb.gy/gmjuwb") ), onTagLineChanged = { }, onDescriptionChanged = { }, diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/ad/BlazeCampaignCreationEditAdViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/ad/BlazeCampaignCreationEditAdViewModel.kt index 8236ecc09d3..bd3da3bbaf0 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/ad/BlazeCampaignCreationEditAdViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/ad/BlazeCampaignCreationEditAdViewModel.kt @@ -4,6 +4,7 @@ import android.os.Parcelable import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.asLiveData import androidx.lifecycle.viewModelScope +import com.woocommerce.android.model.Product import com.woocommerce.android.ui.blaze.BlazeRepository import com.woocommerce.android.ui.blaze.BlazeRepository.AiSuggestionForAd import com.woocommerce.android.viewmodel.MultiLiveEvent.Event @@ -33,7 +34,7 @@ class BlazeCampaignCreationEditAdViewModel @Inject constructor( private val _viewState = savedStateHandle.getStateFlow( scope = viewModelScope, - initialValue = ViewState(navArgs.adImageUrl) + initialValue = ViewState(navArgs.adImage) ) val viewState = _viewState.asLiveData() @@ -85,7 +86,7 @@ class BlazeCampaignCreationEditAdViewModel @Inject constructor( EditAdResult( tagline = _viewState.value.tagLine, description = _viewState.value.description, - campaignImageUrl = _viewState.value.adImageUrl + campaignImage = _viewState.value.adImage ) ) ) @@ -116,9 +117,19 @@ class BlazeCampaignCreationEditAdViewModel @Inject constructor( updateSuggestion(AiSuggestionForAd(_viewState.value.tagLine, description.take(TAGLINE_MAX_LENGTH))) } - fun onImageChanged(url: String) { + fun onLocalImageSelected(uri: String) { _viewState.update { - _viewState.value.copy(adImageUrl = url) + _viewState.value.copy(adImage = BlazeRepository.BlazeCampaignImage.LocalImage(uri)) + } + } + + fun onWPMediaSelected(image: Product.Image) { + _viewState.update { + _viewState.value.copy( + adImage = BlazeRepository.BlazeCampaignImage.RemoteImage( + mediaId = image.id, uri = image.source + ) + ) } } @@ -140,7 +151,7 @@ class BlazeCampaignCreationEditAdViewModel @Inject constructor( @Parcelize data class ViewState( - val adImageUrl: String?, + val adImage: BlazeRepository.BlazeCampaignImage, val suggestions: List = emptyList(), val suggestionIndex: Int = 0, val isMediaPickerDialogVisible: Boolean = false @@ -163,6 +174,6 @@ class BlazeCampaignCreationEditAdViewModel @Inject constructor( data class EditAdResult( val tagline: String, val description: String, - val campaignImageUrl: String? + val campaignImage: BlazeRepository.BlazeCampaignImage ) : Parcelable } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/budget/BlazeCampaignBudgetViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/budget/BlazeCampaignBudgetViewModel.kt index 681dc53a25e..fa833361ff6 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/budget/BlazeCampaignBudgetViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/budget/BlazeCampaignBudgetViewModel.kt @@ -158,7 +158,8 @@ class BlazeCampaignBudgetViewModel @Inject constructor( repository.fetchAdForecast( startDate = Date(budgetUiState.value.campaignStartDateMillis), campaignDurationDays = budgetUiState.value.durationInDays, - totalBudget = budgetUiState.value.totalBudget + totalBudget = budgetUiState.value.totalBudget, + targetingParameters = navArgs.targetingParameters ).onSuccess { fetchAdForecastResult -> campaignForecastState = campaignForecastState.copy( isLoading = false, diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/destination/BlazeCampaignCreationAdDestinationFragment.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/destination/BlazeCampaignCreationAdDestinationFragment.kt index bca35bd5d89..edb0f3e8287 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/destination/BlazeCampaignCreationAdDestinationFragment.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/destination/BlazeCampaignCreationAdDestinationFragment.kt @@ -7,17 +7,22 @@ import android.view.ViewGroup import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController import com.woocommerce.android.extensions.handleResult +import com.woocommerce.android.extensions.navigateBackWithResult import com.woocommerce.android.extensions.navigateSafely import com.woocommerce.android.ui.base.BaseFragment +import com.woocommerce.android.ui.blaze.BlazeRepository.DestinationParameters import com.woocommerce.android.ui.blaze.creation.destination.BlazeCampaignCreationAdDestinationParametersFragment.Companion.BLAZE_DESTINATION_PARAMETERS_RESULT import com.woocommerce.android.ui.blaze.creation.destination.BlazeCampaignCreationAdDestinationViewModel.NavigateToParametersScreen 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 class BlazeCampaignCreationAdDestinationFragment : BaseFragment() { + companion object { + const val BLAZE_DESTINATION_RESULT = "blaze_destination_result" + } private val viewModel: BlazeCampaignCreationAdDestinationViewModel by viewModels() override val activityAppBarStatus: AppBarStatus @@ -37,10 +42,10 @@ class BlazeCampaignCreationAdDestinationFragment : BaseFragment() { private fun handleEvents() { viewModel.event.observe(viewLifecycleOwner) { event -> when (event) { - is MultiLiveEvent.Event.Exit -> findNavController().navigateUp() + is ExitWithResult<*> -> navigateBackWithResult(BLAZE_DESTINATION_RESULT, event.data) is NavigateToParametersScreen -> { val action = BlazeCampaignCreationAdDestinationFragmentDirections - .actionAdDestinationFragmentToAdDestinationParametersFragment(event.url) + .actionAdDestinationFragmentToAdDestinationParametersFragment(event.destinationParameters) findNavController().navigateSafely(action) } } @@ -48,8 +53,8 @@ class BlazeCampaignCreationAdDestinationFragment : BaseFragment() { } private fun handleResults() { - handleResult(BLAZE_DESTINATION_PARAMETERS_RESULT) { url -> - viewModel.onTargetUrlUpdated(url) + handleResult(BLAZE_DESTINATION_PARAMETERS_RESULT) { + viewModel.onDestinationParametersUpdated(it.targetUrl, it.parameters) } } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/destination/BlazeCampaignCreationAdDestinationParametersBottomSheet.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/destination/BlazeCampaignCreationAdDestinationParametersBottomSheet.kt new file mode 100644 index 00000000000..fc90364c3b7 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/destination/BlazeCampaignCreationAdDestinationParametersBottomSheet.kt @@ -0,0 +1,176 @@ +package com.woocommerce.android.ui.blaze.creation.destination + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Divider +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.MaterialTheme +import androidx.compose.material.ModalBottomSheetState +import androidx.compose.material.ModalBottomSheetValue.Hidden +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import com.woocommerce.android.R +import com.woocommerce.android.ui.blaze.creation.destination.BlazeCampaignCreationAdDestinationParametersViewModel.ViewState +import com.woocommerce.android.ui.blaze.creation.destination.BlazeCampaignCreationAdDestinationParametersViewModel.ViewState.ParameterBottomSheetState.Editing +import com.woocommerce.android.ui.compose.component.BottomSheetHandle +import com.woocommerce.android.ui.compose.component.ModalStatusBarBottomSheetLayout +import com.woocommerce.android.ui.compose.component.WCColoredButton +import com.woocommerce.android.ui.compose.component.WCOutlinedTextField +import com.woocommerce.android.ui.compose.preview.LightDarkThemePreviews +import com.woocommerce.android.ui.compose.theme.WooThemeWithBackground + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun AdDestinationParametersBottomSheet( + viewState: ViewState, + modalSheetState: ModalBottomSheetState, + onParameterChanged: (String, String) -> Unit, + onParameterSaved: (String, String) -> Unit, + onParameterBottomSheetDismissed: () -> Unit, + modifier: Modifier = Modifier, + screenContent: @Composable () -> Unit, +) { + val roundedCornerRadius = dimensionResource(id = R.dimen.major_100) + + BackHandler(modalSheetState.isVisible) { + onParameterBottomSheetDismissed() + } + + LaunchedEffect(modalSheetState.currentValue) { + if (modalSheetState.currentValue == Hidden) { + onParameterBottomSheetDismissed() + } + } + + ModalStatusBarBottomSheetLayout( + sheetState = modalSheetState, + sheetShape = RoundedCornerShape(topStart = roundedCornerRadius, topEnd = roundedCornerRadius), + sheetContent = { + if (viewState.bottomSheetState is Editing) { + ParameterBottomSheet( + paramsState = viewState.bottomSheetState, + onParameterChanged = onParameterChanged, + onParameterSaved = onParameterSaved, + modifier = modifier.fillMaxWidth() + ) + } + } + ) { + screenContent() + } +} + +@Composable +private fun ParameterBottomSheet( + paramsState: Editing, + onParameterChanged: (String, String) -> Unit, + onParameterSaved: (String, String) -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + ) { + Spacer(modifier = Modifier.height(dimensionResource(id = R.dimen.minor_100))) + BottomSheetHandle(Modifier.align(Alignment.CenterHorizontally)) + Column( + modifier = Modifier + .padding(vertical = dimensionResource(id = R.dimen.major_100)), + verticalArrangement = Arrangement.spacedBy(dimensionResource(id = R.dimen.minor_100)) + ) { + Text( + text = stringResource(id = R.string.blaze_campaign_edit_ad_destination_add_parameter_button), + style = MaterialTheme.typography.h6, + modifier = Modifier + .padding(horizontal = dimensionResource(id = R.dimen.major_100)) + ) + + Divider( + modifier = Modifier + .padding(top = dimensionResource(id = R.dimen.minor_100)), + color = colorResource(id = R.color.divider_color), + thickness = dimensionResource(id = R.dimen.minor_10), + ) + + WCOutlinedTextField( + value = paramsState.key, + label = stringResource(id = R.string.blaze_campaign_edit_ad_destination_parameter_key), + onValueChange = { + onParameterChanged(it, paramsState.value) + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = dimensionResource(id = R.dimen.major_100)), + isError = paramsState.error != 0, + helperText = if (paramsState.error != 0) stringResource(paramsState.error) else null, + ) + + WCOutlinedTextField( + value = paramsState.value, + label = stringResource(id = R.string.blaze_campaign_edit_ad_destination_parameter_value), + onValueChange = { + onParameterChanged(paramsState.key, it) + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = dimensionResource(id = R.dimen.major_100)), + ) + + Text( + modifier = Modifier + .padding(horizontal = dimensionResource(id = R.dimen.major_100)), + text = stringResource( + R.string.blaze_campaign_edit_ad_destination_destination_with_parameters, paramsState.url + ), + style = MaterialTheme.typography.caption, + color = colorResource(id = R.color.color_on_surface_medium) + ) + + WCColoredButton( + modifier = Modifier + .fillMaxWidth() + .padding( + start = dimensionResource(id = R.dimen.major_100), + end = dimensionResource(id = R.dimen.major_100), + top = dimensionResource(id = R.dimen.minor_100) + ), + onClick = { onParameterSaved(paramsState.key, paramsState.value) }, + text = stringResource(id = R.string.save), + enabled = paramsState.isSaveButtonEnabled + ) + } + } +} + +@LightDarkThemePreviews +@Composable +fun PreviewParameterBottomSheet() { + WooThemeWithBackground { + ParameterBottomSheet( + paramsState = Editing( + targetUrl = "https://example.com", + parameters = emptyMap(), + key = "key", + value = "value" + ), + onParameterChanged = { _, _ -> }, + onParameterSaved = { _, _ -> }, + modifier = Modifier.fillMaxWidth() + ) + } +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/destination/BlazeCampaignCreationAdDestinationParametersScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/destination/BlazeCampaignCreationAdDestinationParametersScreen.kt index 6779a59275e..a23616e44a4 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/destination/BlazeCampaignCreationAdDestinationParametersScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/destination/BlazeCampaignCreationAdDestinationParametersScreen.kt @@ -9,11 +9,13 @@ 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.itemsIndexed +import androidx.compose.foundation.lazy.items import androidx.compose.material.Divider +import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme +import androidx.compose.material.ModalBottomSheetValue.Hidden import androidx.compose.material.Scaffold import androidx.compose.material.Text import androidx.compose.material.icons.Icons @@ -21,7 +23,9 @@ import androidx.compose.material.icons.Icons.Filled import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.DeleteOutline +import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier @@ -47,138 +51,179 @@ fun BlazeCampaignCreationAdDestinationParametersScreen( viewModel::onBackPressed, viewModel::onAddParameterTapped, viewModel::onParameterTapped, - viewModel::onDeleteParameterTapped + viewModel::onDeleteParameterTapped, + viewModel::onParameterChanged, + viewModel::onParameterSaved, + viewModel::onParameterBottomSheetDismissed ) } } -@OptIn(ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterialApi::class) @Composable fun AdDestinationParametersScreen( viewState: ViewState, onBackPressed: () -> Unit, onAddParameterTapped: () -> Unit, onParameterTapped: (String) -> Unit, - onDeleteParameterTapped: (String) -> Unit + onDeleteParameterTapped: (String) -> Unit, + onParameterChanged: (String, String) -> Unit, + onParameterSaved: (String, String) -> Unit, + onParameterBottomSheetDismissed: () -> Unit ) { - Scaffold( - topBar = { - Toolbar( - title = stringResource(id = R.string.blaze_campaign_edit_ad_destination_parameters_property_title), - onNavigationButtonClick = onBackPressed, - navigationIcon = Filled.ArrowBack - ) - }, - modifier = Modifier.background(MaterialTheme.colors.surface) - ) { paddingValues -> - LazyColumn( - modifier = Modifier - .background(MaterialTheme.colors.surface) - .padding(paddingValues) - .fillMaxSize(), - ) { - item(key = "header") { - WCTextButton( - modifier = Modifier - .fillMaxWidth() - .padding(dimensionResource(id = R.dimen.minor_50)), - onClick = onAddParameterTapped, - text = stringResource(id = R.string.blaze_campaign_edit_ad_destination_add_parameter_button), - icon = Icons.Default.Add + val modalSheetState = rememberModalBottomSheetState( + initialValue = Hidden, + skipHalfExpanded = true + ) + + LaunchedEffect(viewState.bottomSheetState is ViewState.ParameterBottomSheetState.Hidden) { + if (viewState.bottomSheetState is ViewState.ParameterBottomSheetState.Editing) { + modalSheetState.show() + } else { + modalSheetState.hide() + } + } + + AdDestinationParametersBottomSheet( + viewState = viewState, + modalSheetState = modalSheetState, + onParameterChanged = onParameterChanged, + onParameterSaved = onParameterSaved, + onParameterBottomSheetDismissed = onParameterBottomSheetDismissed + ) { + Scaffold( + topBar = { + Toolbar( + title = stringResource(id = R.string.blaze_campaign_edit_ad_destination_parameters_property_title), + onNavigationButtonClick = onBackPressed, + navigationIcon = Filled.ArrowBack ) - } + }, + modifier = Modifier.background(MaterialTheme.colors.surface) + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .background(MaterialTheme.colors.surface) + .padding(paddingValues) + .fillMaxSize(), + ) { + item(key = "header") { + WCTextButton( + modifier = Modifier + .fillMaxWidth() + .padding(dimensionResource(id = R.dimen.minor_50)), + onClick = onAddParameterTapped, + text = stringResource(id = R.string.blaze_campaign_edit_ad_destination_add_parameter_button), + icon = Icons.Default.Add + ) + } + + items( + items = viewState.parameters.entries.toList(), + key = { item -> "key${item.key}" } + ) { (key, value) -> + ParameterItem( + onParameterTapped = onParameterTapped, + key = key, + value = value, + onDeleteParameterTapped = onDeleteParameterTapped, + modifier = Modifier.animateItemPlacement() + ) + } - itemsIndexed( - items = viewState.parameters.entries.toList(), - key = { _, item -> "key${item.key}" } - ) { index, (key, value) -> - Column( - modifier = Modifier - .animateItemPlacement() - .fillMaxWidth() - ) { - Row( + item(key = "footer") { + Column( modifier = Modifier - .clickable { onParameterTapped(key) } - .padding( - start = dimensionResource(id = R.dimen.major_100), - top = dimensionResource(id = R.dimen.minor_100), - bottom = dimensionResource(id = R.dimen.minor_100) - ), - verticalAlignment = CenterVertically + .animateItemPlacement() + .fillMaxWidth() ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = key, - style = MaterialTheme.typography.body2, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - fontWeight = FontWeight.Bold - ) - Text( - text = value, - style = MaterialTheme.typography.body2, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = colorResource(id = R.color.color_on_surface_medium) - ) - } - IconButton( - onClick = { onDeleteParameterTapped(key) } - ) { - Icon( - imageVector = Icons.Default.DeleteOutline, - contentDescription = stringResource(id = R.string.delete), - tint = colorResource(id = R.color.color_on_surface_medium) - ) - } - } - - if (index < viewState.parameters.size) { - Divider( + Text( modifier = Modifier - .fillMaxWidth() + .padding( + start = dimensionResource(id = R.dimen.major_100), + end = dimensionResource(id = R.dimen.major_100), + top = dimensionResource(id = R.dimen.major_100), + bottom = dimensionResource(id = R.dimen.minor_100) + ), + text = stringResource( + R.string.blaze_campaign_edit_ad_characters_remaining, + viewState.charactersRemaining + ), + style = MaterialTheme.typography.caption, + color = colorResource(id = R.color.color_on_surface_medium) + ) + Text( + modifier = Modifier + .padding( + horizontal = dimensionResource(id = R.dimen.major_100), + ), + text = stringResource( + R.string.blaze_campaign_edit_ad_destination_destination_with_parameters, + viewState.url + ), + style = MaterialTheme.typography.caption, + color = colorResource(id = R.color.color_on_surface_medium) ) } } } + } + } +} - item(key = "footer") { - Column( - modifier = Modifier - .animateItemPlacement() - .fillMaxWidth() - ) { - Text( - modifier = Modifier - .padding( - start = dimensionResource(id = R.dimen.major_100), - end = dimensionResource(id = R.dimen.major_100), - top = dimensionResource(id = R.dimen.major_100), - bottom = dimensionResource(id = R.dimen.minor_100) - ), - text = stringResource( - R.string.blaze_campaign_edit_ad_characters_remaining, - viewState.charactersRemaining - ), - style = MaterialTheme.typography.caption, - color = colorResource(id = R.color.color_on_surface_medium) - ) - Text( - modifier = Modifier - .padding( - horizontal = dimensionResource(id = R.dimen.major_100), - ), - text = stringResource( - R.string.blaze_campaign_edit_ad_destination_destination_with_parameters, - viewState.url - ), - style = MaterialTheme.typography.caption, - color = colorResource(id = R.color.color_on_surface_medium) - ) - } +@Composable +private fun ParameterItem( + key: String, + value: String, + onParameterTapped: (String) -> Unit, + onDeleteParameterTapped: (String) -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxWidth() + ) { + Row( + modifier = Modifier + .clickable { onParameterTapped(key) } + .padding( + start = dimensionResource(id = R.dimen.major_100), + top = dimensionResource(id = R.dimen.minor_100), + bottom = dimensionResource(id = R.dimen.minor_100) + ), + verticalAlignment = CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = key, + style = MaterialTheme.typography.body2, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.Bold + ) + Text( + text = value, + style = MaterialTheme.typography.body2, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = colorResource(id = R.color.color_on_surface_medium) + ) + } + IconButton( + onClick = { onDeleteParameterTapped(key) } + ) { + Icon( + imageVector = Icons.Default.DeleteOutline, + contentDescription = stringResource(id = R.string.delete), + tint = colorResource(id = R.color.color_on_surface_medium) + ) } } + + Divider( + modifier = Modifier + .fillMaxWidth() + ) } } @@ -188,17 +233,21 @@ fun PreviewAdDestinationParametersScreen() { WooThemeWithBackground { AdDestinationParametersScreen( viewState = ViewState( - baseUrl = "https://woocommerce.com", + targetUrl = "https://woocommerce.com", parameters = mapOf( "utm_source" to "woocommerce", "utm_medium" to "android", "utm_campaign" to "blaze" - ) + ), + bottomSheetState = ViewState.ParameterBottomSheetState.Hidden ), onBackPressed = {}, onAddParameterTapped = {}, onParameterTapped = {}, - onDeleteParameterTapped = {} + onDeleteParameterTapped = {}, + onParameterChanged = { _, _ -> }, + onParameterSaved = { _, _ -> }, + onParameterBottomSheetDismissed = {} ) } } @@ -209,13 +258,17 @@ fun PreviewEmptyAdDestinationParametersScreen() { WooThemeWithBackground { AdDestinationParametersScreen( viewState = ViewState( - baseUrl = "https://woocommerce.com?utm_source=woocommerce&utm_medium=android&utm_campaign=blaze", - parameters = emptyMap() + targetUrl = "https://woocommerce.com?utm_source=woocommerce&utm_medium=android&utm_campaign=blaze", + parameters = emptyMap(), + bottomSheetState = ViewState.ParameterBottomSheetState.Hidden ), onBackPressed = {}, onAddParameterTapped = {}, onParameterTapped = {}, - onDeleteParameterTapped = {} + onDeleteParameterTapped = {}, + onParameterChanged = { _, _ -> }, + onParameterSaved = { _, _ -> }, + onParameterBottomSheetDismissed = {} ) } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/destination/BlazeCampaignCreationAdDestinationParametersViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/destination/BlazeCampaignCreationAdDestinationParametersViewModel.kt index 5c49494dfc1..0df47af46b2 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/destination/BlazeCampaignCreationAdDestinationParametersViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/destination/BlazeCampaignCreationAdDestinationParametersViewModel.kt @@ -1,16 +1,23 @@ package com.woocommerce.android.ui.blaze.creation.destination +import android.os.Parcelable +import androidx.annotation.StringRes import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.asLiveData -import com.woocommerce.android.util.getBaseUrl +import androidx.lifecycle.viewModelScope +import com.woocommerce.android.R +import com.woocommerce.android.ui.blaze.BlazeRepository.DestinationParameters +import com.woocommerce.android.ui.blaze.creation.destination.BlazeCampaignCreationAdDestinationParametersViewModel.ViewState.ParameterBottomSheetState.Editing +import com.woocommerce.android.ui.blaze.creation.destination.BlazeCampaignCreationAdDestinationParametersViewModel.ViewState.ParameterBottomSheetState.Hidden import com.woocommerce.android.util.joinToUrl -import com.woocommerce.android.util.parseParameters import com.woocommerce.android.viewmodel.MultiLiveEvent.Event.ExitWithResult import com.woocommerce.android.viewmodel.ScopedViewModel +import com.woocommerce.android.viewmodel.getStateFlow import com.woocommerce.android.viewmodel.navArgs import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize import javax.inject.Inject @HiltViewModel @@ -18,31 +25,48 @@ class BlazeCampaignCreationAdDestinationParametersViewModel @Inject constructor( savedStateHandle: SavedStateHandle ) : ScopedViewModel(savedStateHandle) { companion object { - // The maximum number of characters allowed in a URL by Chrome - private const val MAX_CHARACTERS = 2096 + private const val MAX_CHARACTERS_FOR_DESTINATION_URL = 2096 } + private val navArgs: BlazeCampaignCreationAdDestinationParametersFragmentArgs by savedStateHandle.navArgs() - private val _viewState = MutableStateFlow( - ViewState( - baseUrl = navArgs.url.getBaseUrl(), - parameters = navArgs.url.parseParameters() + private val _viewState = savedStateHandle.getStateFlow( + scope = viewModelScope, + initialValue = ViewState( + targetUrl = navArgs.destinationParameters.targetUrl, + parameters = navArgs.destinationParameters.parameters, + bottomSheetState = Hidden ) ) val viewState = _viewState.asLiveData() fun onBackPressed() { - triggerEvent(ExitWithResult(_viewState.value.url)) + triggerEvent(ExitWithResult(DestinationParameters(_viewState.value.targetUrl, _viewState.value.parameters))) } fun onAddParameterTapped() { - /* TODO */ + _viewState.update { + it.copy( + bottomSheetState = Editing( + targetUrl = it.targetUrl, + parameters = it.parameters + ) + ) + } } - @Suppress("UNUSED_PARAMETER") fun onParameterTapped(key: String) { - /* TODO */ + _viewState.update { + it.copy( + bottomSheetState = Editing( + targetUrl = it.targetUrl, + parameters = it.parameters - key, + key = key, + value = it.parameters[key] ?: "" + ) + ) + } } fun onDeleteParameterTapped(key: String) { @@ -51,15 +75,89 @@ class BlazeCampaignCreationAdDestinationParametersViewModel @Inject constructor( } } + fun onParameterBottomSheetDismissed() { + _viewState.update { + it.copy(bottomSheetState = Hidden) + } + } + + fun onParameterChanged(key: String, value: String) { + _viewState.update { + it.copy( + bottomSheetState = (it.bottomSheetState as Editing).copy( + key = key, + value = value, + error = getError(key, value) + ) + ) + } + } + + private fun getError(key: String, value: String): Int { + val editingState = _viewState.value.bottomSheetState as Editing + val parametersLength = (editingState.parameters + (key to value)).entries.joinToString("&").length + return when { + editingState.parameters.containsKey(key) -> { + R.string.blaze_campaign_edit_ad_destination_key_exists_error + } + + parametersLength >= MAX_CHARACTERS_FOR_DESTINATION_URL -> { + R.string.blaze_campaign_edit_ad_destination_too_long_error + } + + else -> 0 + } + } + + fun onParameterSaved(key: String, value: String) { + _viewState.update { + val params = it.parameters.toMutableMap() + params[key] = value + it.copy( + parameters = params, + bottomSheetState = Hidden + ) + } + } + + @Parcelize data class ViewState( - private val baseUrl: String, - val parameters: Map - ) { + val targetUrl: String, + val parameters: Map, + val bottomSheetState: ParameterBottomSheetState + ) : Parcelable { + @IgnoredOnParcel val url by lazy { - parameters.joinToUrl(baseUrl) + parameters.joinToUrl(targetUrl) } val charactersRemaining: Int - get() = MAX_CHARACTERS - parameters.entries.joinToString("&").length + get() = MAX_CHARACTERS_FOR_DESTINATION_URL - parameters.entries.joinToString("&").length + + sealed interface ParameterBottomSheetState : Parcelable { + @Parcelize + data object Hidden : ParameterBottomSheetState + + @Parcelize + data class Editing( + val targetUrl: String, + val parameters: Map, + val key: String = "", + val value: String = "", + @StringRes val error: Int = 0 + ) : ParameterBottomSheetState { + @IgnoredOnParcel + val url: String by lazy { + if (key.isNotEmpty()) { + parameters + (key to value) + } else { + parameters + }.joinToUrl(targetUrl) + } + + val isSaveButtonEnabled: Boolean + get() = key.isNotEmpty() && value.isNotEmpty() && error == 0 + } + } } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/destination/BlazeCampaignCreationAdDestinationScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/destination/BlazeCampaignCreationAdDestinationScreen.kt index c0c1933770d..25cd611ab30 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/destination/BlazeCampaignCreationAdDestinationScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/destination/BlazeCampaignCreationAdDestinationScreen.kt @@ -47,7 +47,7 @@ fun BlazeCampaignCreationAdDestinationScreen(viewModel: BlazeCampaignCreationAdD viewModel::onBackPressed, viewModel::onUrlPropertyTapped, viewModel::onParameterPropertyTapped, - viewModel::onDestinationUrlChanged + viewModel::onDestinationParametersUpdated ) } } @@ -58,7 +58,7 @@ fun AdDestinationScreen( onBackPressed: () -> Unit, onUrlPropertyTapped: () -> Unit, onParametersPropertyTapped: () -> Unit, - onDestinationUrlChanged: (String) -> Unit + onTargetUrlChanged: (String) -> Unit ) { Scaffold( topBar = { @@ -78,22 +78,24 @@ fun AdDestinationScreen( ) { AdDestinationProperty( title = stringResource(id = R.string.blaze_campaign_edit_ad_destination_url_property_title), - value = viewState.destinationUrl, + value = viewState.targetUrl, onPropertyTapped = onUrlPropertyTapped ) Divider() AdDestinationProperty( title = stringResource(id = R.string.blaze_campaign_edit_ad_destination_parameters_property_title), - value = viewState.parameters, + value = viewState.joinedParameters.ifBlank { + stringResource(R.string.blaze_campaign_edit_ad_destination_empty_parameters_message) + }, onPropertyTapped = onParametersPropertyTapped ) } if (viewState.isUrlDialogVisible) { - AdDestinationUrlDialog( + TargetUrlDialog( viewState, - onDismissed = { onDestinationUrlChanged(viewState.destinationUrl) }, - onSaveTapped = onDestinationUrlChanged + onDismissed = { onTargetUrlChanged(viewState.targetUrl) }, + onSaveTapped = onTargetUrlChanged ) } } @@ -124,7 +126,7 @@ fun AdDestinationProperty(title: String, value: String, onPropertyTapped: () -> } @Composable -fun AdDestinationUrlDialog( +fun TargetUrlDialog( viewState: ViewState, onDismissed: () -> Unit, onSaveTapped: (String) -> Unit, @@ -142,30 +144,30 @@ fun AdDestinationUrlDialog( style = MaterialTheme.typography.h6 ) - var destinationUrl by rememberSaveable { - mutableStateOf(viewState.destinationUrl) + var targetUrl by rememberSaveable { + mutableStateOf(viewState.targetUrl) } UrlOption( url = viewState.productUrl, - targetUrl = destinationUrl, + targetUrl = targetUrl, title = R.string.blaze_campaign_edit_ad_destination_product_url_option ) { - destinationUrl = viewState.productUrl + targetUrl = viewState.productUrl } UrlOption( url = viewState.siteUrl, - targetUrl = destinationUrl, + targetUrl = targetUrl, title = R.string.blaze_campaign_edit_ad_destination_site_url_option ) { - destinationUrl = viewState.siteUrl + targetUrl = viewState.siteUrl } DialogButtonsRowLayout( confirmButton = { WCTextButton(onClick = { - onSaveTapped(destinationUrl) + onSaveTapped(targetUrl) }) { Text(text = stringResource(id = R.string.save)) } @@ -223,14 +225,14 @@ fun PreviewAdDestinationScreen() { viewState = ViewState( productUrl = "https://woocommerce.com/products/1", siteUrl = "https://woocommerce.com", - destinationUrl = "https://woocommerce.com/products/12", - parameters = "utm_source=woocommerce_android\nutm_medium=ad\nutm_campaign=blaze", + targetUrl = "https://woocommerce.com/products/12", + parameters = emptyMap(), isUrlDialogVisible = true ), onBackPressed = {}, onUrlPropertyTapped = {}, onParametersPropertyTapped = {}, - onDestinationUrlChanged = {} + onTargetUrlChanged = {} ) } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/destination/BlazeCampaignCreationAdDestinationViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/destination/BlazeCampaignCreationAdDestinationViewModel.kt index 8dcd72bbe2e..c8d24cfdecc 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/destination/BlazeCampaignCreationAdDestinationViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/destination/BlazeCampaignCreationAdDestinationViewModel.kt @@ -2,14 +2,11 @@ package com.woocommerce.android.ui.blaze.creation.destination import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.asLiveData -import com.woocommerce.android.R import com.woocommerce.android.tools.SelectedSite +import com.woocommerce.android.ui.blaze.BlazeRepository.DestinationParameters import com.woocommerce.android.ui.products.ProductDetailRepository -import com.woocommerce.android.util.getBaseUrl -import com.woocommerce.android.util.parseParameters import com.woocommerce.android.viewmodel.MultiLiveEvent -import com.woocommerce.android.viewmodel.MultiLiveEvent.Event.Exit -import com.woocommerce.android.viewmodel.ResourceProvider +import com.woocommerce.android.viewmodel.MultiLiveEvent.Event.ExitWithResult import com.woocommerce.android.viewmodel.ScopedViewModel import com.woocommerce.android.viewmodel.navArgs import dagger.hilt.android.lifecycle.HiltViewModel @@ -21,19 +18,17 @@ import javax.inject.Inject class BlazeCampaignCreationAdDestinationViewModel @Inject constructor( savedStateHandle: SavedStateHandle, selectedSite: SelectedSite, - productDetailRepository: ProductDetailRepository, - private val resourceProvider: ResourceProvider + productDetailRepository: ProductDetailRepository ) : ScopedViewModel(savedStateHandle) { private val navArgs: BlazeCampaignCreationAdDestinationFragmentArgs by savedStateHandle.navArgs() - private val productUrl = requireNotNull(productDetailRepository.getProduct(navArgs.productId)) - .permalink + private val productUrl = requireNotNull(productDetailRepository.getProduct(navArgs.productId)).permalink private val _viewState = MutableStateFlow( ViewState( - productUrl = productUrl.trim('/'), - siteUrl = selectedSite.get().url.trim('/'), - destinationUrl = navArgs.targetUrl.getBaseUrl() + "?a=b&c=d", - parameters = getParameters(navArgs.targetUrl), + productUrl = productUrl, + siteUrl = selectedSite.get().url, + targetUrl = navArgs.destinationParameters.targetUrl, + parameters = navArgs.destinationParameters.parameters, isUrlDialogVisible = false ) ) @@ -41,7 +36,7 @@ class BlazeCampaignCreationAdDestinationViewModel @Inject constructor( val viewState = _viewState.asLiveData() fun onBackPressed() { - triggerEvent(Exit) + triggerEvent(ExitWithResult(DestinationParameters(_viewState.value.targetUrl, _viewState.value.parameters))) } fun onUrlPropertyTapped() { @@ -50,48 +45,30 @@ class BlazeCampaignCreationAdDestinationViewModel @Inject constructor( fun onParameterPropertyTapped() { triggerEvent( - NavigateToParametersScreen(getTargetUrl(_viewState.value.destinationUrl, _viewState.value.parameters)) + NavigateToParametersScreen(DestinationParameters(_viewState.value.targetUrl, _viewState.value.parameters)) ) } - fun onTargetUrlUpdated(targetUrl: String) { + fun onDestinationParametersUpdated(targetUrl: String, parameters: Map? = null) { _viewState.update { it.copy( - destinationUrl = targetUrl.getBaseUrl(), - parameters = getParameters(targetUrl), + targetUrl = targetUrl, + parameters = parameters ?: it.parameters, + isUrlDialogVisible = false ) } } - fun onDestinationUrlChanged(destinationUrl: String) { - _viewState.value = _viewState.value.copy( - destinationUrl = destinationUrl, - isUrlDialogVisible = false - ) - } - - private fun getParameters(url: String): String { - return url.parseParameters().entries.joinToString(separator = "\n") - .ifBlank { - resourceProvider.getString(R.string.blaze_campaign_edit_ad_destination_empty_parameters_message) - } - } - - private fun getTargetUrl(baseUrl: String, parameters: String): String { - return if (parameters.isEmpty()) { - baseUrl - } else { - "$baseUrl?${parameters.replace("\n", "&")}" - } - } - data class ViewState( val productUrl: String, val siteUrl: String, - val destinationUrl: String, - val parameters: String, + val targetUrl: String, + val parameters: Map, val isUrlDialogVisible: Boolean - ) + ) { + val joinedParameters: String + get() = parameters.entries.joinToString(separator = "\n") + } - data class NavigateToParametersScreen(val url: String) : MultiLiveEvent.Event() + data class NavigateToParametersScreen(val destinationParameters: DestinationParameters) : MultiLiveEvent.Event() } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/intro/BlazeCampaignCreationIntroFragment.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/intro/BlazeCampaignCreationIntroFragment.kt index fa668cfc2d2..746889b3af1 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/intro/BlazeCampaignCreationIntroFragment.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/intro/BlazeCampaignCreationIntroFragment.kt @@ -6,6 +6,7 @@ import android.view.View import android.view.ViewGroup import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController +import androidx.navigation.navOptions import com.woocommerce.android.R import com.woocommerce.android.extensions.handleResult import com.woocommerce.android.extensions.navigateSafely @@ -41,10 +42,14 @@ class BlazeCampaignCreationIntroFragment : BaseFragment() { when (event) { is BlazeCampaignCreationIntroViewModel.ShowCampaignCreationForm -> { findNavController().navigateSafely( - BlazeCampaignCreationIntroFragmentDirections + directions = BlazeCampaignCreationIntroFragmentDirections .actionBlazeCampaignCreationIntroFragmentToBlazeCampaignCreationPreviewFragment( - productId = event.productId - ) + productId = event.productId, + source = event.source + ), + navOptions = navOptions { + popUpTo(R.id.blazeCampaignCreationIntroFragment) { inclusive = true } + } ) } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/intro/BlazeCampaignCreationIntroViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/intro/BlazeCampaignCreationIntroViewModel.kt index e05bc900361..80be40de247 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/intro/BlazeCampaignCreationIntroViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/intro/BlazeCampaignCreationIntroViewModel.kt @@ -1,6 +1,11 @@ package com.woocommerce.android.ui.blaze.creation.intro import androidx.lifecycle.SavedStateHandle +import com.woocommerce.android.analytics.AnalyticsEvent.BLAZE_ENTRY_POINT_TAPPED +import com.woocommerce.android.analytics.AnalyticsEvent.BLAZE_INTRO_DISPLAYED +import com.woocommerce.android.analytics.AnalyticsTracker +import com.woocommerce.android.analytics.AnalyticsTrackerWrapper +import com.woocommerce.android.ui.blaze.BlazeUrlsHelper.BlazeFlowSource import com.woocommerce.android.ui.products.ProductListRepository import com.woocommerce.android.ui.products.ProductStatus import com.woocommerce.android.util.CoroutineDispatchers @@ -20,11 +25,16 @@ import javax.inject.Inject class BlazeCampaignCreationIntroViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val productListRepository: ProductListRepository, - private val coroutineDispatchers: CoroutineDispatchers + private val coroutineDispatchers: CoroutineDispatchers, + private val analyticsTracker: AnalyticsTrackerWrapper, ) : ScopedViewModel(savedStateHandle) { private val navArgs: BlazeCampaignCreationIntroFragmentArgs by savedStateHandle.navArgs() fun onContinueClick() { suspend fun getPublishedProducts() = withContext(coroutineDispatchers.io) { + analyticsTracker.track( + stat = BLAZE_ENTRY_POINT_TAPPED, + properties = mapOf(AnalyticsTracker.KEY_BLAZE_SOURCE to BlazeFlowSource.INTRO_VIEW.trackingName) + ) productListRepository.getProductList( productFilterOptions = mapOf(ProductFilterOption.STATUS to ProductStatus.PUBLISH.value), sortType = ProductSorting.DATE_DESC, @@ -33,11 +43,16 @@ class BlazeCampaignCreationIntroViewModel @Inject constructor( launch { if (navArgs.productId != -1L) { - triggerEvent(ShowCampaignCreationForm(navArgs.productId)) + triggerEvent(ShowCampaignCreationForm(navArgs.productId, BlazeFlowSource.INTRO_VIEW)) } else { val products = getPublishedProducts() when { - products.size == 1 -> triggerEvent(ShowCampaignCreationForm(products.first().remoteId)) + products.size == 1 -> triggerEvent( + ShowCampaignCreationForm( + products.first().remoteId, BlazeFlowSource.INTRO_VIEW + ) + ) + products.isNotEmpty() -> triggerEvent(ShowProductSelector) else -> { WooLog.w(WooLog.T.BLAZE, "No products available to create a campaign") @@ -48,14 +63,21 @@ class BlazeCampaignCreationIntroViewModel @Inject constructor( } } + init { + analyticsTracker.track( + stat = BLAZE_INTRO_DISPLAYED, + properties = mapOf(AnalyticsTracker.KEY_BLAZE_SOURCE to navArgs.source.trackingName) + ) + } + fun onDismissClick() { triggerEvent(Exit) } fun onProductSelected(productId: Long) { - triggerEvent(ShowCampaignCreationForm(productId)) + triggerEvent(ShowCampaignCreationForm(productId, BlazeFlowSource.INTRO_VIEW)) } object ShowProductSelector : MultiLiveEvent.Event() - data class ShowCampaignCreationForm(val productId: Long) : MultiLiveEvent.Event() + data class ShowCampaignCreationForm(val productId: Long, val source: BlazeFlowSource) : MultiLiveEvent.Event() } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/payment/BlazeCampaignPaymentSummaryFragment.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/payment/BlazeCampaignPaymentSummaryFragment.kt index 62c73453f07..b56a3351a42 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/payment/BlazeCampaignPaymentSummaryFragment.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/payment/BlazeCampaignPaymentSummaryFragment.kt @@ -8,7 +8,9 @@ import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController import com.woocommerce.android.extensions.handleResult import com.woocommerce.android.extensions.navigateSafely +import com.woocommerce.android.extensions.navigateToHelpScreen import com.woocommerce.android.ui.base.BaseFragment +import com.woocommerce.android.ui.blaze.creation.payment.BlazeCampaignPaymentSummaryViewModel.NavigateToStartingScreenWithSuccessBottomSheet import com.woocommerce.android.ui.compose.composeView import com.woocommerce.android.ui.compose.theme.WooThemeWithBackground import com.woocommerce.android.ui.main.AppBarStatus @@ -39,6 +41,7 @@ class BlazeCampaignPaymentSummaryFragment : BaseFragment() { viewModel.event.observe(viewLifecycleOwner) { event -> when (event) { MultiLiveEvent.Event.Exit -> findNavController().navigateUp() + is MultiLiveEvent.Event.NavigateToHelpScreen -> navigateToHelpScreen(event.origin) is BlazeCampaignPaymentSummaryViewModel.NavigateToPaymentsListScreen -> { findNavController().navigateSafely( BlazeCampaignPaymentSummaryFragmentDirections @@ -48,6 +51,8 @@ class BlazeCampaignPaymentSummaryFragment : BaseFragment() { ) ) } + + is NavigateToStartingScreenWithSuccessBottomSheet -> navigateBackToStartingScreen() } } } @@ -57,4 +62,11 @@ class BlazeCampaignPaymentSummaryFragment : BaseFragment() { viewModel.onPaymentMethodSelected(it) } } + + private fun navigateBackToStartingScreen() { + findNavController().navigateSafely( + BlazeCampaignPaymentSummaryFragmentDirections + .actionBlazeCampaignPaymentSummaryFragmentToBlazeCampaignSuccessBottomSheetFragment() + ) + } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/payment/BlazeCampaignPaymentSummaryScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/payment/BlazeCampaignPaymentSummaryScreen.kt index fb0ee04c129..6ea2adc69ee 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/payment/BlazeCampaignPaymentSummaryScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/payment/BlazeCampaignPaymentSummaryScreen.kt @@ -1,15 +1,17 @@ package com.woocommerce.android.ui.blaze.creation.payment -import android.content.res.Configuration +import androidx.annotation.StringRes import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.ClickableText import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.ContentAlpha @@ -18,6 +20,8 @@ import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ErrorOutline import androidx.compose.runtime.Composable import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Alignment @@ -29,15 +33,19 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import com.woocommerce.android.AppUrls import com.woocommerce.android.R import com.woocommerce.android.model.CreditCardType import com.woocommerce.android.ui.blaze.BlazeRepository +import com.woocommerce.android.ui.blaze.creation.payment.BlazeCampaignPaymentSummaryViewModel.CampaignCreationState import com.woocommerce.android.ui.compose.URL_ANNOTATION_TAG import com.woocommerce.android.ui.compose.annotatedStringRes import com.woocommerce.android.ui.compose.component.Toolbar +import com.woocommerce.android.ui.compose.component.ToolbarWithHelpButton import com.woocommerce.android.ui.compose.component.WCColoredButton +import com.woocommerce.android.ui.compose.component.WCTextButton +import com.woocommerce.android.ui.compose.preview.LightDarkThemePreviews import com.woocommerce.android.ui.compose.theme.WooThemeWithBackground import com.woocommerce.android.util.ChromeCustomTabUtils import java.util.Date @@ -47,7 +55,9 @@ fun BlazeCampaignPaymentSummaryScreen(viewModel: BlazeCampaignPaymentSummaryView viewModel.viewState.observeAsState().value?.let { BlazeCampaignPaymentSummaryScreen( state = it, - onBackClick = viewModel::onBackClicked + onBackClick = viewModel::onBackClicked, + onSubmitCampaign = viewModel::onSubmitCampaign, + onHelpClick = viewModel::onHelpClicked ) } } @@ -55,80 +65,200 @@ fun BlazeCampaignPaymentSummaryScreen(viewModel: BlazeCampaignPaymentSummaryView @Composable fun BlazeCampaignPaymentSummaryScreen( state: BlazeCampaignPaymentSummaryViewModel.ViewState, - onBackClick: () -> Unit + onBackClick: () -> Unit, + onSubmitCampaign: () -> Unit, + onHelpClick: () -> Unit ) { - val context = LocalContext.current - Scaffold( topBar = { - Toolbar(onNavigationButtonClick = onBackClick) + if (state.campaignCreationState == null) { + ToolbarWithHelpButton( + onNavigationButtonClick = onBackClick, + onHelpButtonClick = onHelpClick, + ) + } else { + Toolbar(onNavigationButtonClick = onBackClick) + } }, backgroundColor = MaterialTheme.colors.surface ) { paddingValues -> + when (state.campaignCreationState) { + is CampaignCreationState.Loading -> CampaignCreationLoadingUi( + modifier = Modifier.padding(paddingValues) + ) + + is CampaignCreationState.Failed -> CampaignCreationErrorUi( + errorMessage = state.campaignCreationState.errorMessage, + onRetryClick = onSubmitCampaign, + onHelpClick = onHelpClick, + onCancelClick = onBackClick, + modifier = Modifier.padding(paddingValues) + ) + else -> PaymenSummaryContent( + state = state, + onSubmitCampaign = onSubmitCampaign, + modifier = Modifier.padding(paddingValues) + ) + } + } +} + +@Composable +private fun PaymenSummaryContent( + state: BlazeCampaignPaymentSummaryViewModel.ViewState, + onSubmitCampaign: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + + Column( + verticalArrangement = Arrangement.spacedBy(dimensionResource(id = R.dimen.major_100)), + modifier = modifier + .fillMaxSize() + .padding(vertical = dimensionResource(id = R.dimen.major_100)) + ) { + Text( + text = stringResource(id = R.string.blaze_campaign_payment_summary_title), + style = MaterialTheme.typography.h5, + modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.major_100)) + ) + + PaymentTotals( + budget = state.budget, + modifier = Modifier.fillMaxWidth() + ) + + PaymentMethod( + paymentMethodsState = state.paymentMethodsState, + selectedPaymentMethod = state.selectedPaymentMethod, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.weight(1f)) + Divider() + + WCColoredButton( + onClick = onSubmitCampaign, + text = stringResource(id = R.string.blaze_campaign_payment_summary_submit_campaign), + enabled = state.isPaymentMethodSelected, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = dimensionResource(id = R.dimen.major_100)) + ) + + val termsOfServices = annotatedStringRes( + stringResId = R.string.blaze_campaign_payment_summary_terms_and_conditions + ) + ClickableText( + text = termsOfServices, + style = MaterialTheme.typography.caption.copy( + textAlign = TextAlign.Center, + color = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.medium) + ), + onClick = { offset -> + termsOfServices.getStringAnnotations(tag = URL_ANNOTATION_TAG, start = offset, end = offset) + .firstOrNull() + ?.let { annotation -> + when (annotation.item) { + "termsOfService" -> + ChromeCustomTabUtils.launchUrl(context, AppUrls.WORPRESS_COM_TERMS) + + "advertisingPolicy" -> + ChromeCustomTabUtils.launchUrl(context, AppUrls.ADVERTISING_POLICY) + + "learnMore" -> + ChromeCustomTabUtils.launchUrl(context, AppUrls.BLAZE_SUPPORT) + } + } + }, + modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.major_100)) + ) + } +} + +@Composable +private fun CampaignCreationLoadingUi(modifier: Modifier = Modifier) { + Column( + verticalArrangement = Arrangement.spacedBy( + space = dimensionResource(id = R.dimen.major_150), + alignment = Alignment.CenterVertically + ), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier.fillMaxSize() + ) { + Image( + painter = painterResource(id = R.drawable.ic_timer), + contentDescription = null, + modifier = Modifier.size(dimensionResource(id = R.dimen.image_major_72)) + ) + Text(text = stringResource(id = R.string.blaze_campaign_creation_loading)) + CircularProgressIndicator() + } +} + +@Composable +private fun CampaignCreationErrorUi( + @StringRes errorMessage: Int, + onRetryClick: () -> Unit, + onHelpClick: () -> Unit, + onCancelClick: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(dimensionResource(id = R.dimen.major_100)) + ) { Column( verticalArrangement = Arrangement.spacedBy(dimensionResource(id = R.dimen.major_100)), - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - .padding(vertical = dimensionResource(id = R.dimen.major_100)) + modifier = Modifier.weight(1f) ) { - Text( - text = stringResource(id = R.string.blaze_campaign_payment_summary_title), - style = MaterialTheme.typography.h5, - modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.major_100)) + Icon( + imageVector = Icons.Default.ErrorOutline, + contentDescription = null, + tint = MaterialTheme.colors.error, + modifier = Modifier.size(dimensionResource(id = R.dimen.image_major_64)) ) - PaymentTotals( - budget = state.budget, - modifier = Modifier.fillMaxWidth() + Text( + text = stringResource(id = R.string.blaze_campaign_creation_error_title), + style = MaterialTheme.typography.h5 ) - PaymentMethod( - paymentMethodsState = state.paymentMethodsState, - selectedPaymentMethod = state.selectedPaymentMethod, - modifier = Modifier.fillMaxWidth() + Text( + text = stringResource(errorMessage), + style = MaterialTheme.typography.body2 ) - Spacer(modifier = Modifier.weight(1f)) - Divider() - - WCColoredButton( - onClick = { /*TODO*/ }, - text = stringResource(id = R.string.blaze_campaign_payment_summary_submit_campaign), - enabled = state.isPaymentMethodSelected, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = dimensionResource(id = R.dimen.major_100)) + Text( + text = stringResource(id = R.string.blaze_campaign_creation_error_payment_hint), + style = MaterialTheme.typography.subtitle1, ) - val termsOfServices = annotatedStringRes( - stringResId = R.string.blaze_campaign_payment_summary_terms_and_conditions + Text( + text = stringResource(id = R.string.blaze_campaign_creation_error_help_hint), + style = MaterialTheme.typography.body2, ) - ClickableText( - text = termsOfServices, - style = MaterialTheme.typography.caption.copy( - textAlign = TextAlign.Center, - color = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.medium) - ), - onClick = { offset -> - termsOfServices.getStringAnnotations(tag = URL_ANNOTATION_TAG, start = offset, end = offset) - .firstOrNull() - ?.let { annotation -> - when (annotation.item) { - "termsOfService" -> - ChromeCustomTabUtils.launchUrl(context, AppUrls.WORPRESS_COM_TERMS) - - "advertisingPolicy" -> - ChromeCustomTabUtils.launchUrl(context, AppUrls.ADVERTISING_POLICY) - - "learnMore" -> - ChromeCustomTabUtils.launchUrl(context, AppUrls.BLAZE_SUPPORT) - } - } - }, - modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.major_100)) + + WCTextButton( + onClick = onHelpClick, + text = stringResource(id = R.string.blaze_campaign_creation_error_get_support), + icon = ImageVector.vectorResource(id = R.drawable.ic_help_24dp), + allCaps = false, + contentPadding = PaddingValues(vertical = dimensionResource(id = R.dimen.minor_100)) ) } + + WCColoredButton( + onClick = onRetryClick, + text = stringResource(id = R.string.try_again), + modifier = Modifier.fillMaxWidth() + ) + WCTextButton( + onClick = onCancelClick, + text = stringResource(id = R.string.blaze_campaign_creation_error_cancel), + modifier = Modifier.fillMaxWidth() + ) } } @@ -270,10 +400,9 @@ private fun PaymentMethodInfo( } } -@Preview(name = "dark", uiMode = Configuration.UI_MODE_NIGHT_YES) -@Preview(name = "light", uiMode = Configuration.UI_MODE_NIGHT_NO) +@LightDarkThemePreviews @Composable -fun BlazeCampaignPaymentSummaryScreenPreview() { +private fun BlazeCampaignPaymentSummaryScreenPreview() { WooThemeWithBackground { BlazeCampaignPaymentSummaryScreen( state = BlazeCampaignPaymentSummaryViewModel.ViewState( @@ -306,7 +435,31 @@ fun BlazeCampaignPaymentSummaryScreenPreview() { ), selectedPaymentMethodId = "1" ), - onBackClick = {} + onBackClick = {}, + onSubmitCampaign = {}, + onHelpClick = {} + ) + } +} + +@LightDarkThemePreviews +@Composable +private fun BlazeCampaignCreationLoadingPreview() { + WooThemeWithBackground { + CampaignCreationLoadingUi(modifier = Modifier.size(width = 360.dp, height = 640.dp)) + } +} + +@LightDarkThemePreviews +@Composable +private fun BlazeCampaignCreationErrorPreview() { + WooThemeWithBackground { + CampaignCreationErrorUi( + errorMessage = R.string.error_generic, + onRetryClick = {}, + onHelpClick = {}, + onCancelClick = {}, + modifier = Modifier.size(width = 360.dp, height = 640.dp) ) } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/payment/BlazeCampaignPaymentSummaryViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/payment/BlazeCampaignPaymentSummaryViewModel.kt index 4a17fe69161..aede9bd1773 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/payment/BlazeCampaignPaymentSummaryViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/payment/BlazeCampaignPaymentSummaryViewModel.kt @@ -1,8 +1,11 @@ package com.woocommerce.android.ui.blaze.creation.payment +import androidx.annotation.StringRes import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.asLiveData import androidx.lifecycle.viewModelScope +import com.woocommerce.android.R +import com.woocommerce.android.support.help.HelpOrigin import com.woocommerce.android.ui.blaze.BlazeRepository import com.woocommerce.android.ui.blaze.BlazeRepository.PaymentMethodsData import com.woocommerce.android.viewmodel.MultiLiveEvent @@ -28,15 +31,18 @@ class BlazeCampaignPaymentSummaryViewModel @Inject constructor( key = "selectedPaymentMethodId" ) private val paymentMethodsState = MutableStateFlow(PaymentMethodsState.Loading) + private val campaignCreationState = MutableStateFlow(null) val viewState = combine( selectedPaymentMethodId, - paymentMethodsState - ) { selectedPaymentMethodId, paymentMethodState -> + paymentMethodsState, + campaignCreationState + ) { selectedPaymentMethodId, paymentMethodState, campaignCreationState -> ViewState( - budget = navArgs.budget, + budget = navArgs.campaignDetails.budget, paymentMethodsState = paymentMethodState, - selectedPaymentMethodId = selectedPaymentMethodId + selectedPaymentMethodId = selectedPaymentMethodId, + campaignCreationState = campaignCreationState ) }.asLiveData() @@ -48,6 +54,10 @@ class BlazeCampaignPaymentSummaryViewModel @Inject constructor( triggerEvent(MultiLiveEvent.Event.Exit) } + fun onHelpClicked() { + triggerEvent(MultiLiveEvent.Event.NavigateToHelpScreen(HelpOrigin.BLAZE_CAMPAIGN_CREATION)) + } + fun onPaymentMethodSelected(paymentMethodId: String) { selectedPaymentMethodId.value = paymentMethodId @@ -87,10 +97,40 @@ class BlazeCampaignPaymentSummaryViewModel @Inject constructor( } } + fun onSubmitCampaign() { + if (campaignCreationState.value == CampaignCreationState.Loading) { + return + } + + launch { + campaignCreationState.value = CampaignCreationState.Loading + blazeRepository.createCampaign( + campaignDetails = navArgs.campaignDetails, + paymentMethodId = requireNotNull(selectedPaymentMethodId.value) + ).fold( + onSuccess = { + campaignCreationState.value = null + triggerEvent(NavigateToStartingScreenWithSuccessBottomSheet) + }, + onFailure = { + val errorMessage = when (it) { + is BlazeRepository.CampaignCreationError.MediaUploadError -> + R.string.blaze_campaign_creation_error_media_upload + is BlazeRepository.CampaignCreationError.MediaFetchError -> + R.string.blaze_campaign_creation_error_media_fetch + else -> R.string.blaze_campaign_creation_error + } + campaignCreationState.value = CampaignCreationState.Failed(errorMessage) + } + ) + } + } + data class ViewState( val budget: BlazeRepository.Budget, val paymentMethodsState: PaymentMethodsState, - private val selectedPaymentMethodId: String? + private val selectedPaymentMethodId: String?, + val campaignCreationState: CampaignCreationState? = null ) { private val paymentMethodsData get() = (paymentMethodsState as? PaymentMethodsState.Success)?.paymentMethodsData @@ -112,8 +152,15 @@ class BlazeCampaignPaymentSummaryViewModel @Inject constructor( data class Error(val onRetry: () -> Unit) : PaymentMethodsState } + sealed interface CampaignCreationState { + data object Loading : CampaignCreationState + data class Failed(@StringRes val errorMessage: Int) : CampaignCreationState + } + data class NavigateToPaymentsListScreen( val paymentMethodsData: PaymentMethodsData, val selectedPaymentMethodId: String? ) : MultiLiveEvent.Event() + + object NavigateToStartingScreenWithSuccessBottomSheet : MultiLiveEvent.Event() } 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 de79c3f177b..b12122d71a8 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 @@ -8,11 +8,14 @@ import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController import com.woocommerce.android.extensions.handleResult import com.woocommerce.android.extensions.navigateSafely +import com.woocommerce.android.extensions.navigateToHelpScreen import com.woocommerce.android.ui.base.BaseFragment import com.woocommerce.android.ui.blaze.BlazeRepository.Budget +import com.woocommerce.android.ui.blaze.BlazeRepository.DestinationParameters import com.woocommerce.android.ui.blaze.creation.ad.BlazeCampaignCreationEditAdFragment 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.preview.BlazeCampaignCreationPreviewViewModel.NavigateToAdDestinationScreen import com.woocommerce.android.ui.blaze.creation.preview.BlazeCampaignCreationPreviewViewModel.NavigateToBudgetScreen import com.woocommerce.android.ui.blaze.creation.preview.BlazeCampaignCreationPreviewViewModel.NavigateToEditAdScreen @@ -25,6 +28,7 @@ import com.woocommerce.android.ui.blaze.creation.targets.BlazeCampaignTargetSele import com.woocommerce.android.ui.blaze.creation.targets.BlazeCampaignTargetSelectionViewModel.TargetSelectionResult 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.Exit import dagger.hilt.android.AndroidEntryPoint @@ -51,18 +55,24 @@ class BlazeCampaignCreationPreviewFragment : BaseFragment() { viewModel.event.observe(viewLifecycleOwner) { event -> when (event) { is Exit -> findNavController().popBackStack() + + is MultiLiveEvent.Event.NavigateToHelpScreen -> navigateToHelpScreen(event.origin) + is NavigateToBudgetScreen -> findNavController().navigateSafely( BlazeCampaignCreationPreviewFragmentDirections - .actionBlazeCampaignCreationPreviewFragmentToBlazeCampaignBudgetFragment(event.budget) + .actionBlazeCampaignCreationPreviewFragmentToBlazeCampaignBudgetFragment( + budget = event.budget, + targetingParameters = event.targetingParameters + ) ) is NavigateToEditAdScreen -> findNavController().navigateSafely( BlazeCampaignCreationPreviewFragmentDirections .actionBlazeCampaignCreationPreviewFragmentToBlazeCampaignCreationEditAdFragment( - event.productId, - event.tagLine, - event.description, - event.campaignImageUrl + productId = event.productId, + tagline = event.tagLine, + description = event.description, + adImage = event.campaignImage ) ) @@ -84,14 +94,14 @@ class BlazeCampaignCreationPreviewFragment : BaseFragment() { is NavigateToAdDestinationScreen -> findNavController().navigateSafely( BlazeCampaignCreationPreviewFragmentDirections .actionBlazeCampaignCreationPreviewFragmentToBlazeCampaignCreationAdDestinationFragment( - event.targetUrl, - event.productId + event.productId, + event.destinationParameters ) ) is NavigateToPaymentSummary -> findNavController().navigateSafely( BlazeCampaignCreationPreviewFragmentDirections .actionBlazeCampaignCreationPreviewFragmentToBlazeCampaignPaymentSummaryFragment( - event.budget + event.campaignDetails ) ) } @@ -100,7 +110,7 @@ class BlazeCampaignCreationPreviewFragment : BaseFragment() { private fun handleResults() { handleResult(BlazeCampaignCreationEditAdFragment.EDIT_AD_RESULT) { - viewModel.onAdUpdated(it.tagline, it.description, it.campaignImageUrl) + viewModel.onAdUpdated(it.tagline, it.description, it.campaignImage) } handleResult(BlazeCampaignBudgetFragment.EDIT_BUDGET_AND_DURATION_RESULT) { viewModel.onBudgetAndDurationUpdated(it) @@ -111,5 +121,8 @@ class BlazeCampaignCreationPreviewFragment : BaseFragment() { handleResult(BlazeCampaignTargetLocationSelectionFragment.BLAZE_TARGET_LOCATION_RESULT) { viewModel.onTargetLocationsUpdated(it.locations) } + handleResult(BlazeCampaignCreationAdDestinationFragment.BLAZE_DESTINATION_RESULT) { + viewModel.onDestinationUpdated(it) + } } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/preview/BlazeCampaignCreationPreviewScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/preview/BlazeCampaignCreationPreviewScreen.kt index f71271b68f4..6d7333c4517 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/preview/BlazeCampaignCreationPreviewScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/preview/BlazeCampaignCreationPreviewScreen.kt @@ -47,8 +47,9 @@ import com.woocommerce.android.ui.blaze.creation.preview.BlazeCampaignCreationPr import com.woocommerce.android.ui.blaze.creation.preview.BlazeCampaignCreationPreviewViewModel.CampaignDetailItemUi import com.woocommerce.android.ui.blaze.creation.preview.BlazeCampaignCreationPreviewViewModel.CampaignDetailsUi import com.woocommerce.android.ui.blaze.creation.preview.BlazeCampaignCreationPreviewViewModel.CampaignPreviewUiState +import com.woocommerce.android.ui.compose.Render import com.woocommerce.android.ui.compose.animations.SkeletonView -import com.woocommerce.android.ui.compose.component.Toolbar +import com.woocommerce.android.ui.compose.component.ToolbarWithHelpButton import com.woocommerce.android.ui.compose.component.WCColoredButton import com.woocommerce.android.ui.compose.component.WCTextButton import com.woocommerce.android.ui.compose.preview.LightDarkThemePreviews @@ -60,7 +61,8 @@ fun BlazeCampaignCreationPreviewScreen(viewModel: BlazeCampaignCreationPreviewVi previewState = previewState, onBackPressed = viewModel::onBackPressed, onEditAdClicked = viewModel::onEditAdClicked, - onConfirmDetailsClicked = viewModel::onConfirmClicked + onConfirmDetailsClicked = viewModel::onConfirmClicked, + onHelpTapped = viewModel::onHelpTapped ) } } @@ -70,13 +72,15 @@ private fun BlazeCampaignCreationPreviewScreen( previewState: CampaignPreviewUiState, onBackPressed: () -> Unit, onEditAdClicked: () -> Unit, - onConfirmDetailsClicked: () -> Unit + onConfirmDetailsClicked: () -> Unit, + onHelpTapped: () -> Unit ) { Scaffold( topBar = { - Toolbar( + ToolbarWithHelpButton( title = stringResource(id = R.string.blaze_campaign_screen_fragment_title), onNavigationButtonClick = onBackPressed, + onHelpButtonClick = onHelpTapped, navigationIcon = Filled.ArrowBack ) }, @@ -117,6 +121,8 @@ private fun BlazeCampaignCreationPreviewScreen( ) } } + + previewState.dialogState?.Render() } @Composable @@ -413,7 +419,8 @@ fun CampaignScreenPreview() { ), onBackPressed = { }, onEditAdClicked = { }, - onConfirmDetailsClicked = { } + onConfirmDetailsClicked = { }, + onHelpTapped = { } ) } 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 e5bb642f0f5..155f466ea86 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 @@ -1,41 +1,36 @@ package com.woocommerce.android.ui.blaze.creation.preview -import android.os.Parcelable import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.asLiveData import androidx.lifecycle.viewModelScope +import com.woocommerce.android.R import com.woocommerce.android.R.string -import com.woocommerce.android.extensions.combine import com.woocommerce.android.extensions.formatToMMMdd +import com.woocommerce.android.support.help.HelpOrigin import com.woocommerce.android.ui.blaze.BlazeRepository -import com.woocommerce.android.ui.blaze.BlazeRepository.Budget -import com.woocommerce.android.ui.blaze.BlazeRepository.CampaignPreview -import com.woocommerce.android.ui.blaze.BlazeRepository.Companion.CAMPAIGN_MINIMUM_DAILY_SPEND -import com.woocommerce.android.ui.blaze.BlazeRepository.Companion.DEFAULT_CAMPAIGN_DURATION -import com.woocommerce.android.ui.blaze.Device -import com.woocommerce.android.ui.blaze.Interest -import com.woocommerce.android.ui.blaze.Language +import com.woocommerce.android.ui.blaze.BlazeRepository.CampaignDetails +import com.woocommerce.android.ui.blaze.BlazeRepository.DestinationParameters import com.woocommerce.android.ui.blaze.Location -import com.woocommerce.android.ui.blaze.creation.preview.BlazeCampaignCreationPreviewViewModel.AdDetailsUi.AdDetails -import com.woocommerce.android.ui.blaze.creation.preview.BlazeCampaignCreationPreviewViewModel.AdDetailsUi.Loading import com.woocommerce.android.ui.blaze.creation.targets.BlazeTargetType import com.woocommerce.android.ui.blaze.creation.targets.BlazeTargetType.DEVICE import com.woocommerce.android.ui.blaze.creation.targets.BlazeTargetType.INTEREST import com.woocommerce.android.ui.blaze.creation.targets.BlazeTargetType.LANGUAGE +import com.woocommerce.android.ui.compose.DialogState import com.woocommerce.android.util.CurrencyFormatter import com.woocommerce.android.viewmodel.MultiLiveEvent import com.woocommerce.android.viewmodel.ResourceProvider 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.MutableStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.parcelize.Parcelize -import java.util.Date import javax.inject.Inject -import kotlin.time.Duration.Companion.days @HiltViewModel class BlazeCampaignCreationPreviewViewModel @Inject constructor( @@ -45,66 +40,33 @@ class BlazeCampaignCreationPreviewViewModel @Inject constructor( private val currencyFormatter: CurrencyFormatter ) : ScopedViewModel(savedStateHandle) { private val navArgs: BlazeCampaignCreationPreviewFragmentArgs by savedStateHandle.navArgs() - private suspend fun getCampaign() = blazeRepository.getCampaignPreviewDetails(navArgs.productId) - - private val adDetails = savedStateHandle.getStateFlow(viewModelScope, Loading) - private val budget = savedStateHandle.getStateFlow(viewModelScope, getDefaultBudget()) - - private val languages = blazeRepository.observeLanguages() - private val devices = blazeRepository.observeDevices() - private val interests = blazeRepository.observeInterests() - - private val selectedLanguageCodes = savedStateHandle.getStateFlow>( - scope = viewModelScope, - initialValue = emptyList(), - key = "selectedLanguages" - ) - - private val selectedLanguages = combine(languages, selectedLanguageCodes) { languages, selectedCodes -> - languages.filter { it.code in selectedCodes } - } - - private val selectedDeviceIds = savedStateHandle.getStateFlow>( - scope = viewModelScope, - initialValue = emptyList(), - key = "selectedDevices" - ) - - private val selectedDevices = combine(devices, selectedDeviceIds) { devices, selectedIds -> - devices.filter { it.id in selectedIds } - } - private val selectedInterestIds = savedStateHandle.getStateFlow>( + private val campaignDetails = savedStateHandle.getNullableStateFlow( scope = viewModelScope, - initialValue = emptyList(), - key = "selectedInterests" + key = "campaignDetails", + initialValue = null, + clazz = CampaignDetails::class.java ) - private val selectedInterests = combine(interests, selectedInterestIds) { interests, selectedIds -> - interests.filter { it.id in selectedIds } - } - - private val selectedLocations = savedStateHandle.getStateFlow>( - scope = viewModelScope, - initialValue = emptyList() - ) + private val adDetailsState = savedStateHandle.getStateFlow(viewModelScope, AdDetailsUiState.LOADING) + private val dialogState = MutableStateFlow(null) val viewState = combine( - adDetails, - budget, - selectedLanguages, - selectedDevices, - selectedInterests, - selectedLocations - ) { ad, budget, selectedLanguages, selectedDevices, selectedInterests, selectedLocations -> + campaignDetails.filterNotNull(), + adDetailsState, + dialogState + ) { campaignDetails, adDetailsState, dialogState -> CampaignPreviewUiState( - adDetails = ad, - campaignDetails = getCampaign().toCampaignDetailsUi( - budget, - selectedLanguages, - selectedDevices, - selectedInterests, - selectedLocations - ) + adDetails = when (adDetailsState) { + AdDetailsUiState.LOADING -> AdDetailsUi.Loading + AdDetailsUiState.LOADED -> AdDetailsUi.AdDetails( + productId = navArgs.productId, + description = campaignDetails.description, + tagLine = campaignDetails.tagLine, + campaignImageUrl = campaignDetails.campaignImage.uri + ) + }, + campaignDetails = campaignDetails.toCampaignDetailsUi(), + dialogState = dialogState ) }.asLiveData() @@ -116,131 +78,207 @@ class BlazeCampaignCreationPreviewViewModel @Inject constructor( triggerEvent(MultiLiveEvent.Event.Exit) } + fun onHelpTapped() { + triggerEvent(MultiLiveEvent.Event.NavigateToHelpScreen(HelpOrigin.BLAZE_CAMPAIGN_CREATION)) + } + fun onEditAdClicked() { - (adDetails.value as? AdDetails)?.let { + campaignDetails.value?.let { triggerEvent( NavigateToEditAdScreen( productId = navArgs.productId, tagLine = it.tagLine, description = it.description, - campaignImageUrl = it.campaignImageUrl + campaignImage = it.campaignImage ) ) } } - fun onAdUpdated(tagline: String, description: String, campaignImageUrl: String?) { - adDetails.update { - AdDetails( - productId = navArgs.productId, - description = description, + fun onAdUpdated(tagline: String, description: String, campaignImage: BlazeRepository.BlazeCampaignImage) { + campaignDetails.update { + it?.copy( tagLine = tagline, - campaignImageUrl = campaignImageUrl + description = description, + campaignImage = campaignImage ) } } - fun onBudgetAndDurationUpdated(updatedBudget: Budget) { - budget.update { updatedBudget } + fun onBudgetAndDurationUpdated(updatedBudget: BlazeRepository.Budget) { + campaignDetails.update { it?.copy(budget = updatedBudget) } } fun onTargetSelectionUpdated(targetType: BlazeTargetType, selectedIds: List) { launch { when (targetType) { - LANGUAGE -> selectedLanguageCodes.update { selectedIds } - DEVICE -> selectedDeviceIds.update { selectedIds } - INTEREST -> selectedInterestIds.update { selectedIds } + LANGUAGE -> blazeRepository.observeLanguages().first().let { languages -> + val selectedLanguages = languages.filter { selectedIds.contains(it.code) } + campaignDetails.update { + it?.copy(targetingParameters = it.targetingParameters.copy(languages = selectedLanguages)) + } + } + + DEVICE -> blazeRepository.observeDevices().first().let { devices -> + val selectedDevices = devices.filter { selectedIds.contains(it.id) } + campaignDetails.update { + it?.copy(targetingParameters = it.targetingParameters.copy(devices = selectedDevices)) + } + } + + INTEREST -> blazeRepository.observeInterests().first().let { interests -> + val selectedInterests = interests.filter { selectedIds.contains(it.id) } + campaignDetails.update { + it?.copy(targetingParameters = it.targetingParameters.copy(interests = selectedInterests)) + } + } + else -> Unit } } } fun onTargetLocationsUpdated(locations: List) { - selectedLocations.update { locations } + campaignDetails.update { + it?.copy(targetingParameters = it.targetingParameters.copy(locations = locations)) + } + } + + fun onDestinationUpdated(destinationParameters: DestinationParameters) { + campaignDetails.update { it?.copy(destinationParameters = destinationParameters) } } fun onConfirmClicked() { - triggerEvent(NavigateToPaymentSummary(budget.value)) + campaignDetails.value?.let { + val isImageMissing = it.campaignImage is BlazeRepository.BlazeCampaignImage.None + val isContentMissing = it.tagLine.isEmpty() || it.description.isEmpty() + if (isImageMissing || isContentMissing) { + dialogState.value = DialogState( + message = if (isImageMissing) R.string.blaze_campaign_preview_missing_image_dialog_text + else R.string.blaze_campaign_preview_missing_content_dialog_text, + positiveButton = DialogState.DialogButton( + text = if (isImageMissing) R.string.blaze_campaign_preview_missing_image_dialog_positive_button + else R.string.blaze_campaign_preview_missing_content_dialog_positive_button, + onClick = { + dialogState.value = null + onEditAdClicked() + } + ), + negativeButton = DialogState.DialogButton( + text = R.string.cancel, + onClick = { dialogState.value = null } + ) + ) + return + } + + triggerEvent(NavigateToPaymentSummary(it)) + } } private fun loadData() { launch { + if (campaignDetails.value == null) { + launch { campaignDetails.value = blazeRepository.generateDefaultCampaignDetails(navArgs.productId) } + } + blazeRepository.fetchLanguages() blazeRepository.fetchDevices() blazeRepository.fetchInterests() blazeRepository.fetchAdSuggestions(navArgs.productId).getOrNull().let { suggestions -> - adDetails.update { - AdDetails( - productId = navArgs.productId, - description = suggestions?.firstOrNull()?.description ?: "", - tagLine = suggestions?.firstOrNull()?.tagLine ?: "", - campaignImageUrl = getCampaign().campaignImageUrl + adDetailsState.value = AdDetailsUiState.LOADED + campaignDetails.update { + it?.copy( + tagLine = suggestions?.firstOrNull()?.tagLine.orEmpty(), + description = suggestions?.firstOrNull()?.description.orEmpty() ) } } } } - private fun CampaignPreview.toCampaignDetailsUi( - budget: Budget, - languages: List, - devices: List, - interests: List, - locations: List - ) = CampaignDetailsUi( - budget = CampaignDetailItemUi( + private fun CampaignDetails.toCampaignDetailsUi() = CampaignDetailsUi( + budget = getBudgetDetails(), + targetDetails = listOf( + getTargetLanguagesDetails(), + getTargetDevicesDetails(), + getTargetLocationsDetails(), + getTargetInterestsDetails(), + ), + destinationUrl = getTargetDestinationDetails() + ) + + private fun CampaignDetails.getBudgetDetails() = + CampaignDetailItemUi( displayTitle = resourceProvider.getString(string.blaze_campaign_preview_details_budget), displayValue = budget.toDisplayValue(), onItemSelected = { - triggerEvent(NavigateToBudgetScreen(budget)) + triggerEvent(NavigateToBudgetScreen(budget, targetingParameters)) }, - ), - targetDetails = listOf( - CampaignDetailItemUi( - displayTitle = resourceProvider.getString(string.blaze_campaign_preview_details_language), - displayValue = languages.joinToString { it.name } - .ifEmpty { resourceProvider.getString(string.blaze_campaign_preview_target_default_value) }, - onItemSelected = { - triggerEvent(NavigateToTargetSelectionScreen(LANGUAGE, languages.map { it.code })) - }, - ), - CampaignDetailItemUi( - displayTitle = resourceProvider.getString(string.blaze_campaign_preview_details_devices), - displayValue = devices.joinToString { it.name } - .ifEmpty { resourceProvider.getString(string.blaze_campaign_preview_target_default_value) }, - onItemSelected = { - triggerEvent(NavigateToTargetSelectionScreen(DEVICE, devices.map { it.id })) - }, - ), - CampaignDetailItemUi( - displayTitle = resourceProvider.getString(string.blaze_campaign_preview_details_location), - displayValue = locations.joinToString { it.name } - .ifEmpty { resourceProvider.getString(string.blaze_campaign_preview_target_default_value) }, - onItemSelected = { - triggerEvent(NavigateToTargetLocationSelectionScreen(locations)) - }, - ), - CampaignDetailItemUi( - displayTitle = resourceProvider.getString(string.blaze_campaign_preview_details_interests), - displayValue = interests.joinToString { it.description } - .ifEmpty { resourceProvider.getString(string.blaze_campaign_preview_target_default_value) }, - onItemSelected = { - triggerEvent(NavigateToTargetSelectionScreen(INTEREST, interests.map { it.id })) - }, - ), - ), - destinationUrl = CampaignDetailItemUi( + ) + + private fun CampaignDetails.getTargetDestinationDetails() = + CampaignDetailItemUi( displayTitle = resourceProvider.getString(string.blaze_campaign_preview_details_destination_url), - displayValue = targetUrl, + displayValue = destinationParameters.fullUrl, maxLinesValue = 1, onItemSelected = { - triggerEvent(NavigateToAdDestinationScreen(targetUrl, navArgs.productId)) + triggerEvent( + NavigateToAdDestinationScreen( + productId = navArgs.productId, + destinationParameters = destinationParameters + ) + ) } ) - ) - private fun Budget.toDisplayValue(): String { + private fun CampaignDetails.getTargetInterestsDetails() = + CampaignDetailItemUi( + displayTitle = resourceProvider.getString(string.blaze_campaign_preview_details_interests), + displayValue = targetingParameters.interests.joinToString { it.description } + .ifEmpty { resourceProvider.getString(string.blaze_campaign_preview_target_default_value) }, + onItemSelected = { + triggerEvent(NavigateToTargetSelectionScreen(INTEREST, targetingParameters.interests.map { it.id })) + }, + ) + + private fun CampaignDetails.getTargetLocationsDetails() = + CampaignDetailItemUi( + displayTitle = resourceProvider.getString(string.blaze_campaign_preview_details_location), + displayValue = targetingParameters.locations.joinToString { it.name } + .ifEmpty { resourceProvider.getString(string.blaze_campaign_preview_target_default_value) }, + onItemSelected = { + triggerEvent(NavigateToTargetLocationSelectionScreen(targetingParameters.locations)) + }, + ) + + private fun CampaignDetails.getTargetDevicesDetails() = + CampaignDetailItemUi( + displayTitle = resourceProvider.getString(string.blaze_campaign_preview_details_devices), + displayValue = targetingParameters.devices.joinToString { it.name } + .ifEmpty { resourceProvider.getString(string.blaze_campaign_preview_target_default_value) }, + onItemSelected = { + triggerEvent(NavigateToTargetSelectionScreen(DEVICE, targetingParameters.devices.map { it.id })) + }, + ) + + private fun CampaignDetails.getTargetLanguagesDetails() = + CampaignDetailItemUi( + displayTitle = resourceProvider.getString(string.blaze_campaign_preview_details_language), + displayValue = targetingParameters.languages.joinToString { it.name } + .ifEmpty { resourceProvider.getString(string.blaze_campaign_preview_target_default_value) }, + onItemSelected = { + triggerEvent( + NavigateToTargetSelectionScreen( + targetType = LANGUAGE, + selectedIds = targetingParameters.languages.map { it.code } + ) + ) + }, + ) + + private fun BlazeRepository.Budget.toDisplayValue(): String { val totalBudgetWithCurrency = currencyFormatter.formatCurrency( totalBudget.toBigDecimal(), currencyCode @@ -250,27 +288,23 @@ class BlazeCampaignCreationPreviewViewModel @Inject constructor( durationInDays, startDate.formatToMMMdd() ) - return "$totalBudgetWithCurrency, $duration" + return "$totalBudgetWithCurrency, $duration" } - private fun getDefaultBudget() = Budget( - totalBudget = DEFAULT_CAMPAIGN_DURATION * CAMPAIGN_MINIMUM_DAILY_SPEND, - spentBudget = 0f, - currencyCode = BlazeRepository.BLAZE_DEFAULT_CURRENCY_CODE, - durationInDays = DEFAULT_CAMPAIGN_DURATION, - startDate = Date().apply { time += 1.days.inWholeMilliseconds }, // By default start tomorrow - ) - data class CampaignPreviewUiState( val adDetails: AdDetailsUi, val campaignDetails: CampaignDetailsUi, + val dialogState: DialogState? = null ) - sealed interface AdDetailsUi : Parcelable { - @Parcelize - object Loading : AdDetailsUi + enum class AdDetailsUiState { + LOADING, + LOADED + } + + sealed interface AdDetailsUi { + data object Loading : AdDetailsUi - @Parcelize data class AdDetails( val productId: Long, val description: String, @@ -293,12 +327,13 @@ class BlazeCampaignCreationPreviewViewModel @Inject constructor( ) data class NavigateToBudgetScreen( - val budget: Budget + val budget: BlazeRepository.Budget, + val targetingParameters: BlazeRepository.TargetingParameters ) : MultiLiveEvent.Event() data class NavigateToAdDestinationScreen( - val targetUrl: String, - val productId: Long + val productId: Long, + val destinationParameters: BlazeRepository.DestinationParameters ) : MultiLiveEvent.Event() data class NavigateToTargetSelectionScreen( @@ -314,11 +349,10 @@ class BlazeCampaignCreationPreviewViewModel @Inject constructor( val productId: Long, val tagLine: String, val description: String, - val campaignImageUrl: String? + val campaignImage: BlazeRepository.BlazeCampaignImage ) : MultiLiveEvent.Event() - // TODO we need to pass more details to use in the campaign creation data class NavigateToPaymentSummary( - val budget: BlazeRepository.Budget + val campaignDetails: CampaignDetails ) : MultiLiveEvent.Event() } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/success/BlazeCampaignSuccessBottomSheet.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/success/BlazeCampaignSuccessBottomSheet.kt new file mode 100644 index 00000000000..7c28e360b0d --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/success/BlazeCampaignSuccessBottomSheet.kt @@ -0,0 +1,82 @@ +package com.woocommerce.android.ui.blaze.creation.success + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource +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 com.woocommerce.android.R.dimen +import com.woocommerce.android.R.drawable +import com.woocommerce.android.R.string +import com.woocommerce.android.ui.compose.component.BottomSheetHandle +import com.woocommerce.android.ui.compose.component.WCColoredButton +import com.woocommerce.android.ui.compose.preview.LightDarkThemePreviews + +@Composable +fun BlazeCampaignSuccessBottomSheet( + onDoneTapped: () -> Unit, + modifier: Modifier = Modifier +) { + Surface( + shape = RoundedCornerShape( + topStart = dimensionResource(id = dimen.minor_100), + topEnd = dimensionResource(id = dimen.minor_100) + ) + ) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(8.dp)) + BottomSheetHandle(Modifier.align(Alignment.CenterHorizontally)) + Spacer(modifier = Modifier.height(30.dp)) + Image( + painter = painterResource(id = drawable.blaze_campaign_created_success), + contentDescription = "" + ) + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = stringResource(id = string.blaze_campaign_created_success_title), + style = MaterialTheme.typography.h6, + color = MaterialTheme.colors.onSurface + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + modifier = Modifier.padding(horizontal = 20.dp), + text = stringResource(id = string.blaze_campaign_created_success_description), + style = MaterialTheme.typography.body1, + textAlign = TextAlign.Center, + color = MaterialTheme.colors.onSurface + ) + Spacer(modifier = Modifier.height(32.dp)) + WCColoredButton( + onClick = onDoneTapped, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = stringResource(id = string.blaze_campaign_created_success_done_button)) + } + Spacer(modifier = Modifier.height(16.dp)) + } + } +} + +@LightDarkThemePreviews +@Composable +private fun BlazeCampaignSuccessBottomSheetPreview() { + BlazeCampaignSuccessBottomSheet(onDoneTapped = {}) +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/success/BlazeCampaignSuccessBottomSheetFragment.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/success/BlazeCampaignSuccessBottomSheetFragment.kt new file mode 100644 index 00000000000..552e2c308a1 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/success/BlazeCampaignSuccessBottomSheetFragment.kt @@ -0,0 +1,22 @@ +package com.woocommerce.android.ui.blaze.creation.success + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.woocommerce.android.ui.compose.composeView +import com.woocommerce.android.widgets.WCBottomSheetDialogFragment +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class BlazeCampaignSuccessBottomSheetFragment : WCBottomSheetDialogFragment() { + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + return composeView { + BlazeCampaignSuccessBottomSheet(::onDoneClicked) + } + } + + private fun onDoneClicked() { + dismiss() + } +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/compose/DialogState.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/compose/DialogState.kt new file mode 100644 index 00000000000..5b374520617 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/compose/DialogState.kt @@ -0,0 +1,74 @@ +package com.woocommerce.android.ui.compose + +import androidx.annotation.StringRes +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.window.DialogProperties +import com.woocommerce.android.model.UiString +import com.woocommerce.android.ui.compose.component.AlertDialog +import com.woocommerce.android.ui.compose.component.WCTextButton +import com.woocommerce.android.ui.compose.component.getText + +data class DialogState( + val title: UiString? = null, + val message: UiString? = null, + val positiveButton: DialogButton? = null, + val negativeButton: DialogButton? = null, + val neutralButton: DialogButton? = null, + val isCancelable: Boolean = true, + val onDismiss: () -> Unit = {} +) { + constructor( + @StringRes title: Int? = null, + @StringRes message: Int? = null, + positiveButton: DialogButton? = null, + negativeButton: DialogButton? = null, + neutralButton: DialogButton? = null, + isCancelable: Boolean = true, + onDismiss: () -> Unit = {} + ) : this( + title?.let { UiString.UiStringRes(it) }, + message?.let { UiString.UiStringRes(it) }, + positiveButton, + negativeButton, + neutralButton, + isCancelable, + onDismiss + ) + + data class DialogButton( + val text: UiString, + val onClick: () -> Unit + ) { + constructor( + @StringRes text: Int, + onClick: () -> Unit + ) : this(UiString.UiStringRes(text), onClick) + } +} + +@Composable +fun DialogState.Render() { + AlertDialog( + onDismissRequest = { onDismiss() }, + title = title?.let { + { Text(text = title.getText()) } + }, + text = message?.let { + { Text(text = message.getText()) } + }, + confirmButton = positiveButton?.let { + { WCTextButton(text = it.text.getText(), onClick = it.onClick) } + } ?: { }, + dismissButton = negativeButton?.let { + { WCTextButton(text = it.text.getText(), onClick = it.onClick) } + } ?: { }, + neutralButton = neutralButton?.let { + { WCTextButton(text = it.text.getText(), onClick = it.onClick) } + } ?: { }, + properties = DialogProperties( + dismissOnBackPress = isCancelable, + dismissOnClickOutside = isCancelable + ) + ) +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/compose/component/ModalStatusBarBottomSheetLayout.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/compose/component/ModalStatusBarBottomSheetLayout.kt new file mode 100644 index 00000000000..6f751bd48a5 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/compose/component/ModalStatusBarBottomSheetLayout.kt @@ -0,0 +1,143 @@ +package com.woocommerce.android.ui.compose.component + +import android.R.attr +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.util.TypedValue +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imeNestedScroll +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.MaterialTheme +import androidx.compose.material.ModalBottomSheetDefaults +import androidx.compose.material.ModalBottomSheetLayout +import androidx.compose.material.ModalBottomSheetState +import androidx.compose.material.ModalBottomSheetValue.Hidden +import androidx.compose.material.contentColorFor +import androidx.compose.material.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.unit.Dp +import androidx.core.view.WindowCompat +import com.woocommerce.android.R + +/* + * This is a custom implementation of the ModalBottomSheetLayout that fixes the scrim color of the status bar + * and the show animation. + * + * Source: https://stackoverflow.com/a/76998328 + * + */ +@OptIn(ExperimentalMaterialApi::class, ExperimentalLayoutApi::class) +@Composable +fun ModalStatusBarBottomSheetLayout( + sheetContent: @Composable ColumnScope.() -> Unit, + modifier: Modifier = Modifier, + sheetState: ModalBottomSheetState = + rememberModalBottomSheetState(Hidden), + sheetShape: Shape = MaterialTheme.shapes.large, + sheetElevation: Dp = ModalBottomSheetDefaults.Elevation, + sheetBackgroundColor: Color = colorResource(id = R.color.bottom_sheet_background), + sheetContentColor: Color = contentColorFor(sheetBackgroundColor), + content: @Composable () -> Unit +): Unit = ModalBottomSheetLayout( + sheetContent = { + Box( + modifier = Modifier + .fillMaxWidth() + ) { + sheetContent.invoke(this@ModalBottomSheetLayout) + } + }, + modifier = modifier + .imePadding() + .navigationBarsPadding() + .imeNestedScroll(), + sheetState = sheetState, + sheetShape = sheetShape, + sheetElevation = sheetElevation, + sheetBackgroundColor = sheetBackgroundColor, + sheetContentColor = sheetContentColor, + scrimColor = scrimColor(), +) { + val context = LocalContext.current + var statusBarColor by remember { mutableStateOf(Color.Transparent) } + val backgroundColor = remember { + val typedValue = TypedValue() + if (context.findActivity().theme.resolveAttribute(attr.windowBackground, typedValue, true)) { + Color(typedValue.data) + } else { + sheetBackgroundColor + } + } + + Box( + modifier = Modifier + .fillMaxWidth() + .background(statusBarColor) + .statusBarsPadding() + ) { + Box( + modifier = Modifier + .background(backgroundColor) + .fillMaxSize() + .navigationBarsPadding() + ) { + content() + } + } + + val window = remember { context.findActivity().window } + val originalNavigationBarColor = remember { window.navigationBarColor } + if (sheetState.currentValue != Hidden) { + window.navigationBarColor = sheetBackgroundColor.toArgb() + } else { + window.navigationBarColor = originalNavigationBarColor + } + + DisposableEffect(Unit) { + val originalStatusBarColor = window.statusBarColor + statusBarColor = Color(originalStatusBarColor) + + window.statusBarColor = android.graphics.Color.TRANSPARENT + WindowCompat.setDecorFitsSystemWindows(window, false) + + onDispose { + window.statusBarColor = originalStatusBarColor + window.navigationBarColor = originalNavigationBarColor + WindowCompat.setDecorFitsSystemWindows(window, true) + } + } +} + +@Composable +private fun scrimColor() = if (isSystemInDarkTheme()) + colorResource(id = R.color.color_scrim_background) else ModalBottomSheetDefaults.scrimColor + +fun Context.findActivity(): Activity { + var context = this + while (context is ContextWrapper) { + if (context is Activity) return context + context = context.baseContext + } + throw IllegalStateException("Permissions should be called in the context of an Activity") +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/coupons/CouponListScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/coupons/CouponListScreen.kt index 8f7fc8cac2d..3945bfb1ea3 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/coupons/CouponListScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/coupons/CouponListScreen.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -17,8 +18,12 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Divider +import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.MaterialTheme import androidx.compose.material.Text +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState @@ -31,14 +36,13 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import com.google.accompanist.swiperefresh.SwipeRefresh -import com.google.accompanist.swiperefresh.SwipeRefreshIndicator -import com.google.accompanist.swiperefresh.rememberSwipeRefreshState import com.woocommerce.android.R import com.woocommerce.android.ui.compose.animations.SkeletonView import com.woocommerce.android.ui.compose.component.InfiniteListHandler import com.woocommerce.android.ui.coupons.CouponListViewModel.CouponListState import com.woocommerce.android.ui.coupons.CouponListViewModel.LoadingState +import com.woocommerce.android.ui.coupons.CouponListViewModel.LoadingState.Appending +import com.woocommerce.android.ui.coupons.CouponListViewModel.LoadingState.Refreshing import com.woocommerce.android.ui.coupons.components.CouponExpirationLabel @Composable @@ -100,6 +104,7 @@ private fun EmptyCouponList() { } } +@OptIn(ExperimentalMaterialApi::class) @Composable private fun CouponList( coupons: List, @@ -108,17 +113,9 @@ private fun CouponList( onRefresh: () -> Unit, onLoadMore: () -> Unit ) { - SwipeRefresh( - state = rememberSwipeRefreshState(isRefreshing = loadingState == LoadingState.Refreshing), - onRefresh = onRefresh, - indicator = { state, refreshTrigger -> - SwipeRefreshIndicator( - state = state, - refreshTriggerDistance = refreshTrigger, - contentColor = MaterialTheme.colors.primary, - ) - } - ) { + val isRefreshing = loadingState == Refreshing + val pullRefreshState = rememberPullRefreshState(isRefreshing, { onRefresh() }) + Box(Modifier.pullRefresh(pullRefreshState)) { val listState = rememberLazyListState() LazyColumn( state = listState, @@ -136,7 +133,7 @@ private fun CouponList( thickness = dimensionResource(id = R.dimen.minor_10) ) } - if (loadingState == LoadingState.Appending) { + if (loadingState == Appending) { item { CircularProgressIndicator( modifier = Modifier @@ -151,6 +148,13 @@ private fun CouponList( InfiniteListHandler(listState = listState) { onLoadMore() } + + PullRefreshIndicator( + refreshing = isRefreshing, + state = pullRefreshState, + modifier = Modifier.align(Alignment.TopCenter), + contentColor = MaterialTheme.colors.primary, + ) } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/coupons/selector/CouponSelectorScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/coupons/selector/CouponSelectorScreen.kt index 35458ddaaf3..200be535645 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/coupons/selector/CouponSelectorScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/coupons/selector/CouponSelectorScreen.kt @@ -3,6 +3,7 @@ package com.woocommerce.android.ui.coupons.selector import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -16,8 +17,12 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Divider +import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.MaterialTheme import androidx.compose.material.Text +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.ui.Alignment @@ -28,14 +33,12 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import com.google.accompanist.swiperefresh.SwipeRefresh -import com.google.accompanist.swiperefresh.SwipeRefreshIndicator -import com.google.accompanist.swiperefresh.rememberSwipeRefreshState import com.woocommerce.android.R import com.woocommerce.android.ui.compose.component.InfiniteListHandler import com.woocommerce.android.ui.compose.component.WCColoredButton import com.woocommerce.android.ui.coupons.CouponListItem import com.woocommerce.android.ui.coupons.CouponListSkeleton +import com.woocommerce.android.ui.coupons.selector.LoadingState.Appending @Composable fun CouponSelectorScreen( @@ -112,6 +115,7 @@ fun EmptyCouponSelectorList( } } +@OptIn(ExperimentalMaterialApi::class) @Composable fun CouponSelectorList( coupons: List, @@ -120,17 +124,9 @@ fun CouponSelectorList( onRefresh: () -> Unit, onLoadMore: () -> Unit, ) { - SwipeRefresh( - state = rememberSwipeRefreshState(isRefreshing = loadingState == LoadingState.Refreshing), - onRefresh = onRefresh, - indicator = { state, refreshTrigger -> - SwipeRefreshIndicator( - state = state, - refreshTriggerDistance = refreshTrigger, - contentColor = MaterialTheme.colors.primary, - ) - } - ) { + val isRefreshing = loadingState == LoadingState.Refreshing + val pullRefreshState = rememberPullRefreshState(isRefreshing, { onRefresh() }) + Box(Modifier.pullRefresh(pullRefreshState)) { val listState = rememberLazyListState() LazyColumn( state = listState, @@ -145,7 +141,7 @@ fun CouponSelectorList( thickness = dimensionResource(id = R.dimen.minor_10) ) } - if (loadingState == LoadingState.Appending) { + if (loadingState == Appending) { item { CircularProgressIndicator( modifier = Modifier @@ -160,6 +156,13 @@ fun CouponSelectorList( InfiniteListHandler(listState = listState) { onLoadMore() } + + PullRefreshIndicator( + refreshing = isRefreshing, + state = pullRefreshState, + modifier = Modifier.align(Alignment.TopCenter), + contentColor = MaterialTheme.colors.primary, + ) } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/inbox/InboxScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/inbox/InboxScreen.kt index d11a0f1c537..32c63c5022e 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/inbox/InboxScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/inbox/InboxScreen.kt @@ -3,6 +3,7 @@ package com.woocommerce.android.ui.inbox import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -19,10 +20,14 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.ButtonDefaults import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Divider +import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.MaterialTheme import androidx.compose.material.OutlinedButton import androidx.compose.material.Text import androidx.compose.material.TextButton +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState @@ -38,9 +43,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.core.text.HtmlCompat -import com.google.accompanist.swiperefresh.SwipeRefresh -import com.google.accompanist.swiperefresh.SwipeRefreshIndicator -import com.google.accompanist.swiperefresh.rememberSwipeRefreshState import com.woocommerce.android.R import com.woocommerce.android.ui.compose.animations.SkeletonView import com.woocommerce.android.ui.compose.toAnnotatedString @@ -103,27 +105,19 @@ fun InboxEmptyCase() { } } +@OptIn(ExperimentalMaterialApi::class) @Composable fun InboxNotes( notes: List, isRefreshing: Boolean, onRefresh: () -> Unit ) { - SwipeRefresh( - state = rememberSwipeRefreshState(isRefreshing), - onRefresh = { onRefresh.invoke() }, - indicator = { state, trigger -> - SwipeRefreshIndicator( - state = state, - refreshTriggerDistance = trigger, - contentColor = MaterialTheme.colors.primary, - ) - } - ) { + val pullRefreshState = rememberPullRefreshState(isRefreshing, { onRefresh() }) + Box(Modifier.pullRefresh(pullRefreshState)) { if (notes.isEmpty()) { InboxEmptyCase() } else { - LazyColumn { + LazyColumn(Modifier.fillMaxSize()) { itemsIndexed(notes) { index, note -> InboxNoteRow(note = note) if (index < notes.lastIndex) @@ -134,6 +128,12 @@ fun InboxNotes( } } } + PullRefreshIndicator( + refreshing = isRefreshing, + state = pullRefreshState, + modifier = Modifier.align(Alignment.TopCenter), + contentColor = MaterialTheme.colors.primary, + ) } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/login/jetpack/main/JetpackActivationMainScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/login/jetpack/main/JetpackActivationMainScreen.kt index daa3415069c..78f51ef56bd 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/login/jetpack/main/JetpackActivationMainScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/login/jetpack/main/JetpackActivationMainScreen.kt @@ -11,7 +11,7 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically -import androidx.compose.animation.with +import androidx.compose.animation.togetherWith import androidx.compose.foundation.Canvas import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -109,16 +109,19 @@ fun JetpackActivationMainScreen( transition.AnimatedContent( contentKey = { it is JetpackActivationMainViewModel.ViewState.ErrorViewState }, transitionSpec = { - fadeIn(animationSpec = tween(DefaultDurationMillis, delayMillis = DefaultDurationMillis)) with - fadeOut(animationSpec = tween(DefaultDurationMillis)) + fadeIn( + animationSpec = tween( + DefaultDurationMillis, delayMillis = DefaultDurationMillis + ) + ) togetherWith fadeOut(animationSpec = tween(DefaultDurationMillis)) }, modifier = Modifier.weight(1f) ) { targetState -> when (targetState) { is JetpackActivationMainViewModel.ViewState.ProgressViewState -> ProgressState( - viewState = targetState, - onContinueClick = onContinueClick + viewState = targetState, onContinueClick = onContinueClick ) + is JetpackActivationMainViewModel.ViewState.ErrorViewState -> ErrorState( viewState = targetState, onGetHelpClick = onGetHelpClick, @@ -294,10 +297,13 @@ private fun AnimatedVisibilityScope.ErrorState( val retryButton = when (viewState.stepType) { JetpackActivationMainViewModel.StepType.Installation -> R.string.login_jetpack_installation_retry_installing + JetpackActivationMainViewModel.StepType.Activation -> R.string.login_jetpack_installation_retry_activating + JetpackActivationMainViewModel.StepType.Connection -> R.string.login_jetpack_installation_retry_authorizing + else -> null } retryButton?.let { @@ -328,9 +334,11 @@ private fun JetpackActivationStep( JetpackActivationMainViewModel.StepState.Idle -> { IdleCircle(indicatorModifier) } + JetpackActivationMainViewModel.StepState.Ongoing -> { CircularProgressIndicator(indicatorModifier) } + JetpackActivationMainViewModel.StepState.Success -> { Image( painter = painterResource(id = R.drawable.ic_progress_circle_complete), @@ -338,6 +346,7 @@ private fun JetpackActivationStep( modifier = indicatorModifier ) } + is JetpackActivationMainViewModel.StepState.Error -> { Icon( painter = painterResource(id = R.drawable.ic_gridicons_notice), @@ -385,6 +394,7 @@ private fun ConnectionStepHint(connectionStep: JetpackActivationMainViewModel.Co R.string.login_jetpack_steps_authorizing_validation, R.color.color_on_surface_medium ) + else -> Pair( R.string.login_jetpack_steps_authorizing_done, diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/main/MainActivity.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/main/MainActivity.kt index 1f30bc19d3a..9d3000f3475 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/main/MainActivity.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/main/MainActivity.kt @@ -101,6 +101,7 @@ import com.woocommerce.android.ui.plans.di.TrialStatusBarFormatterFactory import com.woocommerce.android.ui.plans.trial.DetermineTrialStatusBarState.TrialStatusBarState import com.woocommerce.android.ui.prefs.AppSettingsActivity import com.woocommerce.android.ui.prefs.RequestedAnalyticsValue +import com.woocommerce.android.ui.products.ProductDetailFragment import com.woocommerce.android.ui.products.ProductListFragmentDirections import com.woocommerce.android.ui.reviews.ReviewListFragmentDirections import com.woocommerce.android.util.ChromeCustomTabUtils @@ -946,7 +947,7 @@ class MainActivity : override fun showProductDetail(remoteProductId: Long, enableTrash: Boolean) { val action = NavGraphMainDirections.actionGlobalProductDetailFragment( - remoteProductId = remoteProductId, + mode = ProductDetailFragment.Mode.ShowProduct(remoteProductId), isTrashEnabled = enableTrash ) navController.navigateSafely(action) @@ -957,7 +958,7 @@ class MainActivity : val extras = FragmentNavigatorExtras(sharedView to productCardDetailTransitionName) val action = NavGraphMainDirections.actionGlobalProductDetailFragment( - remoteProductId = remoteProductId, + mode = ProductDetailFragment.Mode.ShowProduct(remoteProductId), isTrashEnabled = enableTrash ) navController.navigateSafely(directions = action, extras = extras) @@ -974,7 +975,7 @@ class MainActivity : override fun showAddProduct(imageUris: List) { showBottomNav() val action = NavGraphMainDirections.actionGlobalProductDetailFragment( - isAddProduct = true, + mode = ProductDetailFragment.Mode.AddNewProduct, images = imageUris.toTypedArray() ) navController.navigateSafely(action) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/moremenu/MoreMenuFragment.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/moremenu/MoreMenuFragment.kt index e869413ce1a..1d0bb315174 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/moremenu/MoreMenuFragment.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/moremenu/MoreMenuFragment.kt @@ -14,6 +14,7 @@ import com.woocommerce.android.R import com.woocommerce.android.extensions.navigateSafely import com.woocommerce.android.tools.SelectedSite import com.woocommerce.android.ui.base.TopLevelFragment +import com.woocommerce.android.ui.blaze.BlazeUrlsHelper.BlazeFlowSource import com.woocommerce.android.ui.blaze.creation.BlazeCampaignCreationDispatcher import com.woocommerce.android.ui.compose.theme.WooThemeWithBackground import com.woocommerce.android.ui.main.AppBarStatus @@ -75,7 +76,7 @@ class MoreMenuFragment : TopLevelFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - blazeCampaignCreationDispatcher.attachFragment(this) + blazeCampaignCreationDispatcher.attachFragment(this, BlazeFlowSource.MORE_MENU_ITEM) setupObservers() } @@ -114,7 +115,7 @@ class MoreMenuFragment : TopLevelFragment() { private fun openBlazeCreationFlow() { lifecycleScope.launch { - blazeCampaignCreationDispatcher.startCampaignCreation() + blazeCampaignCreationDispatcher.startCampaignCreation(source = BlazeFlowSource.MORE_MENU_ITEM) } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/mystore/AIProductDescriptionDialogFragment.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/mystore/AIProductDescriptionDialogFragment.kt index a8882e454fc..c238d859262 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/mystore/AIProductDescriptionDialogFragment.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/mystore/AIProductDescriptionDialogFragment.kt @@ -14,6 +14,7 @@ import com.woocommerce.android.R.style import com.woocommerce.android.extensions.navigateSafely import com.woocommerce.android.ui.compose.theme.WooThemeWithBackground import com.woocommerce.android.ui.mystore.AIProductDescriptionDialogViewModel.TryAIProductDescriptionGeneration +import com.woocommerce.android.ui.products.ProductDetailFragment import com.woocommerce.android.viewmodel.MultiLiveEvent.Event.Exit import dagger.hilt.android.AndroidEntryPoint import org.wordpress.android.util.DisplayUtils @@ -59,7 +60,9 @@ class AIProductDescriptionDialogFragment : DialogFragment() { private fun openBlankProduct() { findNavController().navigateSafely( - NavGraphMainDirections.actionGlobalProductDetailFragment(isAddProduct = true) + NavGraphMainDirections.actionGlobalProductDetailFragment( + mode = ProductDetailFragment.Mode.AddNewProduct, + ) ) } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/mystore/MyStoreFragment.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/mystore/MyStoreFragment.kt index be3ea3d2742..089abd7ea78 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/mystore/MyStoreFragment.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/mystore/MyStoreFragment.kt @@ -69,6 +69,7 @@ import com.woocommerce.android.ui.mystore.MyStoreViewModel.RevenueStatsViewState import com.woocommerce.android.ui.mystore.MyStoreViewModel.VisitorStatsViewState import com.woocommerce.android.ui.prefs.privacy.banner.PrivacyBannerFragmentDirections import com.woocommerce.android.ui.products.AddProductNavigator +import com.woocommerce.android.ui.products.ProductDetailFragment import com.woocommerce.android.util.ActivityUtils import com.woocommerce.android.util.CurrencyFormatter import com.woocommerce.android.util.DateUtils @@ -176,7 +177,7 @@ class MyStoreFragment : override fun onViewCreated(view: View, savedInstanceState: Bundle?) { requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) - blazeCampaignCreationDispatcher.attachFragment(this) + blazeCampaignCreationDispatcher.attachFragment(this, BlazeFlowSource.MY_STORE_SECTION) _binding = FragmentMyStoreBinding.bind(view) @@ -279,7 +280,10 @@ class MyStoreFragment : private fun openBlazeCreationFlow(productId: Long?) { lifecycleScope.launch { - blazeCampaignCreationDispatcher.startCampaignCreation(productId) + blazeCampaignCreationDispatcher.startCampaignCreation( + source = BlazeFlowSource.MY_STORE_SECTION, + productId = productId + ) } } @@ -442,7 +446,7 @@ class MyStoreFragment : when (event) { is OpenTopPerformer -> findNavController().navigateSafely( NavGraphMainDirections.actionGlobalProductDetailFragment( - remoteProductId = event.productId, + mode = ProductDetailFragment.Mode.ShowProduct(event.productId), isTrashEnabled = false ) ) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/payments/hub/depositsummary/PaymentsHubDepositSummaryView.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/payments/hub/depositsummary/PaymentsHubDepositSummaryView.kt index ce8465b8c54..b98a4934d9d 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/payments/hub/depositsummary/PaymentsHubDepositSummaryView.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/payments/hub/depositsummary/PaymentsHubDepositSummaryView.kt @@ -17,7 +17,7 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically -import androidx.compose.animation.with +import androidx.compose.animation.togetherWith import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -138,7 +138,7 @@ fun PaymentsHubDepositSummaryView( .fillMaxWidth() .background(colorResource(id = R.color.color_surface)) ) { - val pagerState = rememberPagerState(initialPage = selectedPage) + val pagerState = rememberPagerState(initialPage = selectedPage) { pageCount } val isInitialLoad = remember { mutableStateOf(true) } val currencies = overview.infoPerCurrency.keys.toList() @@ -164,7 +164,6 @@ fun PaymentsHubDepositSummaryView( } HorizontalPager( - pageCount = pageCount, state = pagerState ) { pageIndex -> Column( @@ -558,11 +557,11 @@ private fun FundsNumber( targetState = valueToDisplay to valueAmount, transitionSpec = { if (animationPlayed) { - EnterTransition.None with ExitTransition.None + EnterTransition.None togetherWith ExitTransition.None } else if (targetState.second > initialState.second) { - slideInVertically { -it } with slideOutVertically { it } + slideInVertically { -it } togetherWith slideOutVertically { it } } else { - slideInVertically { it } with slideOutVertically { -it } + slideInVertically { it } togetherWith slideOutVertically { -it } } }, label = "AnimatedFundsNumber" diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductDetailFragment.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductDetailFragment.kt index 638676f60ab..098471e74b8 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductDetailFragment.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductDetailFragment.kt @@ -46,6 +46,7 @@ import com.woocommerce.android.model.Product.Image import com.woocommerce.android.ui.aztec.AztecEditorFragment import com.woocommerce.android.ui.aztec.AztecEditorFragment.Companion.ARG_AZTEC_EDITOR_TEXT import com.woocommerce.android.ui.aztec.AztecEditorFragment.Companion.ARG_AZTEC_TITLE_FROM_AI_DESCRIPTION +import com.woocommerce.android.ui.blaze.BlazeUrlsHelper.BlazeFlowSource import com.woocommerce.android.ui.blaze.creation.BlazeCampaignCreationDispatcher import com.woocommerce.android.ui.compose.theme.WooThemeWithBackground import com.woocommerce.android.ui.dialog.WooDialog @@ -88,6 +89,7 @@ import com.woocommerce.android.widgets.SkeletonView import com.woocommerce.android.widgets.WCProductImageGalleryView.OnGalleryImageInteractionListener import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize import org.wordpress.android.util.ActivityUtils import javax.inject.Inject @@ -159,7 +161,7 @@ class ProductDetailFragment : override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - blazeCampaignCreationDispatcher.attachFragment(this) + blazeCampaignCreationDispatcher.attachFragment(this, BlazeFlowSource.PRODUCT_DETAIL_PROMOTE_BUTTON) _binding = FragmentProductDetailBinding.bind(view) requireActivity().addMenuProvider(this, viewLifecycleOwner) @@ -407,7 +409,10 @@ class ProductDetailFragment : private fun openBlazeCreationFlow(productId: Long) { lifecycleScope.launch { - blazeCampaignCreationDispatcher.startCampaignCreation(productId = productId) + blazeCampaignCreationDispatcher.startCampaignCreation( + source = BlazeFlowSource.PRODUCT_DETAIL_PROMOTE_BUTTON, + productId = productId + ) } } @@ -676,4 +681,16 @@ class ProductDetailFragment : } override fun getFragmentTitle(): String = productName + + @Parcelize + sealed class Mode : Parcelable { + @Parcelize + data object Loading : Mode() + + @Parcelize + data class ShowProduct(val remoteProductId: Long) : Mode() + + @Parcelize + data object AddNewProduct : Mode() + } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductDetailViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductDetailViewModel.kt index 23ae378e719..d6e5c4c2482 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductDetailViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductDetailViewModel.kt @@ -306,8 +306,8 @@ class ProductDetailViewModel @Inject constructor( /** * Returns boolean value of [navArgs.isAddProduct] to determine if the view model was started for the **add** flow */ - val isAddFlowEntryPoint: Boolean - get() = navArgs.isAddProduct + private val isAddFlowEntryPoint: Boolean + get() = navArgs.mode == ProductDetailFragment.Mode.AddNewProduct /** * Validates if the view model was started for the **add** flow AND there is an already valid product to modify. @@ -353,13 +353,17 @@ class ProductDetailViewModel @Inject constructor( } private fun initializeViewState() { - when (isAddFlowEntryPoint) { - true -> startAddNewProduct() - else -> { - loadRemoteProduct(navArgs.remoteProductId) + when (val mode = navArgs.mode) { + is ProductDetailFragment.Mode.AddNewProduct -> startAddNewProduct() + is ProductDetailFragment.Mode.ShowProduct -> { + loadRemoteProduct(mode.remoteProductId) if (navArgs.isAIContent && !appPrefsWrapper.isAiProductCreationSurveyDismissed) triggerEventWithDelay(ShowAiProductCreationSurveyBottomSheet, delay = 500) } + + is ProductDetailFragment.Mode.Loading -> { + viewState = viewState.copy(isSkeletonShown = true) + } } } @@ -381,10 +385,17 @@ class ProductDetailViewModel @Inject constructor( private fun initializeStoredProductAfterRestoration() { launch { - storedProduct.value = if (isAddFlowEntryPoint && !isProductStoredAtSite) { - createDefaultProductForAddFlow() + if (isAddFlowEntryPoint && !isProductStoredAtSite) { + storedProduct.value = createDefaultProductForAddFlow() } else { - productRepository.getProductAsync(viewState.productDraft?.remoteId ?: navArgs.remoteProductId) + val mode = navArgs.mode + if (mode is ProductDetailFragment.Mode.ShowProduct) { + storedProduct.value = productRepository.getProductAsync( + viewState.productDraft?.remoteId ?: mode.remoteProductId + ) + } else { + viewState = viewState.copy(isSkeletonShown = true) + } } } } @@ -1340,7 +1351,8 @@ class ProductDetailViewModel @Inject constructor( fun refreshProduct() { launch { - fetchProduct(viewState.productDraft?.remoteId ?: navArgs.remoteProductId) + val mode = navArgs.mode as ProductDetailFragment.Mode.ShowProduct + fetchProduct(viewState.productDraft?.remoteId ?: mode.remoteProductId) } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductItemViewHolder.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductItemViewHolder.kt index 51cf339f7ea..39343e146ca 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductItemViewHolder.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductItemViewHolder.kt @@ -1,5 +1,6 @@ package com.woocommerce.android.ui.products +import android.graphics.Color import androidx.core.view.ViewCompat import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView @@ -15,9 +16,19 @@ class ProductItemViewHolder(val viewBinding: ProductListItemBinding) : fun bind( product: Product, currencyFormatter: CurrencyFormatter, - isActivated: Boolean = false + isActivated: Boolean = false, + isProductHighlighted: Boolean = false, ) { viewBinding.root.isActivated = isActivated + + if (isProductHighlighted) { + viewBinding.root.setBackgroundColor( + viewBinding.root.context.getColor(R.color.color_item_selected) + ) + } else { + viewBinding.root.setBackgroundColor(Color.TRANSPARENT) + } + viewBinding.productItemView.bind( product = product, currencyFormatter = currencyFormatter, diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductListAdapter.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductListAdapter.kt index 71698f79f2d..b1ef2373b2d 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductListAdapter.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductListAdapter.kt @@ -16,7 +16,8 @@ typealias OnProductClickListener = (remoteProductId: Long, sharedView: View?) -> class ProductListAdapter( private inline val clickListener: OnProductClickListener? = null, private val loadMoreListener: OnLoadMoreListener, - private val currencyFormatter: CurrencyFormatter + private val currencyFormatter: CurrencyFormatter, + private val isProductHighlighted: (Long) -> Boolean, ) : ListAdapter(ProductItemDiffCallback) { // allow the selection library to track the selections of the user var tracker: SelectionTracker? = null @@ -43,7 +44,8 @@ class ProductListAdapter( holder.bind( product, currencyFormatter, - isActivated = tracker?.isSelected(product.remoteId) ?: false + isActivated = tracker?.isSelected(product.remoteId) ?: false, + isProductHighlighted = isProductHighlighted(product.remoteId) ) holder.itemView.setOnClickListener { diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductListFragment.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductListFragment.kt index db29da3702d..b31d1678117 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductListFragment.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductListFragment.kt @@ -2,17 +2,12 @@ package com.woocommerce.android.ui.products import android.os.Bundle import android.view.Menu -import android.view.MenuInflater import android.view.MenuItem -import android.view.MenuItem.OnActionExpandListener import android.view.View import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.view.ActionMode -import androidx.appcompat.widget.SearchView -import androidx.appcompat.widget.SearchView.OnQueryTextListener import androidx.core.view.MenuCompat -import androidx.core.view.MenuProvider import androidx.core.view.ViewGroupCompat import androidx.core.view.doOnPreDraw import androidx.core.view.isVisible @@ -43,8 +38,10 @@ import com.woocommerce.android.model.Product import com.woocommerce.android.ui.base.TopLevelFragment import com.woocommerce.android.ui.base.UIMessageResolver import com.woocommerce.android.ui.feedback.SurveyType +import com.woocommerce.android.ui.main.AppBarStatus import com.woocommerce.android.ui.main.MainActivity import com.woocommerce.android.ui.main.MainNavigationRouter +import com.woocommerce.android.ui.products.ProductListViewModel.ProductListEvent.OpenProduct import com.woocommerce.android.ui.products.ProductListViewModel.ProductListEvent.ScrollToTop import com.woocommerce.android.ui.products.ProductListViewModel.ProductListEvent.SelectProducts import com.woocommerce.android.ui.products.ProductListViewModel.ProductListEvent.ShowAddProductBottomSheet @@ -67,11 +64,7 @@ class ProductListFragment : TopLevelFragment(R.layout.fragment_product_list), ProductSortAndFilterListener, OnLoadMoreListener, - OnQueryTextListener, - OnActionExpandListener, - WCProductSearchTabView.ProductSearchTypeChangedListener, ActionMode.Callback, - MenuProvider, TabletLayoutSetupHelper.Screen { companion object { val TAG: String = ProductListFragment::class.java.simpleName @@ -93,6 +86,9 @@ class ProductListFragment : @Inject lateinit var tabletLayoutSetupHelper: TabletLayoutSetupHelper + @Inject + lateinit var productListToolbar: ProductListToolbarHelper + private var _productAdapter: ProductListAdapter? = null private val productAdapter: ProductListAdapter get() = _productAdapter!! @@ -105,10 +101,6 @@ class ProductListFragment : private val skeletonView = SkeletonView() - private var searchMenuItem: MenuItem? = null - private var scanBarcodeMenuItem: MenuItem? = null - private var searchView: SearchView? = null - private var trashProductUndoSnack: Snackbar? = null private var pendingTrashProductId: Long? = null @@ -128,10 +120,15 @@ class ProductListFragment : TabletLayoutSetupHelper.Screen.Navigation( childFragmentManager, R.navigation.nav_graph_products, - null, + ProductDetailFragmentArgs( + mode = ProductDetailFragment.Mode.Loading, + ).toBundle() ) } + override val activityAppBarStatus: AppBarStatus + get() = AppBarStatus.Hidden + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -147,7 +144,6 @@ class ProductListFragment : tabletLayoutSetupHelper.onViewCreated(this) postponeEnterTransition() - requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) _binding = FragmentProductListBinding.bind(view) @@ -159,10 +155,8 @@ class ProductListFragment : _productAdapter = ProductListAdapter( loadMoreListener = this, currencyFormatter = currencyFormatter, - clickListener = { id, sharedView -> - binding.addProductButton.hide() - onProductClick(id, sharedView) - } + clickListener = { id, sharedView -> productListViewModel.onOpenProduct(id, sharedView) }, + isProductHighlighted = { productListViewModel.isProductHighlighted(it) } ) binding.productsRecycler.layoutManager = LinearLayoutManager(requireActivity()) binding.productsRecycler.adapter = productAdapter @@ -182,16 +176,11 @@ class ProductListFragment : initAddProductFab(binding.addProductButton) addSelectionTracker() - when { - productListViewModel.isSearching() -> { - binding.productsSearchTabView.isVisible = true - binding.productsSearchTabView.show(this, productListViewModel.isSkuSearch()) - } - - else -> { - productListViewModel.reloadProductsFromDb(excludeProductId = pendingTrashProductId) - } + if (!productListViewModel.isSearching()) { + productListViewModel.reloadProductsFromDb(excludeProductId = pendingTrashProductId) } + + productListToolbar.onViewCreated(this, productListViewModel, binding) } private fun addSelectionTracker() { @@ -211,7 +200,6 @@ class ProductListFragment : override fun onSelectionChanged() { val selectionCount = tracker?.selection?.size() ?: 0 productListViewModel.onSelectionChanged(selectionCount) - super.onSelectionChanged() } }) } @@ -230,12 +218,9 @@ class ProductListFragment : override fun onDestroyView() { skeletonView.hide() - disableSearchListeners() - searchView = null _productAdapter = null actionMode = null tracker = null - searchMenuItem = null binding.productsSearchTabView.hide() super.onDestroyView() _binding = null @@ -267,118 +252,6 @@ class ProductListFragment : super.onViewStateRestored(savedInstanceState) } - override fun onCreateMenu(menu: Menu, inflater: MenuInflater) { - inflater.inflate(R.menu.menu_product_list_fragment, menu) - - searchMenuItem = menu.findItem(R.id.menu_search) - searchView = searchMenuItem?.actionView as SearchView? - searchView?.queryHint = getString(R.string.product_search_hint) - scanBarcodeMenuItem = menu.findItem(R.id.menu_scan_barcode) - } - - override fun onPrepareMenu(menu: Menu) { - refreshOptionsMenu() - } - - /** - * Use this rather than invalidateOptionsMenu() since that collapses the search menu item - */ - private fun refreshOptionsMenu() { - val showSearch = shouldShowSearchMenuItem() - searchMenuItem?.let { menuItem -> - if (menuItem.isVisible != showSearch) menuItem.isVisible = showSearch - - val isSearchActive = productListViewModel.viewStateLiveData.liveData.value?.isSearchActive == true - if (menuItem.isActionViewExpanded != isSearchActive) { - if (isSearchActive) { - disableSearchListeners() - menuItem.expandActionView() - val queryHint = getSearchQueryHint() - searchView?.queryHint = queryHint - searchView?.setQuery(productListViewModel.viewStateLiveData.liveData.value?.query, false) - enableSearchListeners() - } - } - } - scanBarcodeMenuItem?.isVisible = !productListViewModel.isSquarePluginActive() - } - - private fun getSearchQueryHint(): String { - return if (productListViewModel.viewStateLiveData.liveData.value?.isFilteringActive == true) { - getString(R.string.product_search_hint_active_filters) - } else { - getString(R.string.product_search_hint) - } - } - - /** - * Prevent search from appearing when a child fragment is active - */ - private fun shouldShowSearchMenuItem(): Boolean { - val isChildShowing = (activity as? MainNavigationRouter)?.isChildFragmentShowing() ?: false - return !isChildShowing - } - - override fun onMenuItemSelected(item: MenuItem): Boolean { - return when (item.itemId) { - R.id.menu_search -> { - AnalyticsTracker.track(AnalyticsEvent.PRODUCT_LIST_MENU_SEARCH_TAPPED) - enableSearchListeners() - true - } - R.id.menu_scan_barcode -> { - AnalyticsTracker.track(AnalyticsEvent.PRODUCT_LIST_PRODUCT_BARCODE_SCANNING_TAPPED) - ProductListFragmentDirections.actionProductListFragmentToScanToUpdateInventory().let { - findNavController().navigate(it) - } - searchMenuItem?.collapseActionView() - true - } - - else -> false - } - } - - private fun disableSearchListeners() { - searchMenuItem?.setOnActionExpandListener(null) - searchView?.setOnQueryTextListener(null) - } - - private fun enableSearchListeners() { - searchMenuItem?.setOnActionExpandListener(this) - searchView?.setOnQueryTextListener(this) - } - - override fun onQueryTextSubmit(query: String): Boolean { - productListViewModel.onSearchRequested() - org.wordpress.android.util.ActivityUtils.hideKeyboard(activity) - return true - } - - override fun onQueryTextChange(newText: String): Boolean { - productListViewModel.onSearchQueryChanged(newText) - return true - } - - override fun onProductSearchTypeChanged(isSkuSearch: Boolean) { - productListViewModel.onSearchTypeChanged(isSkuSearch) - } - - override fun onMenuItemActionExpand(item: MenuItem): Boolean { - productListViewModel.onSearchOpened() - onSearchViewActiveChanged(isActive = true) - binding.productsSearchTabView.show(this) - return true - } - - override fun onMenuItemActionCollapse(item: MenuItem): Boolean { - productListViewModel.onSearchClosed() - updateActivityTitle() - onSearchViewActiveChanged(isActive = false) - binding.productsSearchTabView.hide() - return true - } - private fun setIsRefreshing(isRefreshing: Boolean) { binding.productsRefreshLayout.isRefreshing = isRefreshing } @@ -462,6 +335,22 @@ class ProductListFragment : is ShowProductSortingBottomSheet -> showProductSortingBottomSheet() is SelectProducts -> tracker?.setItemsSelected(event.productsIds, true) is ShowUpdateDialog -> handleUpdateDialogs(event) + is OpenProduct -> { + tabletLayoutSetupHelper.onItemClicked( + tabletNavigateTo = { + productAdapter.notifyItemChanged(event.oldPosition) + productAdapter.notifyItemChanged(event.newPosition) + R.id.nav_graph_products to ProductDetailFragmentArgs( + mode = ProductDetailFragment.Mode.ShowProduct(event.productId), + isTrashEnabled = true, + ).toBundle() + }, + navigateWithPhoneNavigation = { + binding.addProductButton.hide() + onProductClick(event.productId, event.sharedView) + } + ) + } else -> event.isHandled = false } } @@ -522,14 +411,12 @@ class ProductListFragment : actionMode = (requireActivity() as AppCompatActivity) .startSupportActionMode(this@ProductListFragment) delayMultiSelection() - onListSelectionActiveChanged(isActive = true, expandToolbar = false) enableProductsRefresh(false) enableProductSortAndFiltersCard(false) } ProductListViewModel.ProductListState.Browsing -> { actionMode?.finish() - onListSelectionActiveChanged(isActive = false, expandToolbar = !productListViewModel.isSearching()) enableProductsRefresh(true) enableProductSortAndFiltersCard(true) } @@ -596,8 +483,6 @@ class ProductListFragment : } } - override fun getFragmentTitle() = getString(R.string.products) - override fun scrollToTop() { binding.productsRecycler.smoothScrollToPosition(0) } @@ -694,7 +579,7 @@ class ProductListFragment : private fun onProductClick(remoteProductId: Long, sharedView: View?) { if (shouldPreventDetailNavigation(remoteProductId)) return - disableSearchListeners() + productListToolbar.disableSearchListeners() (activity as? MainNavigationRouter)?.let { router -> if (sharedView == null) { router.showProductDetail(remoteProductId, enableTrash = true) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductListToolbarHelper.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductListToolbarHelper.kt new file mode 100644 index 00000000000..52832c2bb65 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductListToolbarHelper.kt @@ -0,0 +1,194 @@ +package com.woocommerce.android.ui.products + +import android.app.Activity +import android.view.MenuItem +import androidx.activity.OnBackPressedCallback +import androidx.appcompat.widget.SearchView +import androidx.appcompat.widget.Toolbar +import androidx.core.view.isVisible +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.navigation.fragment.findNavController +import com.woocommerce.android.R +import com.woocommerce.android.analytics.AnalyticsEvent +import com.woocommerce.android.analytics.AnalyticsTracker +import com.woocommerce.android.databinding.FragmentProductListBinding +import com.woocommerce.android.ui.main.MainNavigationRouter +import com.woocommerce.android.util.IsTabletLogicNeeded +import org.wordpress.android.util.ActivityUtils +import javax.inject.Inject + +class ProductListToolbarHelper @Inject constructor( + private val activity: Activity, + private val isTabletLogicNeeded: IsTabletLogicNeeded, +) : DefaultLifecycleObserver, + MenuItem.OnActionExpandListener, + SearchView.OnQueryTextListener, + Toolbar.OnMenuItemClickListener, + WCProductSearchTabView.ProductSearchTypeChangedListener { + private var fragment: ProductListFragment? = null + private var viewModel: ProductListViewModel? = null + private var binding: FragmentProductListBinding? = null + + private var searchMenuItem: MenuItem? = null + private var scanBarcodeMenuItem: MenuItem? = null + private var searchView: SearchView? = null + + override fun onCreate(owner: LifecycleOwner) { + (activity as FragmentActivity).onBackPressedDispatcher.addCallback( + owner, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if (isTabletLogicNeeded()) { + fragment?.findNavController()?.navigateUp() + } else if (searchMenuItem?.isActionViewExpanded == true) { + searchMenuItem?.collapseActionView() + } else { + fragment?.findNavController()?.navigateUp() + } + } + } + ) + } + + fun onViewCreated( + fragment: ProductListFragment, + productListViewModel: ProductListViewModel, + binding: FragmentProductListBinding + ) { + this.fragment = fragment + this.viewModel = productListViewModel + this.binding = binding + + fragment.lifecycle.addObserver(this) + + if (productListViewModel.isSearching()) { + binding.productsSearchTabView.isVisible = true + binding.productsSearchTabView.show(this, productListViewModel.isSkuSearch()) + } + + setupToolbar(binding.toolbar) + } + + override fun onDestroy(owner: LifecycleOwner) { + fragment = null + searchMenuItem = null + scanBarcodeMenuItem = null + searchView = null + viewModel = null + binding = null + disableSearchListeners() + } + + override fun onMenuItemClick(item: MenuItem): Boolean = + when (item.itemId) { + R.id.menu_search -> { + AnalyticsTracker.track(AnalyticsEvent.PRODUCT_LIST_MENU_SEARCH_TAPPED) + enableSearchListeners() + true + } + + R.id.menu_scan_barcode -> { + AnalyticsTracker.track(AnalyticsEvent.PRODUCT_LIST_PRODUCT_BARCODE_SCANNING_TAPPED) + ProductListFragmentDirections.actionProductListFragmentToScanToUpdateInventory().let { + fragment?.findNavController()?.navigate(it) + } + searchMenuItem?.collapseActionView() + true + } + + else -> false + } + + override fun onMenuItemActionExpand(item: MenuItem): Boolean { + viewModel?.onSearchOpened() + binding?.productsSearchTabView?.show(this) + return true + } + + override fun onMenuItemActionCollapse(item: MenuItem): Boolean { + viewModel?.onSearchClosed() + binding?.productsSearchTabView?.hide() + return true + } + + override fun onQueryTextSubmit(query: String): Boolean { + viewModel?.onSearchRequested() + ActivityUtils.hideKeyboard(activity) + return true + } + + override fun onQueryTextChange(newText: String): Boolean { + viewModel?.onSearchQueryChanged(newText) + return true + } + + override fun onProductSearchTypeChanged(isSkuSearch: Boolean) { + viewModel?.onSearchTypeChanged(isSkuSearch) + } + + private fun setupToolbar(toolbar: Toolbar) { + toolbar.title = activity.getString(R.string.products) + toolbar.setOnMenuItemClickListener(this) + toolbar.inflateMenu(R.menu.menu_product_list_fragment) + toolbar.navigationIcon = null + + searchMenuItem = toolbar.menu.findItem(R.id.menu_search) + searchMenuItem?.setOnActionExpandListener(this) + + searchView = searchMenuItem?.actionView as SearchView + searchView?.queryHint = activity.getString(R.string.product_search_hint) + searchView?.queryHint = getSearchQueryHint() + + scanBarcodeMenuItem = toolbar.menu.findItem(R.id.menu_scan_barcode) + + refreshOptionsMenu() + } + + private fun refreshOptionsMenu() { + val showSearch = shouldShowSearchMenuItem() + searchMenuItem?.let { menuItem -> + if (menuItem.isVisible != showSearch) menuItem.isVisible = showSearch + + val isSearchActive = viewModel?.viewStateLiveData?.liveData?.value?.isSearchActive == true + if (menuItem.isActionViewExpanded != isSearchActive) { + if (isSearchActive) { + disableSearchListeners() + menuItem.expandActionView() + val queryHint = getSearchQueryHint() + searchView?.queryHint = queryHint + searchView?.setQuery(viewModel?.viewStateLiveData?.liveData?.value?.query, false) + enableSearchListeners() + } + } + } + scanBarcodeMenuItem?.isVisible = !(viewModel?.isSquarePluginActive() ?: false) + } + + private fun getSearchQueryHint(): String { + return if (viewModel?.viewStateLiveData?.liveData?.value?.isFilteringActive == true) { + activity.getString(R.string.product_search_hint_active_filters) + } else { + activity.getString(R.string.product_search_hint) + } + } + + /** + * Prevent search from appearing when a child fragment is active + */ + private fun shouldShowSearchMenuItem(): Boolean { + val isChildShowing = (activity as? MainNavigationRouter)?.isChildFragmentShowing() ?: false + return !isChildShowing + } + + fun disableSearchListeners() { + searchMenuItem?.setOnActionExpandListener(null) + searchView?.setOnQueryTextListener(null) + } + + private fun enableSearchListeners() { + searchMenuItem?.setOnActionExpandListener(this) + searchView?.setOnQueryTextListener(this) + } +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductListViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductListViewModel.kt index b9fa601ebc8..0f60462fd33 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductListViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductListViewModel.kt @@ -1,10 +1,12 @@ package com.woocommerce.android.ui.products import android.os.Parcelable +import android.view.View import androidx.annotation.StringRes import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.map import com.woocommerce.android.AppConstants import com.woocommerce.android.R import com.woocommerce.android.analytics.AnalyticsEvent @@ -31,6 +33,7 @@ import com.woocommerce.android.ui.products.ProductListViewModel.ProductListEvent import com.woocommerce.android.ui.products.ProductListViewModel.ProductListEvent.ShowProductFilterScreen import com.woocommerce.android.ui.products.ProductListViewModel.ProductListEvent.ShowProductSortingBottomSheet import com.woocommerce.android.ui.products.ProductListViewModel.ProductListEvent.ShowUpdateDialog +import com.woocommerce.android.util.IsTabletLogicNeeded import com.woocommerce.android.util.WooLog import com.woocommerce.android.viewmodel.LiveDataDelegate import com.woocommerce.android.viewmodel.MultiLiveEvent.Event @@ -67,14 +70,19 @@ class ProductListViewModel @Inject constructor( private val analyticsTracker: AnalyticsTrackerWrapper, private val selectedSite: SelectedSite, private val wooCommerceStore: WooCommerceStore, + private val isTabletLogicNeeded: IsTabletLogicNeeded ) : ScopedViewModel(savedState) { companion object { private const val KEY_PRODUCT_FILTER_OPTIONS = "key_product_filter_options" private const val KEY_PRODUCT_FILTER_SELECTED_CATEGORY_NAME = "key_product_filter_selected_category_name" + private const val KEY_PRODUCT_OPENED = "key_product_opened" } private val _productList = MutableLiveData>() - val productList: LiveData> = _productList + val productList: LiveData> = _productList.map { + openFirstLoadedProductOnTablet(it) + it + } val viewStateLiveData = LiveDataDelegate(savedState, ViewState()) private var viewState by viewStateLiveData @@ -89,6 +97,9 @@ class ProductListViewModel @Inject constructor( private var selectedCategoryName: String? = null private var searchJob: Job? = null private var loadJob: Job? = null + private var openedProduct: Long? + get() = savedState[KEY_PRODUCT_OPENED] + set(value) = savedState.set(KEY_PRODUCT_OPENED, value) init { EventBus.getDefault().register(this) @@ -418,6 +429,31 @@ class ProductListViewModel @Inject constructor( triggerEvent(SelectProducts(selectedProductsIds)) } + private fun openFirstLoadedProductOnTablet(products: List) { + if (products.isNotEmpty() && isTabletLogicNeeded()) { + if (openedProduct == null) { + openedProduct = products.first().remoteId + } + onOpenProduct(openedProduct!!, null) + } + } + + fun onOpenProduct(productId: Long, sharedView: View?) { + val oldPositionInList = _productList.value?.indexOfFirst { it.remoteId == openedProduct } ?: 0 + openedProduct = productId + val newPositionInList = _productList.value?.indexOfFirst { it.remoteId == productId } ?: 0 + triggerEvent( + ProductListEvent.OpenProduct( + productId = productId, + oldPosition = oldPositionInList, + newPosition = newPositionInList, + sharedView = sharedView, + ) + ) + } + + fun isProductHighlighted(productId: Long) = if (isTabletLogicNeeded()) productId == openedProduct else false + fun onSelectAllProductsClicked() { analyticsTracker.track(PRODUCT_LIST_BULK_UPDATE_SELECT_ALL_TAPPED) productList.value?.map { it.remoteId }?.let { allLoadedProductsIds -> @@ -425,7 +461,7 @@ class ProductListViewModel @Inject constructor( } } - fun enterSelectionMode(count: Int) { + private fun enterSelectionMode(count: Int) { viewState = viewState.copy( productListState = ProductListState.Selecting, isAddProductButtonVisible = false, @@ -433,7 +469,7 @@ class ProductListViewModel @Inject constructor( ) } - fun exitSelectionMode() { + private fun exitSelectionMode() { viewState = viewState.copy( productListState = ProductListState.Browsing, isAddProductButtonVisible = true, @@ -441,7 +477,7 @@ class ProductListViewModel @Inject constructor( ) } - fun refreshProducts(scrollToTop: Boolean = false) { + private fun refreshProducts(scrollToTop: Boolean = false) { if (checkConnection()) { loadProducts(scrollToTop = scrollToTop, isRefreshing = true) } else { @@ -697,6 +733,12 @@ class ProductListViewModel @Inject constructor( data class Price(override val productsIds: List) : ShowUpdateDialog() data class Status(override val productsIds: List) : ShowUpdateDialog() } + data class OpenProduct( + val productId: Long, + val oldPosition: Int, + val newPosition: Int, + val sharedView: View? + ) : ProductListEvent() } enum class ProductListState { Selecting, Browsing } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductNavigator.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductNavigator.kt index b422a29de80..b4ce630ba8d 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductNavigator.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductNavigator.kt @@ -265,7 +265,7 @@ class ProductNavigator @Inject constructor() { is ProductNavigationTarget.ViewProductAdd -> { val directions = NavGraphMainDirections.actionGlobalProductDetailFragment( - isAddProduct = true, + mode = ProductDetailFragment.Mode.AddNewProduct, source = target.source ) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductSelectionListFragment.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductSelectionListFragment.kt index 3e03e5bf673..07a084a2759 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductSelectionListFragment.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductSelectionListFragment.kt @@ -50,7 +50,8 @@ class ProductSelectionListFragment : private val productSelectionListAdapter: ProductListAdapter by lazy { ProductListAdapter( loadMoreListener = this, - currencyFormatter = currencyFormatter + currencyFormatter = currencyFormatter, + isProductHighlighted = { false } ) } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ai/AddProductWithAIFragment.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ai/AddProductWithAIFragment.kt index e081f0a0a1f..b2327ac7c7d 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ai/AddProductWithAIFragment.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ai/AddProductWithAIFragment.kt @@ -21,6 +21,7 @@ import com.woocommerce.android.ui.base.BaseFragment import com.woocommerce.android.ui.base.UIMessageResolver import com.woocommerce.android.ui.compose.theme.WooThemeWithBackground import com.woocommerce.android.ui.main.AppBarStatus +import com.woocommerce.android.ui.products.ProductDetailFragment import com.woocommerce.android.ui.products.ai.AddProductWithAIViewModel.NavigateToProductDetailScreen import com.woocommerce.android.ui.products.ai.PackagePhotoViewModel.PackagePhotoData import com.woocommerce.android.ui.products.ai.ProductNameSubViewModel.NavigateToAIProductNameBottomSheet @@ -65,7 +66,7 @@ class AddProductWithAIFragment : BaseFragment(), MediaPickerResultHandler { is NavigateToAIProductNameBottomSheet -> navigateToAIProductName(event.initialName) is NavigateToProductDetailScreen -> findNavController().navigateSafely( directions = NavGraphMainDirections.actionGlobalProductDetailFragment( - remoteProductId = event.productId, + mode = ProductDetailFragment.Mode.ShowProduct(event.productId), isAIContent = true ), navOptions = navOptions { diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/variations/attributes/AttributesAddedFragment.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/variations/attributes/AttributesAddedFragment.kt index 4b823560188..bfb224516ff 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/variations/attributes/AttributesAddedFragment.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/variations/attributes/AttributesAddedFragment.kt @@ -12,6 +12,7 @@ import com.woocommerce.android.extensions.handleDialogNotice import com.woocommerce.android.extensions.navigateSafely import com.woocommerce.android.extensions.takeIfNotEqualTo import com.woocommerce.android.ui.products.BaseProductFragment +import com.woocommerce.android.ui.products.ProductDetailFragment import com.woocommerce.android.ui.products.ProductDetailViewModel.ProductExitEvent.ExitAttributesAdded import com.woocommerce.android.ui.products.variations.GenerateVariationBottomSheetFragment import com.woocommerce.android.ui.products.variations.GenerateVariationBottomSheetFragment.Companion.KEY_ADD_NEW_VARIATION @@ -88,8 +89,9 @@ class AttributesAddedFragment : when (event) { is ExitAttributesAdded -> AttributesAddedFragmentDirections - .actionAttributesAddedFragmentToProductDetailFragment() - .apply { findNavController().navigateSafely(this) } + .actionAttributesAddedFragmentToProductDetailFragment( + mode = ProductDetailFragment.Mode.AddNewProduct + ).apply { findNavController().navigateSafely(this) } is ShowSnackbar -> uiMessageResolver.getSnack(event.message) is ShowGenerateVariationConfirmation -> showGenerateVariationConfirmation(event.variationCandidates) is ShowGenerateVariationsError -> handleGenerateVariationError(event) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/shipping/InstallWCShippingScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/shipping/InstallWCShippingScreen.kt index 472c1008cc2..5a74ea52c59 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/shipping/InstallWCShippingScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/shipping/InstallWCShippingScreen.kt @@ -11,7 +11,7 @@ import androidx.compose.animation.core.tween import androidx.compose.animation.core.updateTransition import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.animation.with +import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.material.MaterialTheme @@ -49,10 +49,10 @@ fun InstallWCShippingScreen(viewState: ViewState) { // Apply a fade-in/fade-out globally, // then each child will animate the individual components separately fadeIn(tween(500, delayMillis = 500)) - .with(fadeOut(tween(500, easing = LinearOutSlowInEasing))) + .togetherWith(fadeOut(tween(500, easing = LinearOutSlowInEasing))) } else { // No-op animation, each screen will define animations for specific components separately - EnterTransition.None.with(ExitTransition.None) + EnterTransition.None.togetherWith(ExitTransition.None) } } ) { targetState -> diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/util/TabletLayoutSetupHelper.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/util/TabletLayoutSetupHelper.kt index 00afb3aae04..0eb9b5134e0 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/util/TabletLayoutSetupHelper.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/util/TabletLayoutSetupHelper.kt @@ -14,9 +14,12 @@ import javax.inject.Inject class TabletLayoutSetupHelper @Inject constructor( private val context: Context, + private val isTabletLogicNeeded: IsTabletLogicNeeded, ) : DefaultLifecycleObserver { private var screen: Screen? = null + private lateinit var navHostFragment: NavHostFragment + fun onViewCreated(screen: Screen) { if (!FeatureFlag.BETTER_TABLETS_SUPPORT_PRODUCTS.isEnabled()) return @@ -24,13 +27,26 @@ class TabletLayoutSetupHelper @Inject constructor( screen.lifecycleKeeper.addObserver(this) } + fun onItemClicked( + tabletNavigateTo: () -> Pair, + navigateWithPhoneNavigation: () -> Unit + ) { + if (isTabletLogicNeeded()) { + val navigationData = tabletNavigateTo() + navHostFragment.navController.navigate( + navigationData.first, + navigationData.second, + ) + } else { + navigateWithPhoneNavigation() + } + } + override fun onCreate(owner: LifecycleOwner) { - if (!FeatureFlag.BETTER_TABLETS_SUPPORT_PRODUCTS.isEnabled()) return + if (!isTabletLogicNeeded()) return - if (DisplayUtils.isTablet(context) || DisplayUtils.isXLargeTablet(context)) { - initNavFragment(screen!!.secondPaneNavigation) - adjustUIForScreenSize(screen!!.twoPaneLayoutGuideline) - } + initNavFragment(screen!!.secondPaneNavigation) + adjustUIForScreenSize(screen!!.twoPaneLayoutGuideline) } override fun onDestroy(owner: LifecycleOwner) { @@ -41,9 +57,9 @@ class TabletLayoutSetupHelper @Inject constructor( private fun initNavFragment(navigation: Screen.Navigation) { val fragmentManager = navigation.fragmentManager val navGraphId = navigation.navGraphId - val bundle = navigation.bundle + val bundle = navigation.initialBundle - val navHostFragment = NavHostFragment.create(navGraphId, bundle) + navHostFragment = NavHostFragment.create(navGraphId, bundle) fragmentManager.beginTransaction() .replace(R.id.detail_nav_container, navHostFragment) @@ -55,9 +71,11 @@ class TabletLayoutSetupHelper @Inject constructor( DisplayUtils.isTablet(context) -> { twoPaneLayoutGuideline.setGuidelinePercent(TABLET_PANES_WIDTH_RATIO) } + DisplayUtils.isXLargeTablet(context) -> { twoPaneLayoutGuideline.setGuidelinePercent(XL_TABLET_PANES_WIDTH_RATIO) } + else -> twoPaneLayoutGuideline.setGuidelinePercent(1.0f) } } @@ -75,7 +93,11 @@ class TabletLayoutSetupHelper @Inject constructor( data class Navigation( val fragmentManager: FragmentManager, val navGraphId: Int, - val bundle: Bundle? + val initialBundle: Bundle? ) } } + +class IsTabletLogicNeeded @Inject constructor(private val isTablet: IsTablet) { + operator fun invoke() = isTablet() && FeatureFlag.BETTER_TABLETS_SUPPORT_PRODUCTS.isEnabled() +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/util/UiHelpers.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/util/UiHelpers.kt index a76125676fd..92f2af21858 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/util/UiHelpers.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/util/UiHelpers.kt @@ -15,6 +15,7 @@ import com.woocommerce.android.model.UiString import com.woocommerce.android.model.UiString.UiStringRes import com.woocommerce.android.model.UiString.UiStringText import org.wordpress.android.util.DisplayUtils +import javax.inject.Inject object UiHelpers { fun getPxOfUiDimen(context: Context, uiDimen: UiDimen): Int = @@ -82,3 +83,7 @@ object UiHelpers { image?.let { imageView.setImageDrawable(image) } } } + +class IsTablet @Inject constructor(val context: Context) { + operator fun invoke() = DisplayUtils.isTablet(context) || DisplayUtils.isXLargeTablet(context) +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/util/UrlUtils.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/util/UrlUtils.kt index facfb1d4439..593f0289168 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/util/UrlUtils.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/util/UrlUtils.kt @@ -7,6 +7,7 @@ import dagger.Reusable import org.wordpress.android.fluxc.network.discovery.DiscoveryUtils import org.wordpress.android.util.LanguageUtils import org.wordpress.android.util.UrlUtils +import java.net.URLEncoder import javax.inject.Inject @Reusable @@ -35,19 +36,11 @@ class UrlUtils @Inject constructor( } } -fun String.getBaseUrl(): String { - return (split("?").getOrNull(0) ?: this).trimEnd('/') -} - -fun String.parseParameters(): Map { - val parameters = split("?").getOrNull(1) ?: return emptyMap() - return parameters.split("&").filter { it.contains("=") }.associate { - val (key, value) = it.split("=") - key to value - } -} - -fun Map.joinToUrl(baseUrl: String) = buildString { +fun Map.joinToUrl(baseUrl: String): String = buildString { + fun encode(value: String) = URLEncoder.encode(value, "UTF-8").replace("+", "%20") append(baseUrl) - appendWithIfNotEmpty(entries.joinToString("&"), "?") + appendWithIfNotEmpty( + line = entries.joinToString("&") { (key, value) -> "${encode(key)}=${encode(value)}" }, + separator = if (baseUrl.contains("?")) "&" else "?" + ) } diff --git a/WooCommerce/src/main/res/drawable-night/blaze_campaign_created_success.xml b/WooCommerce/src/main/res/drawable-night/blaze_campaign_created_success.xml new file mode 100644 index 00000000000..c6c6c7bdc74 --- /dev/null +++ b/WooCommerce/src/main/res/drawable-night/blaze_campaign_created_success.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + diff --git a/WooCommerce/src/main/res/drawable/blaze_campaign_created_success.xml b/WooCommerce/src/main/res/drawable/blaze_campaign_created_success.xml new file mode 100644 index 00000000000..0d1fd724cd7 --- /dev/null +++ b/WooCommerce/src/main/res/drawable/blaze_campaign_created_success.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + diff --git a/WooCommerce/src/main/res/drawable/ic_configuration.xml b/WooCommerce/src/main/res/drawable/ic_configuration.xml index 438f7a10c8c..f32d5b0798f 100644 --- a/WooCommerce/src/main/res/drawable/ic_configuration.xml +++ b/WooCommerce/src/main/res/drawable/ic_configuration.xml @@ -1,6 +1,6 @@ - + android:width="32dp" xmlns:android="http://schemas.android.com/apk/res/android"> diff --git a/WooCommerce/src/main/res/drawable/ic_timer.xml b/WooCommerce/src/main/res/drawable/ic_timer.xml new file mode 100644 index 00000000000..46f7bb45393 --- /dev/null +++ b/WooCommerce/src/main/res/drawable/ic_timer.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/WooCommerce/src/main/res/layout/fragment_product_list.xml b/WooCommerce/src/main/res/layout/fragment_product_list.xml index 4b7db2a5d13..67bf235e362 100644 --- a/WooCommerce/src/main/res/layout/fragment_product_list.xml +++ b/WooCommerce/src/main/res/layout/fragment_product_list.xml @@ -7,14 +7,25 @@ android:layout_height="match_parent" tools:context="com.woocommerce.android.ui.products.ProductListFragment"> + + + app:layout_constraintTop_toBottomOf="@id/toolbar"> + app:layout_constraintStart_toEndOf="@+id/two_pane_layout_guideline" + app:layout_constraintTop_toTopOf="parent" /> diff --git a/WooCommerce/src/main/res/layout/view_expandable_notice_card.xml b/WooCommerce/src/main/res/layout/view_expandable_notice_card.xml index 6a318177d64..d69e977290b 100644 --- a/WooCommerce/src/main/res/layout/view_expandable_notice_card.xml +++ b/WooCommerce/src/main/res/layout/view_expandable_notice_card.xml @@ -24,61 +24,57 @@ tools:textOff="@string/product_wip_title" tools:textOn="@string/product_wip_title_m5" /> - - - + tools:text="@string/error_chooser_photo" /> - - + + - - + + - + + - diff --git a/WooCommerce/src/main/res/menu/menu_analytics_settings.xml b/WooCommerce/src/main/res/menu/menu_analytics_settings.xml new file mode 100644 index 00000000000..7431dcc8ddc --- /dev/null +++ b/WooCommerce/src/main/res/menu/menu_analytics_settings.xml @@ -0,0 +1,10 @@ + + + + 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 2c1aabd5eb2..dfb63ceab41 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 @@ -14,6 +14,10 @@ android:name="productId" android:defaultValue="-1L" app:argType="long" /> + @@ -50,6 +54,10 @@ android:name="productId" android:defaultValue="-1L" app:argType="long" /> + @@ -85,10 +93,8 @@ android:defaultValue="" app:argType="string" /> + android:name="adImage" + app:argType="com.woocommerce.android.ui.blaze.BlazeRepository$BlazeCampaignImage" /> + - + + android:label="BlazeCampaignCreationAdDestinationParametersFragment"> + android:name="destinationParameters" + app:argType="com.woocommerce.android.ui.blaze.BlazeRepository$DestinationParameters" /> + android:name="campaignDetails" + app:argType="com.woocommerce.android.ui.blaze.BlazeRepository$CampaignDetails" /> + + android:label="BlazeCampaignPaymentMethodsListFragment"> @@ -162,4 +176,8 @@ app:argType="string" app:nullable="true" /> + diff --git a/WooCommerce/src/main/res/navigation/nav_graph_main.xml b/WooCommerce/src/main/res/navigation/nav_graph_main.xml index f9daa8b705a..06b6edc71a5 100644 --- a/WooCommerce/src/main/res/navigation/nav_graph_main.xml +++ b/WooCommerce/src/main/res/navigation/nav_graph_main.xml @@ -200,7 +200,7 @@ + android:label="scan_to_update_inventory" /> + + - + android:name="mode" + app:argType="com.woocommerce.android.ui.products.ProductDetailFragment$Mode" /> + app:argType="string[]" + app:nullable="true" /> - + android:name="mode" + app:argType="com.woocommerce.android.ui.products.ProductDetailFragment$Mode" /> + الاطلاع على التقرير + الصفحة الرئيسية للموقع + عنوان URL للمنتج + معايير عناوين URL + عنوان URL للوجهة + الإدخال يدويًا + لم يتم العثور على الموقع.\nترجى المحاولة مجددًا. + بدء كتابة البلد أو الولاية أو المدينة للاطلاع على الخيارات المتوافرة + يعني النقر على \"إرسال الحملة\" أنك توافق على <a href=\'termsOfService\'><u>شروط الخدمة</u></a> و<a href=\'advertisingPolicy\'><u>سياسة الإعلانات</u></a>، والسماح بطريقة الدفع الخاصة بك ليتم تحصيلها من الميزانية والمدة التي اخترتها. <a href=\'learnMore\'><u>تعرّف على المزيد</u></a> حول كيفية عمل الميزانيات والمدفوعات الخاصة بالتدوينات التي تم الترويج لها. + إرسال حملتك + فشل تحميل طرق الدفع، يرجى إعادة المحاولة عن طريق النقر هنا! + إضافة طريقة الدفع + تحميل طرق الدفع + الإجمالي + حملة Blaze + إجماليات المدفوعات + الدفع + البحث عن المواقع يتعذر تخزين الإيصال يتعذر تنزيل الإيصال يتعذر الكشف عن أي تطبيق يمكن مشاركة الإيصال عليه diff --git a/WooCommerce/src/main/res/values-de/strings.xml b/WooCommerce/src/main/res/values-de/strings.xml index 7abce0b6d92..9e1ac55807e 100644 --- a/WooCommerce/src/main/res/values-de/strings.xml +++ b/WooCommerce/src/main/res/values-de/strings.xml @@ -1,11 +1,29 @@ + Bericht anzeigen + Die Startseite der Website + Die Produkt-URL + URL-Parameter + Ziel-URL + Manuell eingeben + Kein Ort gefunden.\nBitte versuche es erneut. + Beginne mit der Eingabe des Landes, Bundeslandes oder der Stadt, um verfügbare Optionen anzuzeigen + Indem du auf „Kampagne absenden“ klickst, akzeptierst du die <a href=\'termsOfService\'><u>Geschäftsbedingungen</u></a> sowie die <a href=\'advertisingPolicy\'><u>Werberichtlinien</u></a> und erlaubst, dass deine Zahlungsmethode für die von dir gewählte Dauer mit dem von dir gewählten Budget belastet wird. <a href=\'learnMore\'><u>Hier findest du weitere Informationen</u></a> zu Budgets und Zahlungen für beworbene Beiträge. + Kampagne absenden + Fehler beim Laden der Zahlungsmethoden. Versuche es erneut, indem du hier klickst! + Zahlungsmethode hinzufügen + Zahlungsmethoden werden geladen + Gesamt + Blaze-Kampagne + Zahlungsübersicht + Zahlung + Orte suchen Beleg konnte nicht gespeichert werden Beleg konnte nicht heruntergeladen werden Es wurde keine Anwendung gefunden, mit der der Beleg geteilt werden kann @@ -988,7 +1006,7 @@ Language: de Der integrierte Kartenleser ist bereit Kartenlesegerät Tap To Pay - Umrechnungskurs + Zielabschlussrate Sitzungen Keine Sitzungen für diesen Zeitraum Verglichen mit diff --git a/WooCommerce/src/main/res/values-es/strings.xml b/WooCommerce/src/main/res/values-es/strings.xml index 8231ee8e486..0ffe8609e61 100644 --- a/WooCommerce/src/main/res/values-es/strings.xml +++ b/WooCommerce/src/main/res/values-es/strings.xml @@ -1,11 +1,29 @@ + Ver informe + La página de inicio del sitio + La URL del producto + Parámetros de la URL + URL de destino + Introducir manualmente + No se ha encontrado la ubicación.\nInténtalo de nuevo. + Empieza a escribir el nombre de un país, estado o ciudad para ver las opciones disponibles + Al hacer clic en \"Enviar campaña\" aceptas las <a href=\'termsOfService\'><u>condiciones del servicio</u></a> y la <a href=\'advertisingPolicy\'><u>política de publicidad</u></a> y nos autorizas a que carguemos a través de tu método de pago los cobros pertinentes según el presupuesto y la duración que hayas elegido. <a href=\'learnMore\'><u>Obtén más información</u></a> sobre el funcionamiento de los presupuestos y los pagos de las entradas promocionadas. + Enviar campaña + Error al cargar los métodos de pago; haz clic aquí para volver a intentarlo. + Añadir método de pago + Cargando métodos de pago + Total + Campaña de Blaze + Cantidad total del pago + Pago + Buscar ubicaciones No se ha podido guardar el recibo No se ha podido descargar el recibo No se ha detectado ninguna aplicación con la que se pueda compartir el recibo diff --git a/WooCommerce/src/main/res/values-fr/strings.xml b/WooCommerce/src/main/res/values-fr/strings.xml index bfa505848ae..8c3fb2d7e18 100644 --- a/WooCommerce/src/main/res/values-fr/strings.xml +++ b/WooCommerce/src/main/res/values-fr/strings.xml @@ -1,11 +1,29 @@ + Voir le rapport + Page d’accueil du site + URL du produit + Paramètres d’URL + URL de destination + Saisir manuellement + Aucun lieu trouvé.\nVeuillez réessayer. + Saisir les premières lettres du pays, de l’État / la région ou de la ville pour voir les options disponibles + En cliquant sur « Soumettre la campagne », vous acceptez les <a href=\'termsOfService\'><u>conditions d’utilisation</u></a> et la <a href=\'advertisingPolicy\'><u>politique en matière de publicité</u></a>. Vous autorisez également que le budget et la durée que vous choisissez soient facturés à l’aide de votre mode de paiement. <a href=\'learnMore\'><u>En savoir plus</u></a> sur le fonctionnement des budgets et des paiements pour les articles promus. + Envoyer la campagne + Le chargement des moyens de paiement a échoué, veuillez réessayer en cliquant ici ! + Ajouter un moyen de paiement + Chargement des moyens de paiement + Total + Campagne Blaze + Totaux des paiements + Paiement + Rechercher des lieux Impossible d’enregistrer le reçu Impossible de télécharger le reçu Impossible de détecter une application compatible avec le partage du reçu diff --git a/WooCommerce/src/main/res/values-he/strings.xml b/WooCommerce/src/main/res/values-he/strings.xml index 191145b58f5..d51ebe5fb86 100644 --- a/WooCommerce/src/main/res/values-he/strings.xml +++ b/WooCommerce/src/main/res/values-he/strings.xml @@ -1,11 +1,29 @@ + להציג את הדוח + עמוד הבית של האתר + כתובת ה-URL של המוצר + פרמטרים של כתובת URL + כתובת URL ליעד + יש להזין באופן ידני + לא נמצא מיקום.\nיש לנסות שוב. + יש להתחיל בהקלדת מדינה או עיר כדי להציג את האפשרויות הזמינות + לחיצה על \'לשלוח את הקמפיין\' נחשבת הסכמה מצדך ל<a href=\'termsOfService\'><u>תנאי השימוש</u></a> <a href=\'advertisingPolicy\'><u>ולמדיניות הפרסום</u></a> שלנו ומאשרת לנו לחייב את אמצעי התשלום שלך בהתאם לתקציב ולמשך הזמן שבחרת. ניתן <a href=\'learnMore\'><u>לקבל מידע נוסף</u></a> לגבי האופן שבו תקציבים ותשלומים עובדים בפוסטים מקודמים. + שליחת קמפיין + טעינת אמצעי התשלום נכשלה, יש ללחוץ כאן כדי לנסות שוב! + להוסיף אמצעי תשלום + טוען אמצעי תשלום + סכום כולל + קמפיין של Blaze + סכומים כוללים לתשלום + תשלום + לחפש מיקומים לא ניתן לאחסן את הקבלה לא ניתן להוריד את הקבלה לא הצלחנו לזהות אפליקציה שאליה אפשר לשתף את הקבלה diff --git a/WooCommerce/src/main/res/values-id/strings.xml b/WooCommerce/src/main/res/values-id/strings.xml index 792e01ffa65..b1bac59babd 100644 --- a/WooCommerce/src/main/res/values-id/strings.xml +++ b/WooCommerce/src/main/res/values-id/strings.xml @@ -1,11 +1,29 @@ + Lihat Laporan + Beranda situs + URL produk + Parameter URL + URL Tujuan + Masukkan secara manual + Lokasi tidak ditemukan.\nCoba lagi. + Mulai ketik negara, provinsi, atau kota untuk melihat pilihan yang tersedia + Dengan mengeklik \"Kirim kampanye\", Anda menyetujui <a href=\'termsOfService\'><u>Ketentuan Layanan</u></a> dan <a href=\'advertisingPolicy\'><u>Kebijakan Iklan</u></a> serta menyepakati metode pembayaran untuk penagihan sesuai anggaran dan durasi yang dipilih. <a href=\'learnMore\'><u>Baca selengkapnya</u></a> tentang cara kerja anggaran dan pembayaran untuk Pos Dipromosikan. + Kirim kampanye + Pemuatan metode pembayaran gagal, coba lagi dengan klik di sini! + Tambahkan metode pembayaran + Memuat metode pembayaran + Total + Kampanye Blaze + Total pembayaran + Pembayaran + Cari lokasi Tidak dapat menyimpan tanda terima Tidak dapat mengunduh tanda terima Tidak dapat mendeteksi aplikasi yang dapat digunakan untuk membagikan tanda terima diff --git a/WooCommerce/src/main/res/values-it/strings.xml b/WooCommerce/src/main/res/values-it/strings.xml index 6d8cefe192f..945bcc6aa97 100644 --- a/WooCommerce/src/main/res/values-it/strings.xml +++ b/WooCommerce/src/main/res/values-it/strings.xml @@ -1,11 +1,29 @@ + Visualizza report + Home del sito + URL del prodotto + Parametri URL + URL di destinazione + Inserisci manualmente + Non è stata trovata alcuna posizione.\nRiprova. + Inizia a digitare il Paese, lo Stato o la città per visualizzare le opzioni disponibili + Facendo clic su \"Invia campagna\" accetti i <a href=\'termsOfService\'><u>Termini di servizio</u></a> e la <a href=\'advertisingPolicy\'><u>Politica sulla pubblicità</u></a> e autorizzi gli addebiti tramite il tuo metodo di pagamento per il budget e la durata scelti. <a href=\'learnMore\'><u>Scopri di più</u></a> sui budget e sui pagamenti per il lavoro legato ad Articoli promossi. + Invia campagna + Il caricamento dei metodi di pagamento non è riuscito, riprova cliccando qui! + Aggiungi un metodo di pagamento + Caricamento dei metodi di pagamento + Totale + Campagna Blaze + Totali pagamenti + Pagamento + Cerca posizioni Impossibile salvare la ricevuta Impossibile scaricare la ricevuta Impossibile individuare un\'applicazione con cui la ricevuta possa essere condivisa diff --git a/WooCommerce/src/main/res/values-ja/strings.xml b/WooCommerce/src/main/res/values-ja/strings.xml index 4db1e651a27..66436d2c619 100644 --- a/WooCommerce/src/main/res/values-ja/strings.xml +++ b/WooCommerce/src/main/res/values-ja/strings.xml @@ -1,11 +1,29 @@ + レポートを見る + サイトホーム + 商品 URL + URL パラメーター + リンク先 URL + 場所が見つかりませんでした。\nもう一度お試しください。 + 国、州、都市を入力すると、選択可能なオプションが表示されます + 「キャンペーンを送信」をクリックすると、<a href=\'termsOfService\'><u>利用規約</u></a>と<a href=\'advertisingPolicy\'><u>広告ポリシー</u></a>に同意し、選択した予算と期間での支払いの決済方法を許可したと見なされます。 宣伝された投稿における予算と支払いの仕組みについて詳しくは<a href=\'learnMore\'><u>こちら</u></a>をご覧ください。 + 手動で入力 + キャンペーンを送信 + 支払い方法を追加 + お支払い方法を読み込んでいます… + 合計 + Blaze キャンペーン + 支払総額 + 支払い + 位置情報を検索 + 支払い方法の読み込みに失敗しました。こちらをクリックしてもう一度お試しください。 領収書を保管できません 領収書をダウンロードできません レシートを共有できるアプリケーションを検出できません diff --git a/WooCommerce/src/main/res/values-ko/strings.xml b/WooCommerce/src/main/res/values-ko/strings.xml index c049b04fb0a..3292043c110 100644 --- a/WooCommerce/src/main/res/values-ko/strings.xml +++ b/WooCommerce/src/main/res/values-ko/strings.xml @@ -1,11 +1,29 @@ + 보고서 보기 + 사이트 홈 + 상품 URL + URL 파라미터 + 대상 URL + 수동으로 입력 + 위치를 찾을 수 없습니다.\n다시 시도해 주세요. + 국가, 광역시/도 또는 시/군/구 입력을 시작하여 사용 가능한 옵션을 참조하세요. + \"캠페인 제출\"을 클릭하면 <a href=\'termsOfService\'><u>서비스 약관</u></a>과 <a href=\'advertisingPolicy\'><u>광고 정책</u></a>에 동의하고 선택한 예산과 기간에 대해 결제 수단으로 요금이 청구되도록 승인하는 것입니다. 홍보한 글의 예산 및 결제 방식에 대해 <a href=\'learnMore\'><u>자세히 알아보세요</u></a>. + 캠페인 제출 + 결제 수단을 로드하지 못했습니다. 여기를 클릭하여 다시 시도해 주세요. + 결제 수단 추가 + 결제 수단 로드 중 + 합계 + Blaze 캠페인 + 결제 합계 + 결제 + 위치 검색 영수증을 저장할 수 없습니다. 영수증을 다운로드할 수 없습니다. 영수증을 공유할 앱을 찾을 수 없습니다. diff --git a/WooCommerce/src/main/res/values-night/colors_base.xml b/WooCommerce/src/main/res/values-night/colors_base.xml index 54a65bbe17c..a6145bf9a28 100644 --- a/WooCommerce/src/main/res/values-night/colors_base.xml +++ b/WooCommerce/src/main/res/values-night/colors_base.xml @@ -49,6 +49,8 @@ @color/woo_black_90_alpha_038 @color/woo_gray_40 @color/woo_black_900 + @color/woo_black_900 + @color/woo_black + Rapport inzien + De startpagina van de site + De product-URL + URL-parameters + URL van bestemming + Enter manually + Geen locatie gevonden.\nProbeer het opnieuw. + Begin met het typen van land, provincie of plaats om de beschikbare opties te bekijken + Door op \'Campagne indienen\' te klikken ga je akkoord met de <a href=\'termsOfService\'><u>Servicevoorwaarden</u></a> en het <a href=\'advertisingPolicy\'><u>Reclamebeleid</u></a>, en geef er goedkeuring voor dat er via je betaalmethode kosten in rekening worden gebracht voor het bedrag en de periode die je kiest. <a href=\'learnMore\'><u>Kom meer te weten</u></a> over hoe budgetten en betalingen voor Gepromote berichten werken. + Campagne indienen + Loading payment methods failed, please retry by clicking here! + Voeg betalingsmethode toe + Betaalmethoden laden… + Totaal + Blaze-campagne + Totale betalingen + Betaling + Zoek locaties Kan het betalingsbewijs niet opslaan Kan het betalingsbewijs niet downloaden Kan geen applicatie detecteren waarmee het betalingsbewijs gedeeld kan worden diff --git a/WooCommerce/src/main/res/values-pt-rBR/strings.xml b/WooCommerce/src/main/res/values-pt-rBR/strings.xml index c8b4dbbc643..4d6a9aeb521 100644 --- a/WooCommerce/src/main/res/values-pt-rBR/strings.xml +++ b/WooCommerce/src/main/res/values-pt-rBR/strings.xml @@ -1,11 +1,29 @@ + Ver relatório + Página inicial + URL do produto + Parâmetros de URL + URL de destino + Inserir manualmente + Nenhuma localização encontrada.\nTente novamente. + Comece a digitar o país, estado ou cidade para ver as opções disponíveis + Ao clicar em \"Enviar campanha\", você concorda com os <a href=\'termsOfService\'><u>Termos de serviço</u></a> e a <a href=\'advertisingPolicy\'><u>Política de publicidade</u></a> e autoriza a cobrança na sua forma de pagamento de acordo com o orçamento e a duração escolhidos. <a href=\'learnMore\'><u>Saiba mais</u></a> sobre como funcionam os orçamentos e pagamentos de posts promovidos. + Enviar campanha + Falha ao carregar formas de pagamento, tente novamente clicando aqui! + Adicionar forma de pagamento + Carregando formas de pagamento + Total + Campanha no Blaze + Totais de pagamentos + Pagamento + Pesquisar localizações Não foi possível armazenar o recibo Não foi possível fazer download do recibo Não foi possível detectar nenhuma aplicação com a qual o recibo possa ser compartilhado diff --git a/WooCommerce/src/main/res/values-ru/strings.xml b/WooCommerce/src/main/res/values-ru/strings.xml index 83dec5e0d0f..10f705ed5f0 100644 --- a/WooCommerce/src/main/res/values-ru/strings.xml +++ b/WooCommerce/src/main/res/values-ru/strings.xml @@ -1,11 +1,29 @@ + Смотреть отчет + Главная страница сайта + URL-адрес товара + Параметры URL-адреса + URL-адрес назначения + Ввести вручную + Местоположение не найдено.\nПовторите попытку. + Начните набирать название страны, штата или города, чтобы увидеть доступные варианты + Нажимая «Отправить кампанию», вы принимаете <a href=\'termsOfService\'><u>условия предоставления услуг</u></a> и <a href=\'advertisingPolicy\'><u>политику публикации рекламы</u></a>, а также разрешаете использовать указанный способ оплаты для списания средств за выбранные вами бюджет и период. <a href=\'learnMore\'><u>Подробнее</u></a> о том, как использовать бюджеты и платежи за продвигаемые записи. + Отправить кампанию + Не удалось загрузить способы оплаты. Чтобы повторить попытку, нажмите здесь. + Добавить способ оплаты + Загрузка способов оплаты + Итого + Кампания Blaze + Итоговые суммы платежей + Оплата + Поиск местоположений Не удалось сохранить чек Не удалось загрузить чек Не удалось обнаружить ни одного приложения, в котором можно опубликовать чек diff --git a/WooCommerce/src/main/res/values-sv/strings.xml b/WooCommerce/src/main/res/values-sv/strings.xml index 3c2c3913276..84b4ea43c32 100644 --- a/WooCommerce/src/main/res/values-sv/strings.xml +++ b/WooCommerce/src/main/res/values-sv/strings.xml @@ -1,11 +1,29 @@ + Se rapport + Webbplatsens hem + Produktens URL + URL-parametrar + Mål-URL + Ange manuellt + Ingen plats hittades.\nFörsök igen. + Börja skriva land, delstat eller stad för att se tillgängliga alternativ + Genom att klicka på \"Skicka kampanj\" godkänner du våra <a href=\'termsOfService\'><u>användarvillkor</u></a> och vår <a href=\'advertisingPolicy\'><u>annonspolicy</u></a> och samtycker till att din betalningsmetod debiteras för den budget och den varaktighet som du väljer. <a href=\'learnMore\'><u>Läs mer</u></a> om hur budgetar och betalningar för marknadsförda inlägg fungerar. + Skicka in kampanj + Lägg till betalningsmetod + Laddar in betalningsmetoder + Totalt + Betalning + Sök platser + Blaze-kampanj + Laddning av betalningsmetoder misslyckades. Försök igen genom att klicka här! + Betalning totalt Det gick inte att lagra kvittot Det gick inte att ladda ner kvittot Det gick inte att hitta någon applikation som kvittot kan delas till diff --git a/WooCommerce/src/main/res/values-tr/strings.xml b/WooCommerce/src/main/res/values-tr/strings.xml index 5af3da8ee25..15adcdc5db9 100644 --- a/WooCommerce/src/main/res/values-tr/strings.xml +++ b/WooCommerce/src/main/res/values-tr/strings.xml @@ -1,11 +1,29 @@ + Raporu Göster + Site ana sayfası + Ürün URL\'si + URL parametreleri + Hedef URL + Manuel olarak girin + Konum bulunamadı.\nLütfen tekrar deneyin. + Kullanılabilir seçenekleri görmek için ülke, eyalet veya şehir yazmaya başlayın + \"Kampanyayı gönder\"e tıklayarak <a href=\'termsOfService\'><u>Hizmet Koşullarını</u></a> ve <a href=\'advertisingPolicy\'><u>Reklam Politikasını</u></a> kabul etmiş ve seçtiğiniz bütçe ile süre için ödeme yönteminize yetki vermiş olursunuz. Tanıtılan Yazılar için bütçelerin ve ödemelerin nasıl çalıştığı hakkında <a href=\'learnMore\'><u>daha fazla bilgi edinin</u></a>. + Kampanyayı gönderin + Ödeme yöntemlerini yükleme başarısız oldu, lütfen buraya tıklayarak yeniden deneyin! + Ödeme yöntemi ekleyin + Ödeme yöntemleri yükleniyor + Toplam + Kampanyayı öne çıkarın + Ödeme toplamı + Ödeme + Konum arayın Fatura depolanamıyor Fatura indirilemiyor Faturanın paylaşılabileceği bir uygulama bulunamadı diff --git a/WooCommerce/src/main/res/values-zh-rCN/strings.xml b/WooCommerce/src/main/res/values-zh-rCN/strings.xml index 614d2b5c78f..8499fbce9ac 100644 --- a/WooCommerce/src/main/res/values-zh-rCN/strings.xml +++ b/WooCommerce/src/main/res/values-zh-rCN/strings.xml @@ -1,11 +1,29 @@ + 查看报告 + 站点主页 + 产品 URL + URL 参数 + 未找到位置。\n请重试。 + 开始键入国家/地区、州或城市,查看可用选项 + 目标 URL + 手动输入 + 点击“提交广告活动”即表示您同意<a href=\'termsOfService\'><u>服务条款</u></a>和<a href=\'advertisingPolicy\'><u>广告政策</u></a>,并授权您的付款方式,以便按照您选择的预算和持续时间收费。 <a href=\'learnMore\'><u>详细了解</u></a> Promoted Posts 的预算和付款的运作方式。 + 添加付款方式 + 总计 + Blaze 广告活动 + 付款总额 + 付款 + 搜索位置 + 提交广告活动 + 加载付款方式失败,请点击此处重试! + 正在加载付款方式 无法存储此收据 无法下载此收据 无法检测到任何可共享收据的应用程序 diff --git a/WooCommerce/src/main/res/values-zh-rTW/strings.xml b/WooCommerce/src/main/res/values-zh-rTW/strings.xml index 2585b807829..07a5e1edec1 100644 --- a/WooCommerce/src/main/res/values-zh-rTW/strings.xml +++ b/WooCommerce/src/main/res/values-zh-rTW/strings.xml @@ -1,11 +1,29 @@ + 查看報告 + 網站首頁 + 商品 URL + URL 參數 + 目的地 URL + 手動輸入 + 找不到地點。\n請再試一次。 + 立即輸入國家/地區、州或城市,查看可用選項 + 按一下「提交行銷活動」即表示你同意<a href=\'termsOfService\'><u>服務條款</u></a>和<a href=\'advertisingPolicy\'><u>廣告政策</u></a>,並授權我們根據你選擇的預算和時間長度向你的付款方式扣款。 <a href=\'learnMore\'><u>深入了解</u></a>推廣文章的預算和付款運作方式。 + 提交行銷活動 + 付款方式載入失敗,請按一下此處重試! + 新增付款方式 + 正在載入付款方式 + 總計 + Blaze 行銷活動 + 付款總金額 + 付款 + 搜尋地點 無法儲存收據 無法下載收據 無法偵測任何可分享收據的應用程式 diff --git a/WooCommerce/src/main/res/values/colors_base.xml b/WooCommerce/src/main/res/values/colors_base.xml index f8b1b44bf78..721b4d1e0c5 100644 --- a/WooCommerce/src/main/res/values/colors_base.xml +++ b/WooCommerce/src/main/res/values/colors_base.xml @@ -45,6 +45,8 @@ @color/color_on_surface @color/woo_pink_50 + @color/woo_purple_10 + Blaze campaigns There was an error refreshing the list of campaigns. Please try again later. + @@ -3823,7 +3827,7 @@ Blaze campaign creation celebration --> All set! - The ad has been submistted for approval. We’ll send you a confirmation email once it’s approvied and running. + The ad has been submitted for approval. We’ll send you a confirmation email once it’s approvied and running. Got it @@ -3874,6 +3883,7 @@ Description %d characters remaining Suggested by AI + @@ -3903,6 +3913,7 @@ Promote Start typing country, state or city to see available options No location found.\nPlease try again. + @@ -3913,6 +3924,27 @@ The site home Add parameter Destination: %s + Key + Value + The final URL is too long + The key already exits + + + Ready to Go! + We\'re reviewing your campaign. It\'ll be live within 24 hours. Exciting times ahead for your sales! + Done + Creating your campaign + Error creating campaign + Error creating campaign + Please try again, or contact support for assistance. + Get support + Cancel campaign + Failed to upload campaign image. + Failed to fetch campaign image details + Something’s not quite right.\nWe couldn\'t create your campaign. + diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/blaze/creation/BlazeCampaignCreationDispatcherTests.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/blaze/creation/BlazeCampaignCreationDispatcherTests.kt index a030bf71c89..67119069280 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/blaze/creation/BlazeCampaignCreationDispatcherTests.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/blaze/creation/BlazeCampaignCreationDispatcherTests.kt @@ -1,6 +1,8 @@ package com.woocommerce.android.ui.blaze.creation +import com.woocommerce.android.analytics.AnalyticsTrackerWrapper import com.woocommerce.android.ui.blaze.BlazeRepository +import com.woocommerce.android.ui.blaze.BlazeUrlsHelper.BlazeFlowSource import com.woocommerce.android.ui.blaze.creation.BlazeCampaignCreationDispatcher.BlazeCampaignCreationDispatcherEvent import com.woocommerce.android.ui.products.ProductListRepository import com.woocommerce.android.ui.products.ProductStatus @@ -18,6 +20,7 @@ import org.wordpress.android.fluxc.store.WCProductStore.ProductSorting class BlazeCampaignCreationDispatcherTests : BaseUnitTest() { private val productListRepository: ProductListRepository = mock() private val blazeRepository: BlazeRepository = mock() + private val analyticsTracker: AnalyticsTrackerWrapper = mock() private lateinit var dispatcher: BlazeCampaignCreationDispatcher @@ -28,7 +31,8 @@ class BlazeCampaignCreationDispatcherTests : BaseUnitTest() { dispatcher = BlazeCampaignCreationDispatcher( blazeRepository = blazeRepository, productListRepository = productListRepository, - coroutineDispatchers = coroutinesTestRule.testDispatchers + coroutineDispatchers = coroutinesTestRule.testDispatchers, + analyticsTracker = analyticsTracker ) } @@ -39,9 +43,14 @@ class BlazeCampaignCreationDispatcherTests : BaseUnitTest() { } var event: BlazeCampaignCreationDispatcherEvent? = null - dispatcher.startCampaignCreation { event = it } + dispatcher.startCampaignCreation(source = BlazeFlowSource.MY_STORE_SECTION) { event = it } - assertThat(event).isEqualTo(BlazeCampaignCreationDispatcherEvent.ShowBlazeCampaignCreationIntro(null)) + assertThat(event).isEqualTo( + BlazeCampaignCreationDispatcherEvent.ShowBlazeCampaignCreationIntro( + productId = null, + blazeSource = BlazeFlowSource.MY_STORE_SECTION + ) + ) } @Test @@ -58,7 +67,7 @@ class BlazeCampaignCreationDispatcherTests : BaseUnitTest() { } var event: BlazeCampaignCreationDispatcherEvent? = null - dispatcher.startCampaignCreation { event = it } + dispatcher.startCampaignCreation(source = BlazeFlowSource.MY_STORE_SECTION) { event = it } assertThat(event).isEqualTo(BlazeCampaignCreationDispatcherEvent.ShowProductSelectorScreen) } @@ -77,9 +86,17 @@ class BlazeCampaignCreationDispatcherTests : BaseUnitTest() { } var event: BlazeCampaignCreationDispatcherEvent? = null - dispatcher.startCampaignCreation(productId = 1L) { event = it } - - assertThat(event).isEqualTo(BlazeCampaignCreationDispatcherEvent.ShowBlazeCampaignCreationForm(1L)) + dispatcher.startCampaignCreation( + source = BlazeFlowSource.MY_STORE_SECTION, + productId = 1L + ) { event = it } + + assertThat(event).isEqualTo( + BlazeCampaignCreationDispatcherEvent.ShowBlazeCampaignCreationForm( + productId = 1L, + blazeSource = BlazeFlowSource.MY_STORE_SECTION + ) + ) } @Test @@ -96,8 +113,13 @@ class BlazeCampaignCreationDispatcherTests : BaseUnitTest() { } var event: BlazeCampaignCreationDispatcherEvent? = null - dispatcher.startCampaignCreation { event = it } - - assertThat(event).isEqualTo(BlazeCampaignCreationDispatcherEvent.ShowBlazeCampaignCreationForm(1L)) + dispatcher.startCampaignCreation(source = BlazeFlowSource.MY_STORE_SECTION) { event = it } + + assertThat(event).isEqualTo( + BlazeCampaignCreationDispatcherEvent.ShowBlazeCampaignCreationForm( + productId = 1L, + blazeSource = BlazeFlowSource.MY_STORE_SECTION, + ) + ) } } diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/blaze/creation/intro/BlazeCampaignCreationIntroViewModelTests.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/blaze/creation/intro/BlazeCampaignCreationIntroViewModelTests.kt index bf1cc5afe68..a1e8c2e04de 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/blaze/creation/intro/BlazeCampaignCreationIntroViewModelTests.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/blaze/creation/intro/BlazeCampaignCreationIntroViewModelTests.kt @@ -1,5 +1,7 @@ package com.woocommerce.android.ui.blaze.creation.intro +import com.woocommerce.android.analytics.AnalyticsTrackerWrapper +import com.woocommerce.android.ui.blaze.BlazeUrlsHelper.BlazeFlowSource import com.woocommerce.android.ui.products.ProductListRepository import com.woocommerce.android.ui.products.ProductStatus import com.woocommerce.android.ui.products.ProductTestUtils @@ -16,15 +18,20 @@ import kotlin.test.Test @OptIn(ExperimentalCoroutinesApi::class) class BlazeCampaignCreationIntroViewModelTests : BaseUnitTest() { private val productListRepository: ProductListRepository = mock() + private val analyticsTracker: AnalyticsTrackerWrapper = mock() private lateinit var viewModel: BlazeCampaignCreationIntroViewModel suspend fun setup(productId: Long, setupMocks: suspend () -> Unit = {}) { setupMocks() viewModel = BlazeCampaignCreationIntroViewModel( - savedStateHandle = BlazeCampaignCreationIntroFragmentArgs(productId).toSavedStateHandle(), + savedStateHandle = BlazeCampaignCreationIntroFragmentArgs( + productId = productId, + source = BlazeFlowSource.MY_STORE_SECTION + ).toSavedStateHandle(), productListRepository = productListRepository, - coroutineDispatchers = coroutinesTestRule.testDispatchers + coroutineDispatchers = coroutinesTestRule.testDispatchers, + analyticsTracker = analyticsTracker, ) } @@ -35,7 +42,12 @@ class BlazeCampaignCreationIntroViewModelTests : BaseUnitTest() { viewModel.onContinueClick() val event = viewModel.event.value - assertThat(event).isEqualTo(BlazeCampaignCreationIntroViewModel.ShowCampaignCreationForm(1L)) + assertThat(event).isEqualTo( + BlazeCampaignCreationIntroViewModel.ShowCampaignCreationForm( + productId = 1L, + source = BlazeFlowSource.INTRO_VIEW + ) + ) } @Test @@ -53,7 +65,12 @@ class BlazeCampaignCreationIntroViewModelTests : BaseUnitTest() { viewModel.onContinueClick() val event = viewModel.event.value - assertThat(event).isEqualTo(BlazeCampaignCreationIntroViewModel.ShowCampaignCreationForm(1L)) + assertThat(event).isEqualTo( + BlazeCampaignCreationIntroViewModel.ShowCampaignCreationForm( + productId = 1L, + source = BlazeFlowSource.INTRO_VIEW + ) + ) } @Test @@ -81,7 +98,12 @@ class BlazeCampaignCreationIntroViewModelTests : BaseUnitTest() { viewModel.onProductSelected(1L) val event = viewModel.event.value - assertThat(event).isEqualTo(BlazeCampaignCreationIntroViewModel.ShowCampaignCreationForm(1L)) + assertThat(event).isEqualTo( + BlazeCampaignCreationIntroViewModel.ShowCampaignCreationForm( + productId = 1L, + source = BlazeFlowSource.INTRO_VIEW + ) + ) } @Test diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/blaze/creation/preview/BlazeCampaignCreationPreviewViewModelTests.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/blaze/creation/preview/BlazeCampaignCreationPreviewViewModelTests.kt new file mode 100644 index 00000000000..47b4484ea2a --- /dev/null +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/blaze/creation/preview/BlazeCampaignCreationPreviewViewModelTests.kt @@ -0,0 +1,489 @@ +package com.woocommerce.android.ui.blaze.creation.preview + +import com.woocommerce.android.R +import com.woocommerce.android.extensions.formatToMMMdd +import com.woocommerce.android.model.UiString +import com.woocommerce.android.ui.blaze.BlazeRepository +import com.woocommerce.android.ui.blaze.BlazeRepository.BlazeCampaignImage +import com.woocommerce.android.ui.blaze.BlazeUrlsHelper.BlazeFlowSource +import com.woocommerce.android.ui.blaze.Device +import com.woocommerce.android.ui.blaze.Interest +import com.woocommerce.android.ui.blaze.Language +import com.woocommerce.android.ui.blaze.Location +import com.woocommerce.android.ui.blaze.creation.preview.BlazeCampaignCreationPreviewViewModel.AdDetailsUi +import com.woocommerce.android.ui.blaze.creation.targets.BlazeTargetType +import com.woocommerce.android.util.CurrencyFormatter +import com.woocommerce.android.util.captureValues +import com.woocommerce.android.util.getOrAwaitValue +import com.woocommerce.android.util.runAndCaptureValues +import com.woocommerce.android.viewmodel.BaseUnitTest +import com.woocommerce.android.viewmodel.ResourceProvider +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.advanceUntilIdle +import org.assertj.core.api.Assertions.assertThat +import org.mockito.kotlin.any +import org.mockito.kotlin.anyVararg +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.math.BigDecimal +import java.util.Date +import kotlin.test.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class BlazeCampaignCreationPreviewViewModelTests : BaseUnitTest() { + companion object { + private const val PRODUCT_ID = 1L + private val defaultCampaignDetails = BlazeRepository.CampaignDetails( + productId = PRODUCT_ID, + tagLine = "", + description = "", + budget = BlazeRepository.Budget( + totalBudget = 10f, + spentBudget = 0f, + currencyCode = "$", + durationInDays = 7, + startDate = Date() + ), + campaignImage = BlazeCampaignImage.None, + destinationParameters = BlazeRepository.DestinationParameters( + targetUrl = "http://test_url", + parameters = emptyMap() + ), + targetingParameters = BlazeRepository.TargetingParameters() + ) + private val locations = listOf(Location(1, "Location 1"), Location(2, "Location 2")) + private val languages = listOf(Language("en", "English"), Language("es", "Spanish")) + private val interests = listOf(Interest("1", "Interest 1"), Interest("2", "Interest 2")) + private val devices = listOf(Device("1", "Device 1"), Device("2", "Device 2")) + } + + private val currencyFormatter: CurrencyFormatter = mock { + on { formatCurrency(amount = any(), any(), any()) }.doAnswer { it.getArgument(0).toString() } + } + private val resourceProvider: ResourceProvider = mock { + on { getString(any()) } doAnswer { it.getArgument(0).toString() } + on { getString(any(), anyVararg()) } doAnswer { it.arguments.joinToString { it.toString() } } + } + private val blazeRepository: BlazeRepository = mock { + onBlocking { generateDefaultCampaignDetails(PRODUCT_ID) } doReturn defaultCampaignDetails + on { observeDevices() } doReturn flowOf(devices) + on { observeInterests() } doReturn flowOf(interests) + on { observeLanguages() } doReturn flowOf(languages) + } + private lateinit var viewModel: BlazeCampaignCreationPreviewViewModel + + suspend fun setup(prepareMocks: suspend () -> Unit = {}) { + prepareMocks() + viewModel = BlazeCampaignCreationPreviewViewModel( + savedStateHandle = BlazeCampaignCreationPreviewFragmentArgs( + source = BlazeFlowSource.CAMPAIGN_LIST, + productId = PRODUCT_ID + ).toSavedStateHandle(), + blazeRepository = blazeRepository, + resourceProvider = resourceProvider, + currencyFormatter = currencyFormatter + ) + } + + @Test + fun `when screen is opened, then fetch ad suggestions`() = testBlocking { + val adSuggestions = listOf( + BlazeRepository.AiSuggestionForAd( + tagLine = "Ad suggestion 1", + description = "Ad suggestion 1 description", + ) + ) + setup { + whenever(blazeRepository.fetchAdSuggestions(PRODUCT_ID)).doSuspendableAnswer { + delay(10) + Result.success(adSuggestions) + } + } + + val states = viewModel.viewState.captureValues() + advanceUntilIdle() + + assertThat(states.first().adDetails).isInstanceOf(AdDetailsUi.Loading::class.java) + assertThat(states.last().adDetails).isInstanceOf(AdDetailsUi.AdDetails::class.java) + assertThat((states.last().adDetails as AdDetailsUi.AdDetails).tagLine) + .isEqualTo(adSuggestions.first().tagLine) + assertThat((states.last().adDetails as AdDetailsUi.AdDetails).description) + .isEqualTo(adSuggestions.first().description) + } + + @Test + fun `when screen is opened, then fetch targeting options`() = testBlocking { + setup { + whenever(blazeRepository.fetchLanguages()).doReturn(Result.success(Unit)) + whenever(blazeRepository.fetchInterests()).doReturn(Result.success(Unit)) + whenever(blazeRepository.fetchDevices()).doReturn(Result.success(Unit)) + } + + advanceUntilIdle() + + verify(blazeRepository).fetchLanguages() + verify(blazeRepository).fetchInterests() + verify(blazeRepository).fetchDevices() + } + + @Test + fun `when screen is opened, then show default campaign details`() = testBlocking { + setup() + + val state = viewModel.viewState.getOrAwaitValue() + + assertThat(state.campaignDetails.budget.displayValue).isEqualTo( + "${ + currencyFormatter.formatCurrency( + amount = defaultCampaignDetails.budget.totalBudget.toBigDecimal(), + currencyCode = defaultCampaignDetails.budget.currencyCode + ) + }, ${ + resourceProvider.getString( + R.string.blaze_campaign_preview_days_duration, + defaultCampaignDetails.budget.durationInDays, + defaultCampaignDetails.budget.startDate.formatToMMMdd() + ) + }" + ) + assertThat(state.campaignDetails.targetDetails).hasSize(4) + assertThat(state.campaignDetails.targetDetails).allMatch { + it.displayValue == resourceProvider.getString(R.string.blaze_campaign_preview_target_default_value) + } + assertThat(state.campaignDetails.destinationUrl.displayValue) + .isEqualTo(defaultCampaignDetails.destinationParameters.targetUrl) + } + + @Test + fun `when tapping on edit ad, then open edit ad screen`() = testBlocking { + setup() + + val event = viewModel.event.runAndCaptureValues { + viewModel.onEditAdClicked() + }.last() + + assertThat(event) + .isInstanceOf(BlazeCampaignCreationPreviewViewModel.NavigateToEditAdScreen::class.java) + } + + @Test + fun `when ad details are changed, then update campaign details`() = testBlocking { + setup() + + val newTagline = "New tagline" + val newDescription = "New description" + val newImage = BlazeCampaignImage.LocalImage("new_image") + val state = viewModel.viewState.runAndCaptureValues { + viewModel.onAdUpdated(newTagline, newDescription, newImage) + }.last() + + val adDetailsUi = state.adDetails as AdDetailsUi.AdDetails + assertThat(adDetailsUi.tagLine).isEqualTo(newTagline) + assertThat(adDetailsUi.description).isEqualTo(newDescription) + assertThat(adDetailsUi.campaignImageUrl).isEqualTo(newImage.uri) + } + + @Test + fun `when tapping on language, then open language selection screen`() = testBlocking { + setup() + + val state = viewModel.viewState.getOrAwaitValue() + val event = viewModel.event.runAndCaptureValues { + val languageTargetUi = state.campaignDetails.targetDetails.first { + it.displayTitle == resourceProvider.getString(R.string.blaze_campaign_preview_details_language) + } + languageTargetUi.onItemSelected.invoke() + }.last() + + assertThat(event) + .isInstanceOf(BlazeCampaignCreationPreviewViewModel.NavigateToTargetSelectionScreen::class.java) + } + + @Test + fun `when languages are changed, then update campaign details`() = testBlocking { + setup() + + val newLanguage = languages.last() + val state = viewModel.viewState.runAndCaptureValues { + viewModel.onTargetSelectionUpdated(BlazeTargetType.LANGUAGE, listOf(newLanguage.code)) + }.last() + + val languageTargetUi = state.campaignDetails.targetDetails.first { + it.displayTitle == resourceProvider.getString(R.string.blaze_campaign_preview_details_language) + } + assertThat(languageTargetUi.displayValue).isEqualTo(newLanguage.name) + } + + @Test + fun `when tapping on interest, then open interest selection screen`() = testBlocking { + setup() + + val state = viewModel.viewState.getOrAwaitValue() + val event = viewModel.event.runAndCaptureValues { + val interestTargetUi = state.campaignDetails.targetDetails.first { + it.displayTitle == resourceProvider.getString(R.string.blaze_campaign_preview_details_interests) + } + interestTargetUi.onItemSelected.invoke() + }.last() + + assertThat(event) + .isInstanceOf(BlazeCampaignCreationPreviewViewModel.NavigateToTargetSelectionScreen::class.java) + } + + @Test + fun `when interests are changed, then update campaign details`() = testBlocking { + setup() + + val newInterest = interests.last() + val state = viewModel.viewState.runAndCaptureValues { + viewModel.onTargetSelectionUpdated(BlazeTargetType.INTEREST, listOf(newInterest.id)) + }.last() + + val interestTargetUi = state.campaignDetails.targetDetails.first { + it.displayTitle == resourceProvider.getString(R.string.blaze_campaign_preview_details_interests) + } + assertThat(interestTargetUi.displayValue).isEqualTo(newInterest.description) + } + + @Test + fun `when tapping on device, then open device selection screen`() = testBlocking { + setup() + + val state = viewModel.viewState.getOrAwaitValue() + val event = viewModel.event.runAndCaptureValues { + val deviceTargetUi = state.campaignDetails.targetDetails.first { + it.displayTitle == resourceProvider.getString(R.string.blaze_campaign_preview_details_devices) + } + deviceTargetUi.onItemSelected.invoke() + }.last() + + assertThat(event) + .isInstanceOf(BlazeCampaignCreationPreviewViewModel.NavigateToTargetSelectionScreen::class.java) + } + + @Test + fun `when devices are changed, then update campaign details`() = testBlocking { + setup() + + val newDevice = devices.last() + val state = viewModel.viewState.runAndCaptureValues { + viewModel.onTargetSelectionUpdated(BlazeTargetType.DEVICE, listOf(newDevice.id)) + }.last() + + val deviceTargetUi = state.campaignDetails.targetDetails.first { + it.displayTitle == resourceProvider.getString(R.string.blaze_campaign_preview_details_devices) + } + assertThat(deviceTargetUi.displayValue).isEqualTo(newDevice.name) + } + + @Test + fun `when tapping on location, then open location selection screen`() = testBlocking { + setup() + + val state = viewModel.viewState.getOrAwaitValue() + val event = viewModel.event.runAndCaptureValues { + val locationTargetUi = state.campaignDetails.targetDetails.first { + it.displayTitle == resourceProvider.getString(R.string.blaze_campaign_preview_details_location) + } + locationTargetUi.onItemSelected.invoke() + }.last() + + assertThat(event) + .isInstanceOf(BlazeCampaignCreationPreviewViewModel.NavigateToTargetLocationSelectionScreen::class.java) + } + + @Test + fun `when locations are changed, then update campaign details`() = testBlocking { + setup() + + val newLocation = locations.first() + val state = viewModel.viewState.runAndCaptureValues { + viewModel.onTargetLocationsUpdated(listOf(newLocation)) + }.last() + + val locationTargetUi = state.campaignDetails.targetDetails.first { + it.displayTitle == resourceProvider.getString(R.string.blaze_campaign_preview_details_location) + } + assertThat(locationTargetUi.displayValue).isEqualTo(newLocation.name) + } + + @Test + fun `when tapping on budget, then open budget selection screen`() = testBlocking { + setup() + + val state = viewModel.viewState.getOrAwaitValue() + val event = viewModel.event.runAndCaptureValues { + state.campaignDetails.budget.onItemSelected.invoke() + }.last() + + assertThat(event).isInstanceOf(BlazeCampaignCreationPreviewViewModel.NavigateToBudgetScreen::class.java) + } + + @Test + fun `when budget changes, then update campaign details`() = testBlocking { + setup() + + val newBudget = BlazeRepository.Budget( + totalBudget = 20f, + spentBudget = 0f, + currencyCode = "$", + durationInDays = 14, + startDate = Date() + ) + val state = viewModel.viewState.runAndCaptureValues { + viewModel.onBudgetAndDurationUpdated(newBudget) + }.last() + + assertThat(state.campaignDetails.budget.displayValue).isEqualTo( + "${ + currencyFormatter.formatCurrency( + amount = newBudget.totalBudget.toBigDecimal(), + currencyCode = newBudget.currencyCode + ) + }, ${ + resourceProvider.getString( + R.string.blaze_campaign_preview_days_duration, + newBudget.durationInDays, + newBudget.startDate.formatToMMMdd() + ) + }" + ) + } + + @Test + fun `when tapping on destination url, then open destination url screen`() = testBlocking { + setup() + + val state = viewModel.viewState.getOrAwaitValue() + val event = viewModel.event.runAndCaptureValues { + state.campaignDetails.destinationUrl.onItemSelected.invoke() + }.last() + + assertThat(event).isInstanceOf(BlazeCampaignCreationPreviewViewModel.NavigateToAdDestinationScreen::class.java) + } + + @Test + fun `when destination changes, then update campaign details`() = testBlocking { + setup() + + val newDestination = BlazeRepository.DestinationParameters( + targetUrl = "http://new_url", + parameters = mapOf("key" to "value") + ) + val state = viewModel.viewState.runAndCaptureValues { + viewModel.onDestinationUpdated(newDestination) + }.last() + + assertThat(state.campaignDetails.destinationUrl.displayValue).isEqualTo(newDestination.fullUrl) + } + + @Test + fun `given image is missing, when tapping on confirm, then show a dialog`() = testBlocking { + setup() + + val state = viewModel.viewState.runAndCaptureValues { + viewModel.onConfirmClicked() + }.last() + + assertThat(state.dialogState).isNotNull + assertThat(state.dialogState!!.message).isEqualTo( + UiString.UiStringRes(R.string.blaze_campaign_preview_missing_image_dialog_text) + ) + assertThat(state.dialogState!!.positiveButton!!.text).isEqualTo( + UiString.UiStringRes(R.string.blaze_campaign_preview_missing_image_dialog_positive_button) + ) + assertThat(state.dialogState!!.negativeButton!!.text).isEqualTo( + UiString.UiStringRes(R.string.cancel) + ) + } + + @Test + fun `given image is missing, when tapping on add image of confirm dialog, then edit ad screen`() = testBlocking { + setup() + + val dialogState = viewModel.viewState.runAndCaptureValues { + viewModel.onConfirmClicked() + }.last().dialogState!! + val event = viewModel.event.runAndCaptureValues { + dialogState.positiveButton!!.onClick.invoke() + }.last() + val viewState = viewModel.viewState.getOrAwaitValue() + + assertThat(viewState.dialogState).isNull() + assertThat(event).isInstanceOf(BlazeCampaignCreationPreviewViewModel.NavigateToEditAdScreen::class.java) + } + + @Test + fun `given ad details missing, when tapping on confirm, then show a dialog`() = testBlocking { + setup { + whenever(blazeRepository.generateDefaultCampaignDetails(PRODUCT_ID)).doReturn( + defaultCampaignDetails.copy( + campaignImage = BlazeCampaignImage.LocalImage("image") + ) + ) + } + + val state = viewModel.viewState.runAndCaptureValues { + viewModel.onConfirmClicked() + }.last() + + assertThat(state.dialogState).isNotNull + assertThat(state.dialogState!!.message).isEqualTo( + UiString.UiStringRes(R.string.blaze_campaign_preview_missing_content_dialog_text) + ) + assertThat(state.dialogState!!.positiveButton!!.text).isEqualTo( + UiString.UiStringRes(R.string.blaze_campaign_preview_missing_content_dialog_positive_button) + ) + assertThat(state.dialogState!!.negativeButton!!.text).isEqualTo( + UiString.UiStringRes(R.string.cancel) + ) + } + + @Test + fun `given ad details missing, when tapping on add content of confirm dialog, then edit ad screen`() = + testBlocking { + setup { + whenever(blazeRepository.generateDefaultCampaignDetails(PRODUCT_ID)).doReturn( + defaultCampaignDetails.copy( + campaignImage = BlazeCampaignImage.LocalImage("image") + ) + ) + } + + val dialogState = viewModel.viewState.runAndCaptureValues { + viewModel.onConfirmClicked() + }.last().dialogState!! + val event = viewModel.event.runAndCaptureValues { + dialogState.positiveButton!!.onClick.invoke() + }.last() + val viewState = viewModel.viewState.getOrAwaitValue() + + assertThat(viewState.dialogState).isNull() + assertThat(event).isInstanceOf(BlazeCampaignCreationPreviewViewModel.NavigateToEditAdScreen::class.java) + } + + @Test + fun `given campaign requirements met, when tapping on confirm, then open payment summary`() = testBlocking { + setup { + whenever(blazeRepository.generateDefaultCampaignDetails(PRODUCT_ID)).doReturn( + defaultCampaignDetails.copy( + campaignImage = BlazeCampaignImage.LocalImage("image"), + tagLine = "tagline", + description = "description" + ) + ) + } + + val event = viewModel.event.runAndCaptureValues { + viewModel.onConfirmClicked() + }.last() + + assertThat(event).isInstanceOf(BlazeCampaignCreationPreviewViewModel.NavigateToPaymentSummary::class.java) + } +} diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/payments/receipt/PaymentReceiptHelperTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/payments/receipt/PaymentReceiptHelperTest.kt index 18b25902f08..a0c2b3e2173 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/payments/receipt/PaymentReceiptHelperTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/payments/receipt/PaymentReceiptHelperTest.kt @@ -113,6 +113,30 @@ class PaymentReceiptHelperTest : BaseUnitTest() { assertThat(result.getOrThrow()).isEqualTo("url") } + @Test + fun `given version 8_7_0_10 site and saved url, when getReceiptUrl, then url returned`() = testBlocking { + // GIVEN + val site = selectedSite.get() + val plugin = mock { + on { version }.thenReturn("8.7.0.10") + } + whenever( + wooCommerceStore.getSitePlugin( + selectedSite.get(), + WooCommerceStore.WooPlugin.WOO_CORE + ) + ).thenReturn(plugin) + whenever(orderStore.fetchOrdersReceipt(site, 1, expirationDays = 2)).thenReturn( + WooPayload(OrderReceiptResponse("url", "date")) + ) + + // WHEN + val result = helper.getReceiptUrl(1) + + // THEN + assertThat(result.getOrThrow()).isEqualTo("url") + } + @Test fun `given version 8_7_0 site and remote call fails, when getReceiptUrl, then failure returned`() = testBlocking { // GIVEN diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/ProductDetailViewModelGenerateVariationFlowTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/ProductDetailViewModelGenerateVariationFlowTest.kt index 0f3b2eca05e..7b4ac7640d7 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/ProductDetailViewModelGenerateVariationFlowTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/ProductDetailViewModelGenerateVariationFlowTest.kt @@ -63,7 +63,7 @@ class ProductDetailViewModelGenerateVariationFlowTest : BaseUnitTest() { } private var savedState: SavedStateHandle = - ProductDetailFragmentArgs(remoteProductId = PRODUCT_REMOTE_ID, isAddProduct = false).toSavedStateHandle() + ProductDetailFragmentArgs(mode = ProductDetailFragment.Mode.ShowProduct(PRODUCT_REMOTE_ID)).toSavedStateHandle() private val parameterRepository: ParameterRepository = mock() private val generateVariationCandidates: GenerateVariationCandidates = mock() diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/ProductDetailViewModelTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/ProductDetailViewModelTest.kt index c3dff58111e..7372a4d0f4b 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/ProductDetailViewModelTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/ProductDetailViewModelTest.kt @@ -108,7 +108,7 @@ class ProductDetailViewModelTest : BaseUnitTest() { } private var savedState: SavedStateHandle = - ProductDetailFragmentArgs(remoteProductId = PRODUCT_REMOTE_ID).toSavedStateHandle() + ProductDetailFragmentArgs(ProductDetailFragment.Mode.ShowProduct(PRODUCT_REMOTE_ID)).toSavedStateHandle() private val siteParams = SiteParameters( currencyCode = "USD", @@ -947,8 +947,10 @@ class ProductDetailViewModelTest : BaseUnitTest() { @Test fun `given image uris when app opened, then a product creation is triggered using the images`() = testBlocking { val uris = arrayOf("uri1", "uri2") - savedState = ProductDetailFragmentArgs(remoteProductId = PRODUCT_REMOTE_ID, images = uris) - .toSavedStateHandle() + savedState = ProductDetailFragmentArgs( + ProductDetailFragment.Mode.ShowProduct(PRODUCT_REMOTE_ID), + images = uris + ).toSavedStateHandle() doReturn(product).whenever(productRepository).getProductAsync(any()) diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/ProductDetailViewModel_AddFlowTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/ProductDetailViewModel_AddFlowTest.kt index 5c4e7ae6705..9f659b18102 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/ProductDetailViewModel_AddFlowTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/ProductDetailViewModel_AddFlowTest.kt @@ -86,7 +86,9 @@ class ProductDetailViewModel_AddFlowTest : BaseUnitTest() { onBlocking { invoke() } doReturn false } private var savedState: SavedStateHandle = - ProductDetailFragmentArgs(remoteProductId = PRODUCT_REMOTE_ID, isAddProduct = true).toSavedStateHandle() + ProductDetailFragmentArgs( + mode = ProductDetailFragment.Mode.AddNewProduct + ).toSavedStateHandle() private val siteParams = SiteParameters( currencyCode = "USD", @@ -399,7 +401,9 @@ class ProductDetailViewModel_AddFlowTest : BaseUnitTest() { 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()) - savedState = ProductDetailFragmentArgs(isAddProduct = true).toSavedStateHandle() + savedState = ProductDetailFragmentArgs( + mode = ProductDetailFragment.Mode.AddNewProduct + ).toSavedStateHandle() setup() viewModel.start() diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/ProductListViewModelTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/ProductListViewModelTest.kt index b3372da4f58..bd899f02a97 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/ProductListViewModelTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/ProductListViewModelTest.kt @@ -20,6 +20,7 @@ import com.woocommerce.android.tools.SelectedSite import com.woocommerce.android.ui.media.MediaFileUploadHandler import com.woocommerce.android.ui.products.ProductListViewModel.ProductListEvent.ShowProductFilterScreen import com.woocommerce.android.ui.products.ProductListViewModel.ProductListEvent.ShowProductSortingBottomSheet +import com.woocommerce.android.util.IsTabletLogicNeeded import com.woocommerce.android.viewmodel.BaseUnitTest import com.woocommerce.android.viewmodel.MultiLiveEvent.Event import com.woocommerce.android.viewmodel.MultiLiveEvent.Event.ShowSnackbar @@ -52,6 +53,7 @@ class ProductListViewModelTest : BaseUnitTest() { private val savedStateHandle: SavedStateHandle = SavedStateHandle() private val wooCommerceStore: WooCommerceStore = mock() private val selectedSite: SelectedSite = mock() + private val isTabletLogicNeeded: IsTabletLogicNeeded = mock() private val productList = ProductTestUtils.generateProductList() private lateinit var viewModel: ProductListViewModel @@ -71,7 +73,8 @@ class ProductListViewModelTest : BaseUnitTest() { mediaFileUploadHandler, analyticsTracker, selectedSite, - wooCommerceStore + wooCommerceStore, + isTabletLogicNeeded, ) ) } diff --git a/build.gradle b/build.gradle index 546f16b1bf9..8e6e733f2a6 100644 --- a/build.gradle +++ b/build.gradle @@ -115,7 +115,7 @@ ext { hiltJetpackVersion = '1.1.0' wordPressUtilsVersion = '3.5.0' mediapickerVersion = '0.3.0' - wordPressLoginVersion = '1.9.0' + wordPressLoginVersion = '1.13.0' aboutAutomatticVersion = '0.0.6' automatticTracksVersion = '3.2.0' workManagerVersion = '2.7.1' @@ -132,9 +132,9 @@ ext { httpClientAndroidVersion = '4.3.5.1' // Compose and its module versions need to be consistent with each other (for example 'compose-theme-adapter') - composeBOMVersion = "2023.06.01" - composeCompilerVersion = "1.5.8" - composeAccompanistVersion = "0.23.1" + composeBOMVersion = "2023.10.01" + composeCompilerVersion = "1.5.9" + composeAccompanistVersion = "0.32.0" // Testing jUnitVersion = '4.13.2' diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 51561d8a8d6..b0505c8607b 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -412,7 +412,7 @@ platform :android do hotfix_version = release_version_current - UI.important("Pushing changes to remote and triggering hotfix build for version: #{version}") + UI.important("Pushing changes to remote and triggering hotfix build for version: #{hotfix_version}") unless options[:skip_confirm] || UI.confirm('Do you want to continue?') UI.user_error!("Terminating as requested. Don't forget to run the remainder of this automation manually.") end diff --git a/fastlane/metadata/android/ar/changelogs/505.txt b/fastlane/metadata/android/ar/changelogs/505.txt deleted file mode 100644 index 6b7602cf559..00000000000 --- a/fastlane/metadata/android/ar/changelogs/505.txt +++ /dev/null @@ -1,2 +0,0 @@ -17.2: -يتضمن الإصدار تحسينات تتعلق بالسرعة والموثوقية. إننا ملتزمون بمواصلة تحسين التطبيق، ما يجعل إدارة متجرك على الإنترنت أكثر كفاءة وخالية من العقبات. diff --git a/fastlane/metadata/android/ar/changelogs/509.txt b/fastlane/metadata/android/ar/changelogs/509.txt new file mode 100644 index 00000000000..aeb3863ee7f --- /dev/null +++ b/fastlane/metadata/android/ar/changelogs/509.txt @@ -0,0 +1,2 @@ +17.3: +قمنا بتحسين تدفق إنشاء ملصق الشحن لضمان تجربة أكثر سلاسة وسهولة. يهدف ذلك التحسين إلى تبسيط عملية تنفيذ طلبك، ما يجعلها أسرع وأكثر كفاءة. ترجى مواصلة إرسال الملاحظات إلينا – فكلنا آذان مصغية! diff --git a/fastlane/metadata/android/de-DE/changelogs/505.txt b/fastlane/metadata/android/de-DE/changelogs/505.txt deleted file mode 100644 index 93f679a77d7..00000000000 --- a/fastlane/metadata/android/de-DE/changelogs/505.txt +++ /dev/null @@ -1,2 +0,0 @@ -17.2: -Diese Version enthält Optimierungen im Hinblick auf Geschwindigkeit und Zuverlässigkeit. Unser Ziel ist es, die App kontinuierlich zu verbessern, damit du deinen Onlineshop effizient und stressfrei verwalten kannst. diff --git a/fastlane/metadata/android/de-DE/changelogs/509.txt b/fastlane/metadata/android/de-DE/changelogs/509.txt new file mode 100644 index 00000000000..3ee25f170cb --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/509.txt @@ -0,0 +1,2 @@ +17.3: +Wir haben den Prozess zum Erstellen von Versandetiketten verbessert, um dir ein reibungsloseres und intuitiveres Erlebnis zu ermöglichen. Mit dieser Verbesserung soll deine Auftragsausführung optimiert sowie schneller und effizienter gestaltet werden. Sende uns bitte weiter Feedback – wir freuen uns darauf! diff --git a/fastlane/metadata/android/en-US/changelogs/505.txt b/fastlane/metadata/android/en-US/changelogs/505.txt deleted file mode 100644 index f762f748491..00000000000 --- a/fastlane/metadata/android/en-US/changelogs/505.txt +++ /dev/null @@ -1 +0,0 @@ -This version includes optimizations for speed and reliability. We are committed to continuously improving the app, making managing your online store more efficient and hassle-free. diff --git a/fastlane/metadata/android/en-US/changelogs/509.txt b/fastlane/metadata/android/en-US/changelogs/509.txt new file mode 100644 index 00000000000..670d796b947 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/509.txt @@ -0,0 +1 @@ +We've enhanced the shipping label creation flow to ensure a smoother and more intuitive experience. This improvement aims to streamline your order fulfillment process, making it faster and more efficient. Please continue sending us feedback – we are listening! diff --git a/fastlane/metadata/android/es-ES/changelogs/505.txt b/fastlane/metadata/android/es-ES/changelogs/505.txt deleted file mode 100644 index c74263a8353..00000000000 --- a/fastlane/metadata/android/es-ES/changelogs/505.txt +++ /dev/null @@ -1,2 +0,0 @@ -17.2: -Esta versión incluye optimizaciones de velocidad y fiabilidad. Nuestro compromiso es que mejoraremos continuamente la aplicación, para que puedas gestionar tu tienda en Internet de forma eficaz y sin ningún problema. diff --git a/fastlane/metadata/android/es-ES/changelogs/509.txt b/fastlane/metadata/android/es-ES/changelogs/509.txt new file mode 100644 index 00000000000..b523cfad814 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/509.txt @@ -0,0 +1,2 @@ +17.3: +Hemos mejorado el proceso de creación de etiquetas de envío para que la experiencia resulte más sencilla y fluida. Con esta mejora también se pretende optimizar tu proceso de tramitación de pedidos, de modo que todo sea más rápido y eficiente. Sigue enviándonos tus comentarios: ¡te escuchamos! diff --git a/fastlane/metadata/android/fr-FR/changelogs/505.txt b/fastlane/metadata/android/fr-FR/changelogs/505.txt deleted file mode 100644 index 536716b4ab5..00000000000 --- a/fastlane/metadata/android/fr-FR/changelogs/505.txt +++ /dev/null @@ -1,2 +0,0 @@ -17.2 : -Dans cette version, la vitesse et la fiabilité ont été optimisées. Nous nous engageons à améliorer sans cesse notre application pour vous permettre de gérer plus efficacement et sans le moindre souci votre boutique en ligne. diff --git a/fastlane/metadata/android/fr-FR/changelogs/509.txt b/fastlane/metadata/android/fr-FR/changelogs/509.txt new file mode 100644 index 00000000000..1566bad4903 --- /dev/null +++ b/fastlane/metadata/android/fr-FR/changelogs/509.txt @@ -0,0 +1,2 @@ +17.3 : +Nous avons amélioré le flux de création d’étiquettes d’expédition pour assurer une expérience plus fluide et plus intuitive. Cette amélioration vise à rationaliser le processus de traitement des commandes, afin de le rendre plus rapide et plus efficace. Continuez à nous envoyer vos commentaires. Votre avis nous intéresse ! diff --git a/fastlane/metadata/android/id/changelogs/505.txt b/fastlane/metadata/android/id/changelogs/505.txt deleted file mode 100644 index 55ed6bf72f7..00000000000 --- a/fastlane/metadata/android/id/changelogs/505.txt +++ /dev/null @@ -1,2 +0,0 @@ -17.2: -Versi ini mencakup optimasi kecepatan dan keandalan. Kami berkomitmen untuk terus meningkatkan aplikasi, sehingga toko online Anda dapat dikelola secara lebih efisien dan bebas repot. diff --git a/fastlane/metadata/android/id/changelogs/509.txt b/fastlane/metadata/android/id/changelogs/509.txt new file mode 100644 index 00000000000..60c44ba2dfe --- /dev/null +++ b/fastlane/metadata/android/id/changelogs/509.txt @@ -0,0 +1,2 @@ +17.3: +Kami menyempurnakan alur pembuatan label pengiriman agar pengalaman Anda lebih mulus dan intuitif. Peningkatan ini bertujuan untuk menyederhanakan proses pemenuhan pesanan sehingga menjadi lebih cepat dan efisien. Mohon terus berikan feedback – kami siap mendengarkan! diff --git a/fastlane/metadata/android/it-IT/changelogs/505.txt b/fastlane/metadata/android/it-IT/changelogs/505.txt deleted file mode 100644 index 3e1142e406b..00000000000 --- a/fastlane/metadata/android/it-IT/changelogs/505.txt +++ /dev/null @@ -1,2 +0,0 @@ -17.2: -Questa versione include ottimizzazioni per velocità e affidabilità. Ci impegniamo a migliorare continuamente l'app, rendendo la gestione del tuo negozio online più efficiente e meno problematica. diff --git a/fastlane/metadata/android/it-IT/changelogs/509.txt b/fastlane/metadata/android/it-IT/changelogs/509.txt new file mode 100644 index 00000000000..6cb2aaf6f7c --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/509.txt @@ -0,0 +1,2 @@ +17.3: +Abbiamo migliorato il flusso di creazione delle etichette di spedizione per garantire un'esperienza più fluida e intuitiva. Questo miglioramento mira a snellire il processo di evasione degli ordini, rendendolo più veloce ed efficiente. Continua a inviarci feedback: siamo lieti di ascoltarli. diff --git a/fastlane/metadata/android/iw-IL/changelogs/505.txt b/fastlane/metadata/android/iw-IL/changelogs/505.txt deleted file mode 100644 index 4b8348652e9..00000000000 --- a/fastlane/metadata/android/iw-IL/changelogs/505.txt +++ /dev/null @@ -1,2 +0,0 @@ -17.2: -הגרסה הזאת כוללת מיטוב למהירות וליציבות. אנחנו מחויבים להמשיך לשפר את האפליקציה כדי לאפשר לך לנהל את החנות המקוונת בצורה יעילה וללא בעיות. diff --git a/fastlane/metadata/android/iw-IL/changelogs/509.txt b/fastlane/metadata/android/iw-IL/changelogs/509.txt new file mode 100644 index 00000000000..70f70636e89 --- /dev/null +++ b/fastlane/metadata/android/iw-IL/changelogs/509.txt @@ -0,0 +1,2 @@ +17.3: +שיפרנו את תהליך היצירה של תוויות משלוח כדי לוודא חוויית שימוש יעילה ואינטואיטיבית יותר. השיפור נועד ליצור תהליך חלק יותר למילוי הזמנות על ידי שיפור הזריזות והיעילות שלו. נשמח לקבל עוד משוב – אנחנו תמיד כאן כדי להקשיב! diff --git a/fastlane/metadata/android/ja-JP/changelogs/505.txt b/fastlane/metadata/android/ja-JP/changelogs/505.txt deleted file mode 100644 index 5e2b4798170..00000000000 --- a/fastlane/metadata/android/ja-JP/changelogs/505.txt +++ /dev/null @@ -1,2 +0,0 @@ -17.2: -このバージョンには速度と信頼性の最適化が含まれています。 私たちはアプリを継続的に改善し、オンラインストアの管理がより効率的かつ手間のかからないものになるよう取り組んでいます。 diff --git a/fastlane/metadata/android/ja-JP/changelogs/509.txt b/fastlane/metadata/android/ja-JP/changelogs/509.txt new file mode 100644 index 00000000000..3dcaac50f43 --- /dev/null +++ b/fastlane/metadata/android/ja-JP/changelogs/509.txt @@ -0,0 +1,2 @@ +17.3: +配送ラベルの作成フローが強化され、よりスムーズで直感的に操作できるようになりました。 この改善は、注文のフルフィルメント処理を合理化し、より迅速で効率的にすることを目的としています。 引き続き、フィードバックをお寄せください。ぜひ参考にさせていただきます。 diff --git a/fastlane/metadata/android/ko-KR/changelogs/505.txt b/fastlane/metadata/android/ko-KR/changelogs/505.txt deleted file mode 100644 index b9cf9a10d18..00000000000 --- a/fastlane/metadata/android/ko-KR/changelogs/505.txt +++ /dev/null @@ -1,2 +0,0 @@ -17.2: -이 버전에는 속도 및 안정성 최적화가 포함됩니다. 더 효율적이면서 간편하게 온라인 스토어를 관리하실 수 있도록 지속적으로 앱을 개선하기 위해 최선을 다하고 있습니다. diff --git a/fastlane/metadata/android/ko-KR/changelogs/509.txt b/fastlane/metadata/android/ko-KR/changelogs/509.txt new file mode 100644 index 00000000000..721354ef5d1 --- /dev/null +++ b/fastlane/metadata/android/ko-KR/changelogs/509.txt @@ -0,0 +1,2 @@ +17.3: +더 원활하고 더 직관적인 경험이 확보되도록 배송 레이블 생성 절차를 개선했습니다. 더 빠르고 더 효율적으로 진행되도록 주문 처리 프로세스를 간소화하는 것이 이 개선의 목표입니다. 계속 피드백을 보내주세요. 경청하겠습니다! diff --git a/fastlane/metadata/android/nl-NL/changelogs/505.txt b/fastlane/metadata/android/nl-NL/changelogs/505.txt deleted file mode 100644 index ad6cdfb0b27..00000000000 --- a/fastlane/metadata/android/nl-NL/changelogs/505.txt +++ /dev/null @@ -1,2 +0,0 @@ -17.2: -Deze versie omvat optimalisaties voor snelheid en betrouwbaarheid. We zijn eraan toegewijd om de app steeds te blijven verbeteren, waardoor we het beheer van je online winkel efficiënter en handiger maken. diff --git a/fastlane/metadata/android/nl-NL/changelogs/509.txt b/fastlane/metadata/android/nl-NL/changelogs/509.txt new file mode 100644 index 00000000000..10fa4110181 --- /dev/null +++ b/fastlane/metadata/android/nl-NL/changelogs/509.txt @@ -0,0 +1,2 @@ +17.3: +We've enhanced the shipping label creation flow to ensure a smoother and more intuitive experience. This improvement aims to streamline your order fulfillment process, making it faster and more efficient. Blijf ons feedback sturen – wij luisteren! diff --git a/fastlane/metadata/android/pt-BR/changelogs/505.txt b/fastlane/metadata/android/pt-BR/changelogs/505.txt deleted file mode 100644 index 27cb8422a0e..00000000000 --- a/fastlane/metadata/android/pt-BR/changelogs/505.txt +++ /dev/null @@ -1,2 +0,0 @@ -17.2: -This version includes optimizations for speed and reliability. Temos o compromisso de melhorar cada vez mais o aplicativo para deixar o gerenciamento da sua loja online mais eficiente e simples. diff --git a/fastlane/metadata/android/pt-BR/changelogs/509.txt b/fastlane/metadata/android/pt-BR/changelogs/509.txt new file mode 100644 index 00000000000..26098981d3e --- /dev/null +++ b/fastlane/metadata/android/pt-BR/changelogs/509.txt @@ -0,0 +1,2 @@ +17.3: +Melhoramos o fluxo de criação de etiquetas de envio para garantir uma experiência mais intuitiva e tranquila. Esta melhoria visa agilizar o processo de atendimento de pedidos, tornando-o mais rápido e eficiente. Continue compartilhando seu feedback conosco. Estamos atentos. diff --git a/fastlane/metadata/android/release_notes.xml b/fastlane/metadata/android/release_notes.xml index b0b214d8474..715f54f1d35 100644 --- a/fastlane/metadata/android/release_notes.xml +++ b/fastlane/metadata/android/release_notes.xml @@ -1,68 +1,68 @@ -17.2: -يتضمن الإصدار تحسينات تتعلق بالسرعة والموثوقية. إننا ملتزمون بمواصلة تحسين التطبيق، ما يجعل إدارة متجرك على الإنترنت أكثر كفاءة وخالية من العقبات. +17.3: +قمنا بتحسين تدفق إنشاء ملصق الشحن لضمان تجربة أكثر سلاسة وسهولة. يهدف ذلك التحسين إلى تبسيط عملية تنفيذ طلبك، ما يجعلها أسرع وأكثر كفاءة. ترجى مواصلة إرسال الملاحظات إلينا – فكلنا آذان مصغية! -17.2: -Diese Version enthält Optimierungen im Hinblick auf Geschwindigkeit und Zuverlässigkeit. Unser Ziel ist es, die App kontinuierlich zu verbessern, damit du deinen Onlineshop effizient und stressfrei verwalten kannst. +17.3: +Wir haben den Prozess zum Erstellen von Versandetiketten verbessert, um dir ein reibungsloseres und intuitiveres Erlebnis zu ermöglichen. Mit dieser Verbesserung soll deine Auftragsausführung optimiert sowie schneller und effizienter gestaltet werden. Sende uns bitte weiter Feedback – wir freuen uns darauf! -17.2: -Esta versión incluye optimizaciones de velocidad y fiabilidad. Nuestro compromiso es que mejoraremos continuamente la aplicación, para que puedas gestionar tu tienda en Internet de forma eficaz y sin ningún problema. +17.3: +Hemos mejorado el proceso de creación de etiquetas de envío para que la experiencia resulte más sencilla y fluida. Con esta mejora también se pretende optimizar tu proceso de tramitación de pedidos, de modo que todo sea más rápido y eficiente. Sigue enviándonos tus comentarios: ¡te escuchamos! -17.2 : -Dans cette version, la vitesse et la fiabilité ont été optimisées. Nous nous engageons à améliorer sans cesse notre application pour vous permettre de gérer plus efficacement et sans le moindre souci votre boutique en ligne. +17.3 : +Nous avons amélioré le flux de création d’étiquettes d’expédition pour assurer une expérience plus fluide et plus intuitive. Cette amélioration vise à rationaliser le processus de traitement des commandes, afin de le rendre plus rapide et plus efficace. Continuez à nous envoyer vos commentaires. Votre avis nous intéresse ! -17.2: -הגרסה הזאת כוללת מיטוב למהירות וליציבות. אנחנו מחויבים להמשיך לשפר את האפליקציה כדי לאפשר לך לנהל את החנות המקוונת בצורה יעילה וללא בעיות. +17.3: +שיפרנו את תהליך היצירה של תוויות משלוח כדי לוודא חוויית שימוש יעילה ואינטואיטיבית יותר. השיפור נועד ליצור תהליך חלק יותר למילוי הזמנות על ידי שיפור הזריזות והיעילות שלו. נשמח לקבל עוד משוב – אנחנו תמיד כאן כדי להקשיב! -17.2: -Versi ini mencakup optimasi kecepatan dan keandalan. Kami berkomitmen untuk terus meningkatkan aplikasi, sehingga toko online Anda dapat dikelola secara lebih efisien dan bebas repot. +17.3: +Kami menyempurnakan alur pembuatan label pengiriman agar pengalaman Anda lebih mulus dan intuitif. Peningkatan ini bertujuan untuk menyederhanakan proses pemenuhan pesanan sehingga menjadi lebih cepat dan efisien. Mohon terus berikan feedback – kami siap mendengarkan! -17.2: -Questa versione include ottimizzazioni per velocità e affidabilità. Ci impegniamo a migliorare continuamente l'app, rendendo la gestione del tuo negozio online più efficiente e meno problematica. +17.3: +Abbiamo migliorato il flusso di creazione delle etichette di spedizione per garantire un'esperienza più fluida e intuitiva. Questo miglioramento mira a snellire il processo di evasione degli ordini, rendendolo più veloce ed efficiente. Continua a inviarci feedback: siamo lieti di ascoltarli. -17.2: -このバージョンには速度と信頼性の最適化が含まれています。 私たちはアプリを継続的に改善し、オンラインストアの管理がより効率的かつ手間のかからないものになるよう取り組んでいます。 +17.3: +配送ラベルの作成フローが強化され、よりスムーズで直感的に操作できるようになりました。 この改善は、注文のフルフィルメント処理を合理化し、より迅速で効率的にすることを目的としています。 引き続き、フィードバックをお寄せください。ぜひ参考にさせていただきます。 -17.2: -이 버전에는 속도 및 안정성 최적화가 포함됩니다. 더 효율적이면서 간편하게 온라인 스토어를 관리하실 수 있도록 지속적으로 앱을 개선하기 위해 최선을 다하고 있습니다. +17.3: +더 원활하고 더 직관적인 경험이 확보되도록 배송 레이블 생성 절차를 개선했습니다. 더 빠르고 더 효율적으로 진행되도록 주문 처리 프로세스를 간소화하는 것이 이 개선의 목표입니다. 계속 피드백을 보내주세요. 경청하겠습니다! -17.2: -Deze versie omvat optimalisaties voor snelheid en betrouwbaarheid. We zijn eraan toegewijd om de app steeds te blijven verbeteren, waardoor we het beheer van je online winkel efficiënter en handiger maken. +17.3: +We've enhanced the shipping label creation flow to ensure a smoother and more intuitive experience. This improvement aims to streamline your order fulfillment process, making it faster and more efficient. Blijf ons feedback sturen – wij luisteren! -17.2: -This version includes optimizations for speed and reliability. Temos o compromisso de melhorar cada vez mais o aplicativo para deixar o gerenciamento da sua loja online mais eficiente e simples. +17.3: +Melhoramos o fluxo de criação de etiquetas de envio para garantir uma experiência mais intuitiva e tranquila. Esta melhoria visa agilizar o processo de atendimento de pedidos, tornando-o mais rápido e eficiente. Continue compartilhando seu feedback conosco. Estamos atentos. -17.2: -В эту версию вошли усовершенствования в сферах быстродействия и надёжности. Мы стремимся постоянно совершенствовать приложение, чтобы вы могли управлять своим онлайн-магазином ещё более эффективно и без всяких проблем. +17.3: +Мы усовершенствовали процесс создания транспортных этикеток, чтобы сделать его понятнее и эффективнее. Цель этого улучшения — оптимизировать и ускорить процесс выполнения заказа. Мы по-прежнему ждём ваших отзывов и прислушиваемся к ним! -17.2: -Den här versionen inkluderar optimeringar för snabbhet och tillförlitlighet. Vi arbetar kontinuerligt med förbättringar av appen, så att du ska kunna hantera din onlinebutik på ett effektivt sätt utan krångel. +17.3: +Vi har förbättrat flödet vid skapande av fraktsedlar för att säkerställa en smidigare och mer intuitiv upplevelse. Förbättringen är tänkt att effektivisera din orderhanteringsprocess och göra den snabbare och mer effektiv. Fortsätt att skicka feedback till oss – vi lyssnar. -17.2: -Bu sürümde hız ve güvenilirlik için optimizasyonlar yapıldı. Uygulamayı sürekli iyileştirmeye ve çevrimiçi mağazanızı daha verimli ve sorunsuz hale getirmeye kararlıyız. +17.3: +Daha sorunsuz ve sezgisel bir deneyim sağlamak için gönderim etiketi oluşturma akışını geliştirdik. Bu geliştirme, sipariş tamamlama sürecinizi kolaylaştırmayı hedefleyerek bu süreci daha hızlı ve verimli hale getirir. Lütfen bize geri bildirim göndermeye devam edin, sizi dinliyoruz! -17.2: -此版本在速度和可靠性方面进行了优化。 我们致力于持续改进该应用程序,助您更轻松高效地管理您的在线商店。 +17.3: +我们增强了货运标签创建流程,以确保更流畅、更直观的体验。 这项改进旨在简化订单履行流程,使之更快速、更高效。 请继续向我们发送反馈,我们时刻倾听您的意见! -17.2: -此版本包含了可提升速度和可靠性的最佳化功能。 我們會致力改善應用程式,持續讓管理線上商店更有效率且更輕鬆。 +17.3: +提升了運送標籤的製作流程,以確保更順暢直觀的使用體驗。 這項改善旨在簡化訂單履行流程,使其更快速有效率。 歡迎繼續給予我們意見回饋,我們很樂意虛心傾聽! -17.2: -This version includes optimizations for speed and reliability. We are committed to continuously improving the app, making managing your online store more efficient and hassle-free. +17.3: +We've enhanced the shipping label creation flow to ensure a smoother and more intuitive experience. This improvement aims to streamline your order fulfillment process, making it faster and more efficient. Please continue sending us feedback – we are listening! diff --git a/fastlane/metadata/android/ru-RU/changelogs/505.txt b/fastlane/metadata/android/ru-RU/changelogs/505.txt deleted file mode 100644 index 18946ab4f78..00000000000 --- a/fastlane/metadata/android/ru-RU/changelogs/505.txt +++ /dev/null @@ -1,2 +0,0 @@ -17.2: -В эту версию вошли усовершенствования в сферах быстродействия и надёжности. Мы стремимся постоянно совершенствовать приложение, чтобы вы могли управлять своим онлайн-магазином ещё более эффективно и без всяких проблем. diff --git a/fastlane/metadata/android/ru-RU/changelogs/509.txt b/fastlane/metadata/android/ru-RU/changelogs/509.txt new file mode 100644 index 00000000000..a2d3729050b --- /dev/null +++ b/fastlane/metadata/android/ru-RU/changelogs/509.txt @@ -0,0 +1,2 @@ +17.3: +Мы усовершенствовали процесс создания транспортных этикеток, чтобы сделать его понятнее и эффективнее. Цель этого улучшения — оптимизировать и ускорить процесс выполнения заказа. Мы по-прежнему ждём ваших отзывов и прислушиваемся к ним! diff --git a/fastlane/metadata/android/sv-SE/changelogs/505.txt b/fastlane/metadata/android/sv-SE/changelogs/505.txt deleted file mode 100644 index 967a6b4bbd5..00000000000 --- a/fastlane/metadata/android/sv-SE/changelogs/505.txt +++ /dev/null @@ -1,2 +0,0 @@ -17.2: -Den här versionen inkluderar optimeringar för snabbhet och tillförlitlighet. Vi arbetar kontinuerligt med förbättringar av appen, så att du ska kunna hantera din onlinebutik på ett effektivt sätt utan krångel. diff --git a/fastlane/metadata/android/sv-SE/changelogs/509.txt b/fastlane/metadata/android/sv-SE/changelogs/509.txt new file mode 100644 index 00000000000..dcc8891b0d0 --- /dev/null +++ b/fastlane/metadata/android/sv-SE/changelogs/509.txt @@ -0,0 +1,2 @@ +17.3: +Vi har förbättrat flödet vid skapande av fraktsedlar för att säkerställa en smidigare och mer intuitiv upplevelse. Förbättringen är tänkt att effektivisera din orderhanteringsprocess och göra den snabbare och mer effektiv. Fortsätt att skicka feedback till oss – vi lyssnar. diff --git a/fastlane/metadata/android/tr-TR/changelogs/505.txt b/fastlane/metadata/android/tr-TR/changelogs/505.txt deleted file mode 100644 index c18dd9d41fa..00000000000 --- a/fastlane/metadata/android/tr-TR/changelogs/505.txt +++ /dev/null @@ -1,2 +0,0 @@ -17.2: -Bu sürümde hız ve güvenilirlik için optimizasyonlar yapıldı. Uygulamayı sürekli iyileştirmeye ve çevrimiçi mağazanızı daha verimli ve sorunsuz hale getirmeye kararlıyız. diff --git a/fastlane/metadata/android/tr-TR/changelogs/509.txt b/fastlane/metadata/android/tr-TR/changelogs/509.txt new file mode 100644 index 00000000000..fbcc38a7d5f --- /dev/null +++ b/fastlane/metadata/android/tr-TR/changelogs/509.txt @@ -0,0 +1,2 @@ +17.3: +Daha sorunsuz ve sezgisel bir deneyim sağlamak için gönderim etiketi oluşturma akışını geliştirdik. Bu geliştirme, sipariş tamamlama sürecinizi kolaylaştırmayı hedefleyerek bu süreci daha hızlı ve verimli hale getirir. Lütfen bize geri bildirim göndermeye devam edin, sizi dinliyoruz! diff --git a/fastlane/metadata/android/zh-CN/changelogs/505.txt b/fastlane/metadata/android/zh-CN/changelogs/505.txt deleted file mode 100644 index 3ed28b56a78..00000000000 --- a/fastlane/metadata/android/zh-CN/changelogs/505.txt +++ /dev/null @@ -1,2 +0,0 @@ -17.2: -此版本在速度和可靠性方面进行了优化。 我们致力于持续改进该应用程序,助您更轻松高效地管理您的在线商店。 diff --git a/fastlane/metadata/android/zh-CN/changelogs/509.txt b/fastlane/metadata/android/zh-CN/changelogs/509.txt new file mode 100644 index 00000000000..0a715c495e1 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/509.txt @@ -0,0 +1,2 @@ +17.3: +我们增强了货运标签创建流程,以确保更流畅、更直观的体验。 这项改进旨在简化订单履行流程,使之更快速、更高效。 请继续向我们发送反馈,我们时刻倾听您的意见! diff --git a/fastlane/metadata/android/zh-TW/changelogs/505.txt b/fastlane/metadata/android/zh-TW/changelogs/505.txt deleted file mode 100644 index 9a1fbdc3779..00000000000 --- a/fastlane/metadata/android/zh-TW/changelogs/505.txt +++ /dev/null @@ -1,2 +0,0 @@ -17.2: -此版本包含了可提升速度和可靠性的最佳化功能。 我們會致力改善應用程式,持續讓管理線上商店更有效率且更輕鬆。 diff --git a/fastlane/metadata/android/zh-TW/changelogs/509.txt b/fastlane/metadata/android/zh-TW/changelogs/509.txt new file mode 100644 index 00000000000..401eda2de03 --- /dev/null +++ b/fastlane/metadata/android/zh-TW/changelogs/509.txt @@ -0,0 +1,2 @@ +17.3: +提升了運送標籤的製作流程,以確保更順暢直觀的使用體驗。 這項改善旨在簡化訂單履行流程,使其更快速有效率。 歡迎繼續給予我們意見回饋,我們很樂意虛心傾聽! diff --git a/fastlane/resources/values/strings.xml b/fastlane/resources/values/strings.xml index 4ccec7dfe2e..e621a28de8e 100644 --- a/fastlane/resources/values/strings.xml +++ b/fastlane/resources/values/strings.xml @@ -138,6 +138,9 @@ %s Required field Analytics + Manage Analytics + Analytic Cards + Drag handle Card Cash Create @@ -3807,6 +3810,7 @@ --> Blaze campaigns There was an error refreshing the list of campaigns. Please try again later. + @@ -3822,7 +3826,7 @@ Blaze campaign creation celebration --> All set! - The ad has been submistted for approval. We’ll send you a confirmation email once it’s approvied and running. + The ad has been submitted for approval. We’ll send you a confirmation email once it’s approvied and running. Got it @@ -3863,6 +3872,7 @@ %1$s days Starts Apply + Failed to estimate impressions. Retry? @@ -3884,7 +3895,13 @@ Loading payment methods failed, please retry by clicking here! Submit campaign By clicking \"Submit campaign\" you agree to the <a href=\'termsOfService\'><u>Terms of Service</u></a> and <a href=\'advertisingPolicy\'><u>Advertising Policy</u></a>, and authorize your payment method to be charged for the budget and duration you chose. <a href=\'learnMore\'><u>Learn more</u></a> about how budgets and payments for Promoted Posts work. - + Payment method + Please add a new payment method + Add credit card + All transactions are secure and encrypted + Credits cards are retrieved from the following WordPress.com account: %1$s <%2$s> + Add new card + Credit card added successfully @@ -3895,6 +3912,7 @@ Promote Start typing country, state or city to see available options No location found.\nPlease try again. + @@ -3903,6 +3921,29 @@ URL parameters The product URL The site home + Add parameter + Destination: %s + Key + Value + The final URL is too long + The key already exits + + + Ready to Go! + We\'re reviewing your campaign. It\'ll be live within 24 hours. Exciting times ahead for your sales! + Done + Creating your campaign + Error creating campaign + Error creating campaign + Please try again, or contact support for assistance. + Get support + Cancel campaign + Failed to upload campaign image. + Failed to fetch campaign image details + Something’s not quite right.\nWe couldn\'t create your campaign. + diff --git a/settings.gradle b/settings.gradle index 374433859e1..9fbaad3f3b2 100644 --- a/settings.gradle +++ b/settings.gradle @@ -6,7 +6,7 @@ pluginManagement { gradle.ext.kotlinVersion = '1.9.22' gradle.ext.kspVersion = '1.9.22-1.0.17' gradle.ext.measureBuildsVersion = '2.0.3' - gradle.ext.navigationVersion = '2.6.0' + gradle.ext.navigationVersion = '2.7.7' gradle.ext.sentryVersion = '3.5.0' gradle.ext.violationCommentsVersion = '1.69.0' diff --git a/version.properties b/version.properties index 6ebc8317bf6..2ef93fa86ab 100644 --- a/version.properties +++ b/version.properties @@ -1,2 +1,2 @@ -versionName=17.3-rc-1 -versionCode=506 \ No newline at end of file +versionName=17.4-rc-3 +versionCode=512 \ No newline at end of file