diff --git a/.buildkite/commands/lint.sh b/.buildkite/commands/lint.sh new file mode 100755 index 00000000000..ccb16770559 --- /dev/null +++ b/.buildkite/commands/lint.sh @@ -0,0 +1,18 @@ +#!/bin/bash -u + +echo "--- 🧹 Linting" +cp gradle.properties-example gradle.properties +./gradlew :WooCommerce:lintJalapenoDebug +app_lint_exit_code=$? + +./gradlew :WooCommerce-Wear:lintJalapenoDebug +wear_lint_exit_code=$? + +lint_exit_code=0 +if [ $app_lint_exit_code -ne 0 ] || [ $wear_lint_exit_code -ne 0 ]; then + lint_exit_code=1 +fi + +upload_sarif_to_github 'WooCommerce/build/reports/lint-results-jalapenoDebug.sarif' + +exit $lint_exit_code diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index 2a015a8a3bf..a6cd852fc8d 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -44,10 +44,7 @@ steps: - "**/build/reports/detekt/detekt.html" - label: "lint" - command: | - echo "--- 🧹 Linting" - cp gradle.properties-example gradle.properties - ./gradlew lintJalapenoDebug + command: .buildkite/commands/lint.sh plugins: [$CI_TOOLKIT] artifact_paths: - "**/build/reports/lint-results*.*" diff --git a/.buildkite/release-builds.yml b/.buildkite/release-builds.yml index e0f72e0bbe1..a81fa31e342 100644 --- a/.buildkite/release-builds.yml +++ b/.buildkite/release-builds.yml @@ -24,6 +24,10 @@ steps: plugins: [$CI_TOOLKIT] notify: - slack: "#build-and-ship" + retry: + manual: + # If those jobs fail, one should always prefer re-triggering a new build from ReleaseV2 rather than retrying the individual job from Buildkite + allowed: false - label: "🛠 Release Build (Wear App)" command: | diff --git a/.buildkite/release-pipelines/new-hotfix-release.yml b/.buildkite/release-pipelines/new-hotfix-release.yml index 2209b01bfef..e284d891f06 100644 --- a/.buildkite/release-pipelines/new-hotfix-release.yml +++ b/.buildkite/release-pipelines/new-hotfix-release.yml @@ -2,7 +2,7 @@ --- steps: - - input: "What version code do you want to use for the new hotfix release?" + - block: "What version code do you want to use for the new hotfix release?" fields: - text: "Version Code" key: "version_code" @@ -16,11 +16,12 @@ steps: echo '--- :ruby: Setup Ruby Tools' install_gems - # Get the version code from the Buildkite 'input' step + # Get the version code from the Buildkite 'block' step VERSION_CODE=$(buildkite-agent meta-data get version_code) echo '--- :fire: Start New Hotfix Release' - bundle exec fastlane new_hotfix_release version_name:${VERSION} version_code:${VERSION_CODE} skip_confirm:true + # Note: we need to double the dollar sign in front of `{VERSION_CODE}` below, so that the env var is interpreted *at runtime*, instead of too soon during `pipeline upload` + bundle exec fastlane new_hotfix_release version_name:$${VERSION} version_code:$${VERSION_CODE} skip_confirm:true agents: queue: "tumblr-metal" retry: diff --git a/.buildkite/release-pipelines/publish-release.yml b/.buildkite/release-pipelines/publish-release.yml new file mode 100644 index 00000000000..2937ccb0cca --- /dev/null +++ b/.buildkite/release-pipelines/publish-release.yml @@ -0,0 +1,22 @@ +steps: + - label: "Publish Release" + plugins: + - $CI_TOOLKIT + command: | + echo '--- :robot_face: Use bot for git operations' + source use-bot-for-git + + echo '--- :git: Checkout Release Branch' + .buildkite/commands/checkout-release-branch.sh + + echo '--- :ruby: Setup Ruby Tools' + install_gems + + echo '--- :package: Publish Release' + bundle exec fastlane publish_release skip_confirm:true include_wear_app:"${INCLUDE_WEAR_APP:-false}" + agents: + queue: "tumblr-metal" + retry: + manual: + # If those jobs fail, one should always prefer re-triggering a new build from ReleaseV2 rather than retrying the individual job from Buildkite + allowed: false diff --git a/.buildkite/shared-pipeline-vars b/.buildkite/shared-pipeline-vars index 279aa805e58..307dd62e628 100644 --- a/.buildkite/shared-pipeline-vars +++ b/.buildkite/shared-pipeline-vars @@ -1,7 +1,8 @@ #!/bin/sh - # This file is `source`'d before calling `buildkite-agent pipeline upload`, and can be used - # to set up some variables that will be interpolated in the `.yml` pipeline before uploading it. +# This file is `source`'d before calling `buildkite-agent pipeline upload`, and can be used +# to set up some variables that will be interpolated in the `.yml` pipeline before uploading it. + +export CI_TOOLKIT="automattic/a8c-ci-toolkit#3.7.1" +export TEST_COLLECTOR="test-collector#v1.10.1" - export CI_TOOLKIT="automattic/a8c-ci-toolkit#3.5.1" - export TEST_COLLECTOR="test-collector#v1.10.1" diff --git a/CHANGELOG.md b/CHANGELOG.md index 44d7134bad0..c873d143b68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,18 @@ +## 20.5 +We’re excited to bring you a smoother experience with our latest update! We've resolved an issue that prevented renaming Product Variation Attributes and fixed a bug related to notification removal. Plus, users can now easily select product images when creating Blaze ads, and the Blaze feature is now fully enabled for sites with the Blaze for WooCommerce plugin active. Enjoy the improvements! + +## 20.4 +We've enhanced your WooCommerce app experience! You can now effortlessly scan tracking numbers when adding them to orders. Plus, we've fixed an issue with shipping labels, ensuring accurate weight calculations for packages with multiple items. Enjoy a smoother, more reliable app experience! + +## 20.3 +We’ve made several enhancements to boost your experience! This update fixes crashes on the orders list screen, resolves issues with the Jetpack plugin installation, and corrects toolbar glitches in the Package details and Order Tracking screens. We’ve also added a helpful blaze campaign reminder if you leave a campaign creation unfinished. Enjoy smoother navigation and a more reliable app! + +## 20.2 +Create Blaze Evergreen campaigns effortlessly with our latest update! We've also improved tablet navigation—now you'll be redirected to the order details screen after payment for a smoother experience. Update today and enjoy the enhancements! + ## 20.1 We've improved your experience with our latest update! We've resolved the issue where the Orders screen would get stuck, addressed incorrect pin errors for UK and Canadian stores, fixed a rare crash in order editing, and enhanced usability on small screens. Enjoy smoother performance and better reliability! diff --git a/Dangerfile b/Dangerfile index e7eb896fbf1..54f6cee2166 100644 --- a/Dangerfile +++ b/Dangerfile @@ -3,7 +3,7 @@ github.dismiss_out_of_range_messages # `files: []` forces rubocop to scan all files, not just the ones modified in the PR -# Prevent RuboCop from running using `bundle exec`, which we don't want on the linter agent +# `skip_bundle_exec` prevents RuboCop from running using `bundle exec`, which we don't want on the linter agent rubocop.lint(files: [], force_exclusion: true, inline_comment: true, fail_on_inline_comment: true, include_cop_names: true, skip_bundle_exec: true) manifest_pr_checker.check_gemfile_lock_updated @@ -54,4 +54,10 @@ labels_checker.check( ) # 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? +if (github_utils.main_branch? || github_utils.release_branch?) && !github_utils.wip_feature? + report_type = github.pr_labels.include?('milestone-not-required') || github.pr_labels.include?('status: feature-flagged') ? :message : :error + milestone_checker.check_milestone_due_date( + days_before_due: 2, + report_type_if_no_milestone: report_type + ) +end diff --git a/Gemfile b/Gemfile index 91634fcd7a0..1714d277893 100644 --- a/Gemfile +++ b/Gemfile @@ -5,11 +5,11 @@ source 'https://rubygems.org' gem 'danger-dangermattic', '~> 1.1' gem 'fastlane', '~> 2.216' gem 'nokogiri' -gem 'rubocop', '~> 1.60' +gem 'rubocop', '~> 1.65' ### Fastlane Plugins -gem 'fastlane-plugin-wpmreleasetoolkit', '~> 11.0' +gem 'fastlane-plugin-wpmreleasetoolkit', '~> 12.0' # gem 'fastlane-plugin-wpmreleasetoolkit', path: '../../release-toolkit' # gem 'fastlane-plugin-wpmreleasetoolkit', git: 'https://github.com/wordpress-mobile/release-toolkit', branch: '' diff --git a/Gemfile.lock b/Gemfile.lock index f31f3a70516..00847ca92f0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -5,16 +5,17 @@ GEM base64 nkf rexml - activesupport (7.1.3.4) + activesupport (7.2.1) base64 bigdecimal - concurrent-ruby (~> 1.0, >= 1.0.2) + concurrent-ruby (~> 1.0, >= 1.3.1) connection_pool (>= 2.2.5) drb i18n (>= 1.6, < 2) + logger (>= 1.4.2) minitest (>= 5.1) - mutex_m - tzinfo (~> 2.0) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) artifactory (3.0.17) @@ -51,7 +52,7 @@ GEM colored2 (3.1.2) commander (4.6.0) highline (~> 2.0.0) - concurrent-ruby (1.3.3) + concurrent-ruby (1.3.4) connection_pool (2.4.1) cork (0.3.0) colored2 (~> 3.1) @@ -158,7 +159,7 @@ GEM xcodeproj (>= 1.13.0, < 2.0.0) xcpretty (~> 0.3.0) xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) - fastlane-plugin-wpmreleasetoolkit (11.1.0) + fastlane-plugin-wpmreleasetoolkit (12.0.0) activesupport (>= 6.1.7.1) buildkit (~> 1.5) chroma (= 0.2.0) @@ -167,7 +168,7 @@ GEM git (~> 1.3) google-cloud-storage (~> 1.31) java-properties (~> 0.3.0) - nokogiri (~> 1.11, < 1.17) + nokogiri (~> 1.11) octokit (~> 6.1) parallel (~> 1.14) plist (~> 3.1) @@ -231,13 +232,13 @@ GEM kramdown-parser-gfm (1.1.0) kramdown (~> 2.0) language_server-protocol (3.17.0.3) + logger (1.6.1) mini_magick (4.13.2) mini_mime (1.1.5) mini_portile2 (2.8.7) - minitest (5.24.1) + minitest (5.25.1) multi_json (1.15.0) multipart-post (2.4.1) - mutex_m (0.2.0) nanaimo (0.3.0) nap (1.1.0) naturally (2.2.1) @@ -252,8 +253,8 @@ GEM options (2.3.2) optparse (0.5.0) os (1.1.4) - parallel (1.25.1) - parser (3.3.4.1) + parallel (1.26.3) + parser (3.3.4.2) ast (~> 2.4.1) racc plist (3.7.1) @@ -273,7 +274,7 @@ GEM trailblazer-option (>= 0.1.1, < 0.2.0) uber (< 0.2.0) retriable (3.1.2) - rexml (3.3.4) + rexml (3.3.6) strscan rmagick (4.3.0) rouge (2.0.7) @@ -288,7 +289,7 @@ GEM rubocop-ast (>= 1.31.1, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.32.0) + rubocop-ast (1.32.1) parser (>= 3.3.1.0) ruby-progressbar (1.13.0) ruby2_keywords (0.0.5) @@ -296,6 +297,7 @@ GEM sawyer (0.9.2) addressable (>= 2.3.5) faraday (>= 0.17.3, < 3) + securerandom (0.3.1) security (0.1.5) signet (0.19.0) addressable (~> 2.8) @@ -337,10 +339,10 @@ PLATFORMS DEPENDENCIES danger-dangermattic (~> 1.1) fastlane (~> 2.216) - fastlane-plugin-wpmreleasetoolkit (~> 11.0) + fastlane-plugin-wpmreleasetoolkit (~> 12.0) nokogiri rmagick (~> 4.1) - rubocop (~> 1.60) + rubocop (~> 1.65) BUNDLED WITH 2.4.19 diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 9c5d6eda306..9beffdf661a 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -2,11 +2,43 @@ *** Use [*****] to indicate smoke tests of all critical flows should be run on the final APK before release (e.g. major library or targetSdk updates). *** For entries which are touching the Android Wear app's, start entry with `[WEAR]` too. +20.6 +----- +- [*] Users can set a password to protect products when signed in using site credentials (compatible with WooCommerce 8.1.0 and higher) [https://github.com/woocommerce/woocommerce-android/pull/12642] + +20.5 +----- +- [*] Fixes a bug that prevented users to rename the Product Variation Attributes to because of case insensitive checks [https://github.com/woocommerce/woocommerce-android/pull/12608] +- [*] Users can directly pick product images when creating Blaze ads [https://github.com/woocommerce/woocommerce-android/pull/12610] +- [*] Enables Blaze feature for sites with Blaze for WooCommerce plugin installed and active [https://github.com/woocommerce/woocommerce-android/pull/12640] +- [*] Fix for ConcurrentModificationException while removing notification [https://github.com/woocommerce/woocommerce-android/pull/12646] + +20.4 +----- +- [**] Users can now scan their tracking number when adding it to the order [https://github.com/woocommerce/woocommerce-android/pull/12533] +- [*] Fixed an issue where shipping labels were incorrectly calculating the weight for packages containing multiple quantities of the same product [https://github.com/woocommerce/woocommerce-android/pull/12602] +- [**] [WEAR] Introduces three new complications for the Wear app: Orders Total, Orders Count, and Visitors [https://github.com/woocommerce/woocommerce-android/pull/12558] + +20.3 +----- +- [**] Stopped potential crash in the orders list screen when tapping "Filters" button [https://github.com/woocommerce/woocommerce-android/pull/12482] +- [**] Fixed a bug that caused the app to freeze after adding products to new order [https://github.com/woocommerce/woocommerce-android/pull/12493] +- [*] Fixes error when installing Jetpack plugin from the app [https://github.com/woocommerce/woocommerce-android/issues/12364] +- [*] [Internal] Updated the Compose WebView component to handle choosing files from the device when the web page requests it [https://github.com/woocommerce/woocommerce-android/pull/12499] +- [*] Fixes toolbar shown twice on Package details screen while adding shipping label [https://github.com/woocommerce/woocommerce-android/pull/12489] +- [*] Fixes navigation from product review detail screen when opened from notification [https://github.com/woocommerce/woocommerce-android/pull/12514] +- [*] Added a blaze campaign reminder that appears after abandoning a campaign creation. [https://github.com/woocommerce/woocommerce-android/pull/12452] +- [**] [Internal] Fix a crash during JITM cache initialisation +- [*] Fixes an issue that caused the an incorrect App Bar visibility in some rare cases [https://github.com/woocommerce/woocommerce-android/pull/12523] +- [*] Fixes duplicated toolbar in the Order Tracking Carriers List [https://github.com/woocommerce/woocommerce-android/pull/12537] +- [*] Fixes an issue on the Order Tracking screen that was causing the carrier being reset unintentionally [https://github.com/woocommerce/woocommerce-android/pull/12535] + 20.2 ----- - [**] Fixed navigation in tablet mode: after order is paid, the app should redirect to order details screen. [https://github.com/woocommerce/woocommerce-android/pull/12415] - [*] Show the back button on the Order Edit Screen for phones [https://github.com/woocommerce/woocommerce-android/pull/12421] - +- [**] Enables support to create Blaze Evergreen campaigns [https://github.com/woocommerce/woocommerce-android/issues/12176] +- [*] Fixed a bug that caused the button "Open Mail" to not work during Magic Link login [https://github.com/woocommerce/woocommerce-android/pull/12486] 20.1 ----- @@ -16,6 +48,7 @@ - [*] [Internal] Fixed an issue with date formatting for the Blaze campaign creation API request [https://github.com/woocommerce/woocommerce-android/pull/12372] - [*] Fixed a rare crash in the order editing screen. [https://github.com/woocommerce/woocommerce-android/pull/12382] - [*] Fixed the blaze budget screens that are unusable on small screens. [https://github.com/woocommerce/woocommerce-android/pull/12402] +- [**] Fixed navigation on "Collect payment" button tap in order creation flow [https://github.com/woocommerce/woocommerce-android/pull/12436] 20.0 ----- diff --git a/WooCommerce-Wear/build.gradle b/WooCommerce-Wear/build.gradle index 29b89cc7dcb..9af1466b7fb 100644 --- a/WooCommerce-Wear/build.gradle +++ b/WooCommerce-Wear/build.gradle @@ -215,6 +215,7 @@ dependencies { // See https://github.com/wordpress-mobile/WordPress-FluxC-Android/issues/919 exclude group: 'com.squareup.okhttp3' } + lintChecks "com.android.security.lint:lint:$securityLintVersion" } def checkGradlePropertiesFile() { diff --git a/WooCommerce-Wear/src/main/AndroidManifest.xml b/WooCommerce-Wear/src/main/AndroidManifest.xml index 555eae15a1b..4cb65b7eaf6 100644 --- a/WooCommerce-Wear/src/main/AndroidManifest.xml +++ b/WooCommerce-Wear/src/main/AndroidManifest.xml @@ -31,9 +31,63 @@ android:value="@integer/google_play_services_version" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -96,6 +150,7 @@ android:name=".app.WearMainActivity" android:exported="true" android:taskAffinity="" + android:configChanges="density|fontScale|keyboard|keyboardHidden|layoutDirection|locale|mcc|mnc|navigation|orientation|screenLayout|screenSize|smallestScreenSize|touchscreen|uiMode" android:theme="@style/MainActivityTheme.Starting"> diff --git a/WooCommerce-Wear/src/main/java/com/woocommerce/android/wear/complications/ComplicationUtils.kt b/WooCommerce-Wear/src/main/java/com/woocommerce/android/wear/complications/ComplicationUtils.kt index 31852d3848c..4477e6787bf 100644 --- a/WooCommerce-Wear/src/main/java/com/woocommerce/android/wear/complications/ComplicationUtils.kt +++ b/WooCommerce-Wear/src/main/java/com/woocommerce/android/wear/complications/ComplicationUtils.kt @@ -35,7 +35,7 @@ private fun createAppPendingIntent(context: Context): PendingIntent { } private fun createIcon(context: Context) = MonochromaticImage.Builder( - Icon.createWithResource(context, R.drawable.img_woo_bubble_white), + Icon.createWithResource(context, R.drawable.img_woo_bubble_monochrome), ).build() private fun String.toComplicationText() = PlainComplicationText.Builder(this).build() diff --git a/WooCommerce-Wear/src/main/java/com/woocommerce/android/wear/complications/FetchStatsForComplications.kt b/WooCommerce-Wear/src/main/java/com/woocommerce/android/wear/complications/FetchStatsForComplications.kt new file mode 100644 index 00000000000..09aff59e893 --- /dev/null +++ b/WooCommerce-Wear/src/main/java/com/woocommerce/android/wear/complications/FetchStatsForComplications.kt @@ -0,0 +1,69 @@ +package com.woocommerce.android.wear.complications + +import android.icu.text.CompactDecimalFormat +import com.woocommerce.android.wear.complications.FetchStatsForComplications.StatType.ORDER_COUNT +import com.woocommerce.android.wear.complications.FetchStatsForComplications.StatType.ORDER_TOTALS +import com.woocommerce.android.wear.complications.FetchStatsForComplications.StatType.VISITORS_COUNT +import com.woocommerce.android.wear.ui.login.LoginRepository +import com.woocommerce.android.wear.ui.stats.datasource.StatsRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.firstOrNull +import org.wordpress.android.fluxc.model.SiteModel +import javax.inject.Inject + +class FetchStatsForComplications @Inject constructor( + private val coroutineScope: CoroutineScope, + private val statsRepository: StatsRepository, + private val loginRepository: LoginRepository, + private val decimalFormat: CompactDecimalFormat +) { + suspend operator fun invoke(statType: StatType): String { + val site = coroutineScope.async { + loginRepository.selectedSiteFlow + .filterNotNull() + .firstOrNull() + }.await() ?: return DEFAULT_EMPTY_VALUE + + return when (statType) { + ORDER_TOTALS -> fetchTodayOrderTotals(site) + ORDER_COUNT -> fetchTodayOrderCount(site) + VISITORS_COUNT -> fetchTodayVisitors(site) + } + } + + private suspend fun fetchTodayOrderTotals( + site: SiteModel + ) = site.let { statsRepository.fetchRevenueStats(it) } + .getOrNull() + ?.parseTotal() + ?.totalSales + ?.let { decimalFormat.format(it) } + ?: DEFAULT_EMPTY_VALUE + + private suspend fun fetchTodayOrderCount( + site: SiteModel + ) = site.let { statsRepository.fetchRevenueStats(it) } + .getOrNull() + ?.parseTotal() + ?.ordersCount?.toString() + ?: DEFAULT_EMPTY_VALUE + + private suspend fun fetchTodayVisitors( + site: SiteModel + ) = site.let { statsRepository.fetchVisitorStats(it) } + .getOrNull() + ?.toString() + ?: DEFAULT_EMPTY_VALUE + + enum class StatType { + ORDER_TOTALS, + ORDER_COUNT, + VISITORS_COUNT + } + + companion object { + const val DEFAULT_EMPTY_VALUE = "N/A" + } +} diff --git a/WooCommerce-Wear/src/main/java/com/woocommerce/android/wear/complications/ordercount/OrderCountComplicationService.kt b/WooCommerce-Wear/src/main/java/com/woocommerce/android/wear/complications/ordercount/OrderCountComplicationService.kt new file mode 100644 index 00000000000..aa4d1bc498f --- /dev/null +++ b/WooCommerce-Wear/src/main/java/com/woocommerce/android/wear/complications/ordercount/OrderCountComplicationService.kt @@ -0,0 +1,42 @@ +package com.woocommerce.android.wear.complications.ordercount + +import androidx.wear.watchface.complications.data.ComplicationData +import androidx.wear.watchface.complications.data.ComplicationType +import androidx.wear.watchface.complications.datasource.ComplicationRequest +import androidx.wear.watchface.complications.datasource.SuspendingComplicationDataSourceService +import com.woocommerce.android.R +import com.woocommerce.android.wear.complications.FetchStatsForComplications +import com.woocommerce.android.wear.complications.FetchStatsForComplications.StatType.ORDER_COUNT +import com.woocommerce.android.wear.complications.createTextComplicationData +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@AndroidEntryPoint +class OrderCountComplicationService : SuspendingComplicationDataSourceService() { + + @Inject lateinit var fetchStatsForComplications: FetchStatsForComplications + + override fun getPreviewData(type: ComplicationType): ComplicationData? { + return when (type) { + ComplicationType.SHORT_TEXT -> createTextComplicationData( + context = applicationContext, + content = getString(R.string.order_count_complication_preview_value), + description = getString(R.string.order_count_complication_preview_description) + ) + + else -> null + } + } + + override suspend fun onComplicationRequest(request: ComplicationRequest): ComplicationData? { + return when (request.complicationType) { + ComplicationType.SHORT_TEXT -> createTextComplicationData( + context = applicationContext, + content = fetchStatsForComplications(ORDER_COUNT), + description = getString(R.string.order_count_complication_preview_description) + ) + + else -> null + } + } +} diff --git a/WooCommerce-Wear/src/main/java/com/woocommerce/android/wear/complications/ordertotals/OrderTotalsComplicationService.kt b/WooCommerce-Wear/src/main/java/com/woocommerce/android/wear/complications/ordertotals/OrderTotalsComplicationService.kt new file mode 100644 index 00000000000..5e485dfe61c --- /dev/null +++ b/WooCommerce-Wear/src/main/java/com/woocommerce/android/wear/complications/ordertotals/OrderTotalsComplicationService.kt @@ -0,0 +1,42 @@ +package com.woocommerce.android.wear.complications.ordertotals + +import androidx.wear.watchface.complications.data.ComplicationData +import androidx.wear.watchface.complications.data.ComplicationType +import androidx.wear.watchface.complications.datasource.ComplicationRequest +import androidx.wear.watchface.complications.datasource.SuspendingComplicationDataSourceService +import com.woocommerce.android.R +import com.woocommerce.android.wear.complications.FetchStatsForComplications +import com.woocommerce.android.wear.complications.FetchStatsForComplications.StatType.ORDER_TOTALS +import com.woocommerce.android.wear.complications.createTextComplicationData +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@AndroidEntryPoint +class OrderTotalsComplicationService : SuspendingComplicationDataSourceService() { + + @Inject lateinit var fetchStatsForComplications: FetchStatsForComplications + + override fun getPreviewData(type: ComplicationType): ComplicationData? { + return when (type) { + ComplicationType.SHORT_TEXT -> createTextComplicationData( + context = applicationContext, + content = getString(R.string.order_totals_complication_preview_value), + description = getString(R.string.order_totals_complication_preview_description) + ) + + else -> null + } + } + + override suspend fun onComplicationRequest(request: ComplicationRequest): ComplicationData? { + return when (request.complicationType) { + ComplicationType.SHORT_TEXT -> createTextComplicationData( + context = applicationContext, + content = fetchStatsForComplications(ORDER_TOTALS), + description = getString(R.string.order_totals_complication_preview_description) + ) + + else -> null + } + } +} diff --git a/WooCommerce-Wear/src/main/java/com/woocommerce/android/wear/complications/AppShortcutComplicationService.kt b/WooCommerce-Wear/src/main/java/com/woocommerce/android/wear/complications/shortcut/AppShortcutComplicationService.kt similarity index 90% rename from WooCommerce-Wear/src/main/java/com/woocommerce/android/wear/complications/AppShortcutComplicationService.kt rename to WooCommerce-Wear/src/main/java/com/woocommerce/android/wear/complications/shortcut/AppShortcutComplicationService.kt index 958f477a174..602b48d89be 100644 --- a/WooCommerce-Wear/src/main/java/com/woocommerce/android/wear/complications/AppShortcutComplicationService.kt +++ b/WooCommerce-Wear/src/main/java/com/woocommerce/android/wear/complications/shortcut/AppShortcutComplicationService.kt @@ -1,10 +1,11 @@ -package com.woocommerce.android.wear.complications +package com.woocommerce.android.wear.complications.shortcut import androidx.wear.watchface.complications.data.ComplicationData import androidx.wear.watchface.complications.data.ComplicationType import androidx.wear.watchface.complications.datasource.ComplicationRequest import androidx.wear.watchface.complications.datasource.SuspendingComplicationDataSourceService import com.woocommerce.android.R +import com.woocommerce.android.wear.complications.createImageComplicationData class AppShortcutComplicationService : SuspendingComplicationDataSourceService() { override fun getPreviewData(type: ComplicationType): ComplicationData? { diff --git a/WooCommerce-Wear/src/main/java/com/woocommerce/android/wear/complications/visitorscount/VisitorsCountComplicationService.kt b/WooCommerce-Wear/src/main/java/com/woocommerce/android/wear/complications/visitorscount/VisitorsCountComplicationService.kt new file mode 100644 index 00000000000..18d64f521df --- /dev/null +++ b/WooCommerce-Wear/src/main/java/com/woocommerce/android/wear/complications/visitorscount/VisitorsCountComplicationService.kt @@ -0,0 +1,42 @@ +package com.woocommerce.android.wear.complications.visitorscount + +import androidx.wear.watchface.complications.data.ComplicationData +import androidx.wear.watchface.complications.data.ComplicationType +import androidx.wear.watchface.complications.datasource.ComplicationRequest +import androidx.wear.watchface.complications.datasource.SuspendingComplicationDataSourceService +import com.woocommerce.android.R +import com.woocommerce.android.wear.complications.FetchStatsForComplications +import com.woocommerce.android.wear.complications.FetchStatsForComplications.StatType.VISITORS_COUNT +import com.woocommerce.android.wear.complications.createTextComplicationData +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@AndroidEntryPoint +class VisitorsCountComplicationService : SuspendingComplicationDataSourceService() { + + @Inject lateinit var fetchStatsForComplications: FetchStatsForComplications + + override fun getPreviewData(type: ComplicationType): ComplicationData? { + return when (type) { + ComplicationType.SHORT_TEXT -> createTextComplicationData( + context = applicationContext, + content = getString(R.string.visitors_count_complication_preview_value), + description = getString(R.string.visitors_count_complication_preview_description) + ) + + else -> null + } + } + + override suspend fun onComplicationRequest(request: ComplicationRequest): ComplicationData? { + return when (request.complicationType) { + ComplicationType.SHORT_TEXT -> createTextComplicationData( + context = applicationContext, + content = fetchStatsForComplications(VISITORS_COUNT), + description = getString(R.string.visitors_count_complication_preview_description) + ) + + else -> null + } + } +} diff --git a/WooCommerce-Wear/src/main/java/com/woocommerce/android/wear/di/AppConfigModule.kt b/WooCommerce-Wear/src/main/java/com/woocommerce/android/wear/di/AppConfigModule.kt index d58697d79c4..dc9da7f8f75 100644 --- a/WooCommerce-Wear/src/main/java/com/woocommerce/android/wear/di/AppConfigModule.kt +++ b/WooCommerce-Wear/src/main/java/com/woocommerce/android/wear/di/AppConfigModule.kt @@ -1,6 +1,7 @@ package com.woocommerce.android.wear.di import android.content.Context +import android.icu.text.CompactDecimalFormat import com.woocommerce.android.BuildConfig import com.woocommerce.android.wear.system.ConnectionStatus import com.woocommerce.android.wear.ui.login.LoginRepository @@ -35,4 +36,10 @@ class AppConfigModule { @Provides fun provideDefaultLocale(): Locale = Locale.getDefault() + + @Provides + fun provideCompactDecimalFormat(locale: Locale) = CompactDecimalFormat.getInstance( + locale, + CompactDecimalFormat.CompactStyle.SHORT + ) } diff --git a/WooCommerce-Wear/src/main/res/drawable/img_woo_bubble_monochrome.xml b/WooCommerce-Wear/src/main/res/drawable/img_woo_bubble_monochrome.xml new file mode 100644 index 00000000000..00bfd189dad --- /dev/null +++ b/WooCommerce-Wear/src/main/res/drawable/img_woo_bubble_monochrome.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/WooCommerce-Wear/src/main/res/values/colors_base.xml b/WooCommerce-Wear/src/main/res/values/colors_base.xml index 3e368813626..1cf31636763 100644 --- a/WooCommerce-Wear/src/main/res/values/colors_base.xml +++ b/WooCommerce-Wear/src/main/res/values/colors_base.xml @@ -110,7 +110,7 @@ @color/woo_black_90_alpha_012 @color/woo_black_90_alpha_012 @color/woo_black_90_alpha_012 - @color/woo_purple_60 + @color/woo_black @color/woo_white @color/color_surface diff --git a/WooCommerce-Wear/src/main/res/values/strings.xml b/WooCommerce-Wear/src/main/res/values/strings.xml index b3acd4e2440..ad497504f0f 100644 --- a/WooCommerce-Wear/src/main/res/values/strings.xml +++ b/WooCommerce-Wear/src/main/res/values/strings.xml @@ -6,7 +6,7 @@ Connecting Log in - Log in to Woo on your phone and hold your Watch nearby + Log in to Woo on your phone and hold it nearby It\'s not working @@ -32,4 +32,13 @@ App Shortcut App Shortcut + Order Totals + $42k + Display your store today\'s order totals + Order Count + 7 + Display your store today\'s order count + Visitors Count + 42 + Display your store today\'s visitors count diff --git a/WooCommerce-Wear/src/main/res/values/styles.xml b/WooCommerce-Wear/src/main/res/values/styles.xml index 915888c6505..f69a70a5f00 100644 --- a/WooCommerce-Wear/src/main/res/values/styles.xml +++ b/WooCommerce-Wear/src/main/res/values/styles.xml @@ -2,7 +2,7 @@ diff --git a/WooCommerce-Wear/src/test/java/com/woocommerce/android/wear/complications/FetchStatsForComplicationsTest.kt b/WooCommerce-Wear/src/test/java/com/woocommerce/android/wear/complications/FetchStatsForComplicationsTest.kt new file mode 100644 index 00000000000..eff47e28b4c --- /dev/null +++ b/WooCommerce-Wear/src/test/java/com/woocommerce/android/wear/complications/FetchStatsForComplicationsTest.kt @@ -0,0 +1,111 @@ +package com.woocommerce.android.wear.complications + +import android.icu.text.CompactDecimalFormat +import com.woocommerce.android.BaseUnitTest +import com.woocommerce.android.wear.ui.login.LoginRepository +import com.woocommerce.android.wear.ui.stats.datasource.StatsRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.TestScope +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.WCRevenueStatsModel +import kotlin.test.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class FetchStatsForComplicationsTest : BaseUnitTest() { + private lateinit var sut: FetchStatsForComplications + private val coroutineScope: CoroutineScope = TestScope(coroutinesTestRule.testDispatcher) + private val statsRepository: StatsRepository = mock() + private val loginRepository: LoginRepository = mock() + private val decimalFormat: CompactDecimalFormat = mock() + + @Before + fun setUp() { + sut = FetchStatsForComplications(coroutineScope, statsRepository, loginRepository, decimalFormat) + } + + @Test + fun `returns formatted order totals when site is selected`() = testBlocking { + val site = SiteModel() + val total = mock { + on { totalSales } doReturn 100.0 + } + val revenueStats = mock { + on { parseTotal() } doReturn total + } + whenever(loginRepository.selectedSiteFlow).thenReturn(MutableStateFlow(site)) + whenever(statsRepository.fetchRevenueStats(site)).thenReturn(Result.success(revenueStats)) + whenever(decimalFormat.format(100.0)).thenReturn("100") + + val result = sut(FetchStatsForComplications.StatType.ORDER_TOTALS) + + assertThat(result).isEqualTo("100") + } + + @Test + fun `returns default value when order totals fetch fails`() = testBlocking { + val site = SiteModel() + whenever(loginRepository.selectedSiteFlow).thenReturn(MutableStateFlow(site)) + whenever(statsRepository.fetchRevenueStats(site)).thenReturn(Result.failure(Exception())) + + val result = sut(FetchStatsForComplications.StatType.ORDER_TOTALS) + + assertThat(result).isEqualTo(FetchStatsForComplications.DEFAULT_EMPTY_VALUE) + } + + @Test + fun `returns order count when site is selected`() = testBlocking { + val site = SiteModel() + val total = mock { + on { ordersCount } doReturn 10 + } + val revenueStats = mock { + on { parseTotal() } doReturn total + } + whenever(loginRepository.selectedSiteFlow).thenReturn(MutableStateFlow(site)) + whenever(statsRepository.fetchRevenueStats(site)).thenReturn(Result.success(revenueStats)) + + val result = sut(FetchStatsForComplications.StatType.ORDER_COUNT) + + assertThat(result).isEqualTo("10") + } + + @Test + fun `returns default value when order count fetch fails`() = testBlocking { + val site = SiteModel() + whenever(loginRepository.selectedSiteFlow).thenReturn(MutableStateFlow(site)) + whenever(statsRepository.fetchRevenueStats(site)).thenReturn(Result.failure(Exception())) + + val result = sut(FetchStatsForComplications.StatType.ORDER_COUNT) + + assertThat(result).isEqualTo(FetchStatsForComplications.DEFAULT_EMPTY_VALUE) + } + + @Test + fun `returns visitors count when site is selected`() = testBlocking { + val site = SiteModel() + whenever(loginRepository.selectedSiteFlow).thenReturn(MutableStateFlow(site)) + whenever(statsRepository.fetchVisitorStats(site)).thenReturn(Result.success(100)) + + val result = sut(FetchStatsForComplications.StatType.VISITORS_COUNT) + + assertThat(result).isEqualTo("100") + } + + @Test + fun `returns default value when visitors count fetch fails`() = testBlocking { + val site = SiteModel() + whenever(loginRepository.selectedSiteFlow).thenReturn(MutableStateFlow(site)) + whenever(statsRepository.fetchVisitorStats(site)).thenReturn(Result.failure(Exception())) + + val result = sut(FetchStatsForComplications.StatType.VISITORS_COUNT) + + assertThat(result).isEqualTo(FetchStatsForComplications.DEFAULT_EMPTY_VALUE) + } +} diff --git a/WooCommerce/build.gradle b/WooCommerce/build.gradle index 20f1c7292de..17c83467b85 100644 --- a/WooCommerce/build.gradle +++ b/WooCommerce/build.gradle @@ -213,6 +213,8 @@ android { } lintOptions { + sarifReport System.getenv('CI') ? true : false + checkDependencies true disable 'InvalidPackage' } @@ -467,6 +469,8 @@ dependencies { implementation "com.google.guava:guava:$guavaVersion" implementation "com.google.protobuf:protobuf-javalite:$protobufVersion" + + lintChecks "com.android.security.lint:lint:$securityLintVersion" } protobuf { diff --git a/WooCommerce/src/androidTest/kotlin/com/woocommerce/android/WooPosExitPosDialogTest.kt b/WooCommerce/src/androidTest/kotlin/com/woocommerce/android/WooPosExitPosDialogTest.kt index f416eabaca7..1205880816e 100644 --- a/WooCommerce/src/androidTest/kotlin/com/woocommerce/android/WooPosExitPosDialogTest.kt +++ b/WooCommerce/src/androidTest/kotlin/com/woocommerce/android/WooPosExitPosDialogTest.kt @@ -75,6 +75,7 @@ class WooPosExitPosDialogTest : TestBase() { .assertIsDisplayed() true } catch (e: AssertionError) { + e.printStackTrace() false } } @@ -111,6 +112,7 @@ class WooPosExitPosDialogTest : TestBase() { .assertIsDisplayed() true } catch (e: AssertionError) { + e.printStackTrace() false } } @@ -159,6 +161,7 @@ class WooPosExitPosDialogTest : TestBase() { .assertIsDisplayed() true } catch (e: AssertionError) { + e.printStackTrace() false } } @@ -199,6 +202,7 @@ class WooPosExitPosDialogTest : TestBase() { .assertIsDisplayed() true } catch (e: AssertionError) { + e.printStackTrace() false } } @@ -239,6 +243,7 @@ class WooPosExitPosDialogTest : TestBase() { .assertIsDisplayed() true } catch (e: AssertionError) { + e.printStackTrace() false } } diff --git a/WooCommerce/src/androidTest/kotlin/com/woocommerce/android/WooPosHomeProductInfoDialogTest.kt b/WooCommerce/src/androidTest/kotlin/com/woocommerce/android/WooPosHomeProductInfoDialogTest.kt index 0f0020307c4..cea5e467b34 100644 --- a/WooCommerce/src/androidTest/kotlin/com/woocommerce/android/WooPosHomeProductInfoDialogTest.kt +++ b/WooCommerce/src/androidTest/kotlin/com/woocommerce/android/WooPosHomeProductInfoDialogTest.kt @@ -76,6 +76,7 @@ class WooPosHomeProductInfoDialogTest : TestBase() { .assertIsDisplayed() true } catch (e: AssertionError) { + e.printStackTrace() false } } @@ -94,8 +95,7 @@ class WooPosHomeProductInfoDialogTest : TestBase() { } @Test - fun testProductInfoDialogIsDisplayedOnIconClick() = runTest { - + fun testProductInfoDialogIsDisplayedOnIconClick() = runTest { composeTestRule.waitUntil(5000) { try { composeTestRule.onNodeWithTag("product_list") @@ -103,6 +103,7 @@ class WooPosHomeProductInfoDialogTest : TestBase() { .assertIsDisplayed() true } catch (e: AssertionError) { + e.printStackTrace() false } } @@ -134,6 +135,7 @@ class WooPosHomeProductInfoDialogTest : TestBase() { .assertIsDisplayed() true } catch (e: AssertionError) { + e.printStackTrace() false } } @@ -171,8 +173,7 @@ class WooPosHomeProductInfoDialogTest : TestBase() { } @Test - fun testProductInfoDialogIsDismissedWhenCloseClick() = runTest { - + fun testProductInfoDialogIsDismissedWhenCloseClick() = runTest { composeTestRule.waitUntil(5000) { try { composeTestRule.onNodeWithTag("product_list") @@ -180,6 +181,7 @@ class WooPosHomeProductInfoDialogTest : TestBase() { .assertIsDisplayed() true } catch (e: AssertionError) { + e.printStackTrace() false } } @@ -207,8 +209,7 @@ class WooPosHomeProductInfoDialogTest : TestBase() { } @Test - fun testProductInfoDialogIsDismissedWhenPrimaryButtonClicked() = runTest { - + fun testProductInfoDialogIsDismissedWhenPrimaryButtonClicked() = runTest { composeTestRule.waitUntil(5000) { try { composeTestRule.onNodeWithTag("product_list") @@ -216,6 +217,7 @@ class WooPosHomeProductInfoDialogTest : TestBase() { .assertIsDisplayed() true } catch (e: AssertionError) { + e.printStackTrace() false } } @@ -226,7 +228,7 @@ class WooPosHomeProductInfoDialogTest : TestBase() { composeTestRule.waitUntil(5000) { composeTestRule.onNodeWithContentDescription( composeTestRule.activity.getString(R.string.woopos_banner_simple_products_info_content_description) - ) + ) .assertExists() true } @@ -247,41 +249,4 @@ class WooPosHomeProductInfoDialogTest : TestBase() { true } } - - @Test - fun testProductInfoDialogIsDismissedWhenOutsideDialogClicked() = runTest { - - composeTestRule.waitUntil(5000) { - try { - composeTestRule.onNodeWithTag("product_list") - .assertExists() - .assertIsDisplayed() - true - } catch (e: AssertionError) { - false - } - } - composeTestRule.onNodeWithContentDescription( - composeTestRule.activity.getString(R.string.woopos_banner_simple_products_close_content_description) - ).performClick() - - composeTestRule.waitUntil(5000) { - composeTestRule.onNodeWithContentDescription( - composeTestRule.activity.getString(R.string.woopos_banner_simple_products_info_content_description) - ).performClick() - true - } - - composeTestRule.waitUntil(5000) { - composeTestRule.onNodeWithTag("woo_pos_product_info_dialog_background") - .performClick() - true - } - - composeTestRule.waitUntil(5000) { - composeTestRule.onNodeWithTag("woo_pos_product_info_dialog") - .assertIsNotDisplayed() - true - } - } } diff --git a/WooCommerce/src/androidTest/kotlin/com/woocommerce/android/WooPosProductScreenTest.kt b/WooCommerce/src/androidTest/kotlin/com/woocommerce/android/WooPosProductScreenTest.kt new file mode 100644 index 00000000000..2b42baa5c24 --- /dev/null +++ b/WooCommerce/src/androidTest/kotlin/com/woocommerce/android/WooPosProductScreenTest.kt @@ -0,0 +1,494 @@ +@file:Suppress("DEPRECATION") + +package com.woocommerce.android + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.isNotDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.rule.ActivityTestRule +import com.woocommerce.android.e2e.helpers.InitializationRule +import com.woocommerce.android.e2e.helpers.TestBase +import com.woocommerce.android.e2e.helpers.util.MocksReader +import com.woocommerce.android.e2e.helpers.util.ProductData +import com.woocommerce.android.e2e.helpers.util.iterator +import com.woocommerce.android.e2e.rules.RetryTestRule +import com.woocommerce.android.e2e.screens.TabNavComponent +import com.woocommerce.android.e2e.screens.login.WelcomeScreen +import com.woocommerce.android.ui.login.LoginActivity +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.test.runTest +import org.json.JSONObject +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import javax.inject.Inject + +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +class WooPosProductScreenTest : TestBase() { + @get:Rule(order = 1) + val rule = HiltAndroidRule(this) + + @get:Rule(order = 2) + val composeTestRule = createAndroidComposeRule() + + @get:Rule(order = 3) + val initRule = InitializationRule() + + @get:Rule(order = 4) + var activityRule = ActivityTestRule(LoginActivity::class.java) + + @get:Rule(order = 5) + var retryTestRule = RetryTestRule() + + @Inject + lateinit var dataStore: DataStore + + @Before + fun setUp() = runTest { + rule.inject() + dataStore.edit { it.clear() } + WelcomeScreen + .skipCarouselIfNeeded() + .selectLogin() + .proceedWith(BuildConfig.SCREENSHOTS_URL) + .proceedWith(BuildConfig.SCREENSHOTS_USERNAME) + .proceedWith(BuildConfig.SCREENSHOTS_PASSWORD) + + TabNavComponent() + .gotoMoreMenuScreen() + .openPOSScreen(composeTestRule) + } + + @Test + fun testProductScreenIsDisplayedWhenPosModeEntered() = runTest { + composeTestRule.waitUntil(5000) { + try { + composeTestRule.onNodeWithTag("product_list") + .assertExists() + .assertIsDisplayed() + true + } catch (e: AssertionError) { + e.printStackTrace() + false + } + } + } + + @Test + fun testCartItemIsAddedWhenProductItemClicked() = runTest { + val productsJSONArray = MocksReader().readAllProductsToArray() + val products = mutableListOf() + for (productJSON in productsJSONArray.iterator()) { + products.add(mapJSONToProduct(productJSON)) + } + val firstProduct = products.first() + composeTestRule.waitUntil(5000) { + try { + composeTestRule.onNodeWithTag("product_list") + .assertExists() + .assertIsDisplayed() + true + } catch (e: AssertionError) { + e.printStackTrace() + false + } + } + // asserting the cart is empty initially + composeTestRule.waitUntil(5000) { + composeTestRule.onNodeWithTag("woo_pos_cart_item_${firstProduct.name}") + .assertDoesNotExist() + true + } + + composeTestRule.waitUntil(5000) { + composeTestRule.onNodeWithTag("woo_pos_product_item_${firstProduct.name}").performClick() + true + } + + composeTestRule.waitUntil(5000) { + composeTestRule.onNodeWithTag("woo_pos_cart_item_${firstProduct.name}") + .assertExists() + true + } + } + + @Test + fun testCheckoutButtonIsNotVisibleWhenCartListIsNotDisplayed() = runTest { + composeTestRule.waitUntil(5000) { + try { + composeTestRule.onNodeWithTag("product_list") + .assertExists() + .assertIsDisplayed() + true + } catch (e: AssertionError) { + e.printStackTrace() + false + } + } + // asserting the cart is empty + composeTestRule.waitUntil(5000) { + composeTestRule.onNodeWithTag("woo_pos_cart_list") + .assertIsNotDisplayed() + true + } + + composeTestRule.waitUntil(5000) { + composeTestRule.onNodeWithTag("woo_pos_checkout_button") + .assertIsNotDisplayed() + true + } + } + + @Suppress("LongMethod") + @Test + fun testCheckoutButtonVisibility() = runTest { + val productsJSONArray = MocksReader().readAllProductsToArray() + val products = mutableListOf() + for (productJSON in productsJSONArray.iterator()) { + products.add(mapJSONToProduct(productJSON)) + } + val firstProduct = products.first() + composeTestRule.waitUntil(5000) { + try { + composeTestRule.onNodeWithTag("product_list") + .assertExists() + .assertIsDisplayed() + true + } catch (e: AssertionError) { + e.printStackTrace() + false + } + } + composeTestRule.waitUntil(5000) { + composeTestRule.onNodeWithTag("woo_pos_cart_list") + .assertIsNotDisplayed() + true + } + + // asserting the checkout button is not displayed when the cart is empty + composeTestRule.waitUntil(5000) { + composeTestRule.onNodeWithTag("woo_pos_checkout_button") + .assertIsNotDisplayed() + true + } + + composeTestRule.waitUntil(5000) { + composeTestRule.onNodeWithTag("woo_pos_product_item_${firstProduct.name}").performClick() + true + } + + composeTestRule.waitUntil(5000) { + composeTestRule.onNodeWithTag("woo_pos_cart_item_${firstProduct.name}") + .assertExists() + true + } + + // asserting the checkout button is displayed when the cart has items + composeTestRule.waitUntil(5000) { + composeTestRule.onNodeWithTag("woo_pos_checkout_button") + .assertIsDisplayed() + true + } + + composeTestRule.waitUntil(5000) { + composeTestRule.onNodeWithTag("woo_pos_cart_item_close_icon_${firstProduct.name}") + .performClick() + true + } + + // asserting the checkout button is not displayed when the cart items are removed + composeTestRule.waitUntil(5000) { + composeTestRule.onNodeWithTag("woo_pos_checkout_button") + .assertIsNotDisplayed() + true + } + + composeTestRule.waitUntil(5000) { + composeTestRule.onNodeWithTag("woo_pos_product_item_${firstProduct.name}").performClick() + true + } + + composeTestRule.waitUntil(5000) { + composeTestRule.onNodeWithTag("woo_pos_checkout_button") + .performClick() + true + } + + // asserting the checkout button is not displayed when we move to the checkout screen + composeTestRule.waitUntil(5000) { + composeTestRule.onNodeWithTag("woo_pos_checkout_button") + .assertIsNotDisplayed() + true + } + } + + @Suppress("LongMethod") + @Test + fun testCartItemRemovalFunctionality() = runTest { + val productsJSONArray = MocksReader().readAllProductsToArray() + val products = mutableListOf() + for (productJSON in productsJSONArray.iterator()) { + products.add(mapJSONToProduct(productJSON)) + } + val firstProduct = products.first() + val secondProduct = products[1] + val thirdProduct = products[2] + composeTestRule.waitUntil(5000) { + try { + composeTestRule.onNodeWithTag("product_list") + .assertExists() + .assertIsDisplayed() + true + } catch (e: AssertionError) { + e.printStackTrace() + false + } + } + composeTestRule.waitUntil(5000) { + composeTestRule.onNodeWithTag("woo_pos_cart_list") + .assertIsNotDisplayed() + true + } + + composeTestRule.waitUntil(5000) { + composeTestRule.onNodeWithTag("woo_pos_product_item_${firstProduct.name}").performClick() + true + } + composeTestRule.waitUntil(5000) { + composeTestRule.onNodeWithTag("woo_pos_product_item_${secondProduct.name}").performClick() + true + } + composeTestRule.waitUntil(5000) { + composeTestRule.onNodeWithTag("woo_pos_product_item_${thirdProduct.name}").performClick() + true + } + + composeTestRule.waitUntil(5000) { + composeTestRule.onNodeWithTag("woo_pos_cart_item_${firstProduct.name}") + .assertExists() + true + } + composeTestRule.waitUntil(5000) { + composeTestRule.onNodeWithTag("woo_pos_cart_item_${secondProduct.name}") + .assertExists() + true + } + composeTestRule.waitUntil(5000) { + composeTestRule.onNodeWithTag("woo_pos_cart_item_${thirdProduct.name}") + .assertExists() + true + } + + composeTestRule.waitUntil(5000) { + composeTestRule.onNodeWithTag("woo_pos_cart_item_close_icon_${secondProduct.name}") + .performClick() + true + } + + composeTestRule.waitUntil(5000) { + composeTestRule.onNodeWithTag("woo_pos_cart_item_${secondProduct.name}") + .isNotDisplayed() + true + } + } + + @Test + fun testClickingCheckoutButtonHidesProductsScreen() = runTest { + val productsJSONArray = MocksReader().readAllProductsToArray() + val products = mutableListOf() + for (productJSON in productsJSONArray.iterator()) { + products.add(mapJSONToProduct(productJSON)) + } + val firstProduct = products.first() + composeTestRule.waitUntil(5000) { + try { + composeTestRule.onNodeWithTag("product_list") + .assertExists() + .assertIsDisplayed() + true + } catch (e: AssertionError) { + e.printStackTrace() + false + } + } + composeTestRule.waitUntil(5000) { + composeTestRule.onNodeWithTag("woo_pos_cart_list") + .assertIsNotDisplayed() + true + } + + composeTestRule.waitUntil(5000) { + composeTestRule.onNodeWithTag("woo_pos_product_item_${firstProduct.name}").performClick() + true + } + + composeTestRule.waitUntil(5000) { + composeTestRule.onNodeWithTag("woo_pos_cart_item_${firstProduct.name}") + .assertExists() + true + } + + composeTestRule.waitUntil(5000) { + composeTestRule.onNodeWithTag("woo_pos_checkout_button") + .assertIsDisplayed() + true + } + + composeTestRule.waitUntil(5000) { + composeTestRule.onNodeWithTag("woo_pos_checkout_button") + .performClick() + true + } + + composeTestRule.waitUntil(5000) { + composeTestRule.onNodeWithTag("product_list") + .assertIsNotDisplayed() + true + } + } + + @Test + fun testClickingCheckoutButtonDisplaysTotalsScreen() = runTest { + val productsJSONArray = MocksReader().readAllProductsToArray() + val products = mutableListOf() + for (productJSON in productsJSONArray.iterator()) { + products.add(mapJSONToProduct(productJSON)) + } + val firstProduct = products.first() + composeTestRule.waitUntil(5000) { + try { + composeTestRule.onNodeWithTag("product_list") + .assertExists() + .assertIsDisplayed() + true + } catch (e: AssertionError) { + e.printStackTrace() + false + } + } + composeTestRule.waitUntil(5000) { + composeTestRule.onNodeWithTag("woo_pos_cart_list") + .assertIsNotDisplayed() + true + } + + composeTestRule.waitUntil(5000) { + composeTestRule.onNodeWithTag("woo_pos_product_item_${firstProduct.name}").performClick() + true + } + + composeTestRule.waitUntil(5000) { + composeTestRule.onNodeWithTag("woo_pos_cart_item_${firstProduct.name}") + .assertExists() + true + } + + composeTestRule.waitUntil(5000) { + composeTestRule.onNodeWithTag("woo_pos_checkout_button") + .assertIsDisplayed() + true + } + + composeTestRule.waitUntil(5000) { + composeTestRule.onNodeWithTag("woo_pos_checkout_button") + .performClick() + true + } + + composeTestRule.waitUntil(5000) { + composeTestRule.onNodeWithTag("woo_pos_totals_loaded_screen") + .assertIsDisplayed() + true + } + } + + @Test + fun testClickingCheckoutButtonDisplaysCollectPaymentButton() = runTest { + val productsJSONArray = MocksReader().readAllProductsToArray() + val products = mutableListOf() + for (productJSON in productsJSONArray.iterator()) { + products.add(mapJSONToProduct(productJSON)) + } + val firstProduct = products.first() + composeTestRule.waitUntil(5000) { + try { + composeTestRule.onNodeWithTag("product_list") + .assertExists() + .assertIsDisplayed() + true + } catch (e: AssertionError) { + e.printStackTrace() + false + } + } + composeTestRule.waitUntil(5000) { + composeTestRule.onNodeWithTag("woo_pos_cart_list") + .assertIsNotDisplayed() + true + } + + composeTestRule.waitUntil(5000) { + composeTestRule.onNodeWithTag("woo_pos_product_item_${firstProduct.name}").performClick() + true + } + + composeTestRule.waitUntil(5000) { + composeTestRule.onNodeWithTag("woo_pos_cart_item_${firstProduct.name}") + .assertExists() + true + } + + composeTestRule.waitUntil(5000) { + composeTestRule.onNodeWithTag("woo_pos_checkout_button") + .assertIsDisplayed() + true + } + + composeTestRule.waitUntil(5000) { + composeTestRule.onNodeWithTag("woo_pos_checkout_button") + .performClick() + true + } + + composeTestRule.waitUntil(5000) { + composeTestRule.onNodeWithTag("woo_pos_totals_loaded_screen") + .assertIsDisplayed() + true + } + + composeTestRule.waitUntil(5000) { + composeTestRule.onNodeWithText( + composeTestRule.activity.getString( + R.string.woopos_payment_collect_payment_label + ) + ) + .assertIsDisplayed() + true + } + } + + private fun mapJSONToProduct(productJSON: JSONObject): ProductData { + return ProductData( + id = productJSON.getInt("id"), + name = productJSON.getString("name"), + stockStatusRaw = productJSON.getString("stock_status"), + priceDiscountedRaw = productJSON.getString("price"), + priceRegularRaw = productJSON.getString("regular_price"), + typeRaw = productJSON.getString("type"), + rating = productJSON.getInt("average_rating"), + reviewsCount = productJSON.getInt("rating_count") + ) + } +} diff --git a/WooCommerce/src/androidTest/kotlin/com/woocommerce/android/e2e/screens/notifications/NotificationsScreen.kt b/WooCommerce/src/androidTest/kotlin/com/woocommerce/android/e2e/screens/notifications/NotificationsScreen.kt index 0785b8ca0db..fea3ae0e5b6 100644 --- a/WooCommerce/src/androidTest/kotlin/com/woocommerce/android/e2e/screens/notifications/NotificationsScreen.kt +++ b/WooCommerce/src/androidTest/kotlin/com/woocommerce/android/e2e/screens/notifications/NotificationsScreen.kt @@ -8,7 +8,7 @@ import com.woocommerce.android.e2e.screens.TabNavComponent import com.woocommerce.android.model.Notification import com.woocommerce.android.notifications.NotificationChannelType import com.woocommerce.android.notifications.WooNotificationBuilder -import com.woocommerce.android.notifications.WooNotificationType.NEW_ORDER +import com.woocommerce.android.notifications.WooNotificationType.NewOrder /** * This is not a screen per-se, as it shows the notification drawer with a push notification. @@ -32,7 +32,7 @@ class NotificationsScreen(private val wooNotificationBuilder: WooNotificationBui icon = "https://s.wp.com/wp-content/mu-plugins/notes/images/update-payment-2x.png", noteTitle = getTranslatedString(R.string.tests_notification_new_order_title), noteMessage = getTranslatedString(R.string.tests_notification_new_order_message), - noteType = NEW_ORDER, + noteType = NewOrder, channelType = NotificationChannelType.NEW_ORDER ), isGroupNotification = false diff --git a/WooCommerce/src/main/AndroidManifest.xml b/WooCommerce/src/main/AndroidManifest.xml index 33512e09e0f..6b96d630962 100644 --- a/WooCommerce/src/main/AndroidManifest.xml +++ b/WooCommerce/src/main/AndroidManifest.xml @@ -23,6 +23,10 @@ + + + + Comparable?.lesserThan(other: T) = this?.let { it < other } ?: false +/** + * Shortens a number to the nearest thousand and appends a 'K' to the end. For example, 1000 will be shortened to 1K. + */ +fun compactNumberCompat(number: Long, locale: Locale = Locale.getDefault()): String = + if (VERSION.SDK_INT >= VERSION_CODES.R) { + NumberFormatter.with() + .notation(Notation.compactShort()) + .locale(locale) + .format(number) + .toString() + } else { + CompactDecimalFormat.getInstance(locale, CompactDecimalFormat.CompactStyle.SHORT) + .format(number.toDouble()) + } + const val PERCENTAGE_BASE = 100.0 diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/extensions/NumberExtensionsWrapper.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/extensions/NumberExtensionsWrapper.kt new file mode 100644 index 00000000000..f655873a018 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/extensions/NumberExtensionsWrapper.kt @@ -0,0 +1,12 @@ +package com.woocommerce.android.extensions + +import java.util.Locale +import javax.inject.Inject +import com.woocommerce.android.extensions.compactNumberCompat as compactNumberCompatExt + +class NumberExtensionsWrapper @Inject constructor() { + fun compactNumberCompat( + number: Long, + locale: Locale = Locale.getDefault() + ): String = compactNumberCompatExt(number, locale) +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/media/ProductImagesUploadWorker.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/media/ProductImagesUploadWorker.kt index 78c2fc15b09..1a604bb58fa 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/media/ProductImagesUploadWorker.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/media/ProductImagesUploadWorker.kt @@ -242,7 +242,7 @@ class ProductImagesUploadWorker @Inject constructor( suspend fun fetchProductWithRetries(productId: Long): Product? { var retries = 0 while (retries < PRODUCT_UPDATE_RETRIES) { - val product = productDetailRepository.fetchProductOrLoadFromCache(productId) + val product = productDetailRepository.fetchAndGetProduct(productId) if (product != null && productDetailRepository.lastFetchProductErrorType == null) { return product } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/mediapicker/MediaPickerDialogScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/mediapicker/MediaPickerDialogScreen.kt index 9c2fed86640..bc2a15ea884 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/mediapicker/MediaPickerDialogScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/mediapicker/MediaPickerDialogScreen.kt @@ -35,7 +35,9 @@ import org.wordpress.android.mediapicker.api.MediaPickerSetup.DataSource.WP_MEDI @Composable fun MediaPickerDialog( onDismissRequest: () -> Unit, - onMediaLibraryRequested: (DataSource) -> Unit + onMediaLibraryRequested: (DataSource) -> Unit, + withProductImagePicker: Boolean = false, + onProductImagesRequested: () -> Unit = {} ) { Dialog(onDismissRequest = onDismissRequest) { Card( @@ -74,6 +76,13 @@ fun MediaPickerDialog( title = string.image_source_wp_media_library, onClick = { onMediaLibraryRequested(WP_MEDIA_LIBRARY) } ) + if (withProductImagePicker) { + DialogButton( + image = drawable.ic_product, + title = string.image_source_product_images, + onClick = onProductImagesRequested + ) + } } } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/model/Notification.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/model/Notification.kt index c48bcdb5ef5..622ad2a574e 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/model/Notification.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/model/Notification.kt @@ -26,10 +26,13 @@ data class Notification( val data: String? = null ) : Parcelable { @IgnoredOnParcel - val isOrderNotification = noteType == WooNotificationType.NEW_ORDER + val isOrderNotification = noteType is WooNotificationType.NewOrder @IgnoredOnParcel - val isReviewNotification = noteType == WooNotificationType.PRODUCT_REVIEW + val isReviewNotification = noteType is WooNotificationType.ProductReview + + @IgnoredOnParcel + val isBlazeNotification = noteType is WooNotificationType.BlazeStatusUpdate /** * Notifications are grouped based on the notification type and the store the notification belongs to. @@ -74,6 +77,11 @@ fun NotificationModel.getUniqueId(): Long { return when (this.type) { NotificationModel.Kind.STORE_ORDER -> this.meta?.ids?.order ?: 0L NotificationModel.Kind.COMMENT -> this.meta?.ids?.comment ?: 0L + NotificationModel.Kind.BLAZE_APPROVED_NOTE, + NotificationModel.Kind.BLAZE_REJECTED_NOTE, + NotificationModel.Kind.BLAZE_CANCELLED_NOTE, + NotificationModel.Kind.BLAZE_PERFORMED_NOTE -> this.meta?.ids?.campaignId ?: 0L + else -> 0L } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/model/Product.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/model/Product.kt index ef373bb37c3..c27fc2aac06 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/model/Product.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/model/Product.kt @@ -92,7 +92,8 @@ data class Product( val bundleMinSize: Float?, val bundleMaxSize: Float?, val groupOfQuantity: Int?, - val combineVariationQuantities: Boolean? + val combineVariationQuantities: Boolean?, + val password: String? ) : Parcelable, IProduct { companion object { const val TAX_CLASS_DEFAULT = "standard" @@ -157,7 +158,8 @@ data class Product( minAllowedQuantity == product.minAllowedQuantity && maxAllowedQuantity == product.maxAllowedQuantity && groupOfQuantity == product.groupOfQuantity && - combineVariationQuantities == product.combineVariationQuantities + combineVariationQuantities == product.combineVariationQuantities && + password == product.password } val hasCategories get() = categories.isNotEmpty() @@ -479,6 +481,7 @@ fun Product.toDataModel(storedProductModel: WCProductModel? = null): WCProductMo it.maxAllowedQuantity = maxAllowedQuantity ?: -1 it.groupOfQuantity = groupOfQuantity ?: -1 it.combineVariationQuantities = combineVariationQuantities ?: false + it.password = password // Subscription details are currently the only editable metadata fields from the app. it.metadata = subscription?.toMetadataJson().toString() } @@ -590,7 +593,8 @@ fun WCProductModel.toAppModel(): Product { bundleMinSize = this.bundleMinSize, bundleMaxSize = this.bundleMaxSize, groupOfQuantity = this.groupOfQuantity(), - combineVariationQuantities = this.combineVariationQuantities + combineVariationQuantities = this.combineVariationQuantities, + password = this.password ) } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/notifications/WooNotificationType.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/notifications/WooNotificationType.kt index 23d50cf95ae..3f566037040 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/notifications/WooNotificationType.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/notifications/WooNotificationType.kt @@ -1,18 +1,60 @@ package com.woocommerce.android.notifications +import android.os.Parcelable +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize import org.wordpress.android.fluxc.model.notification.NotificationModel -enum class WooNotificationType { - NEW_ORDER, - PRODUCT_REVIEW, - LOCAL_REMINDER, - BLAZE +sealed interface WooNotificationType : Parcelable { + val trackingValue: String + + @Parcelize + data object NewOrder : WooNotificationType { + @IgnoredOnParcel override val trackingValue: String = "NEW_ORDER" + } + + @Parcelize + data object ProductReview : WooNotificationType { + @IgnoredOnParcel override val trackingValue: String = "PRODUCT_REVIEW" + } + + @Parcelize + data object LocalReminder : WooNotificationType { + @IgnoredOnParcel override val trackingValue: String = "LOCAL_REMINDER" + } + + @Parcelize + sealed interface BlazeStatusUpdate : WooNotificationType, Parcelable { + @Parcelize + data object BlazeApprovedNote : BlazeStatusUpdate { + @IgnoredOnParcel override val trackingValue: String = "blaze_approved_note" + } + + @Parcelize + data object BlazeRejectedNote : BlazeStatusUpdate { + @IgnoredOnParcel override val trackingValue: String = "blaze_rejected_note" + } + + @Parcelize + data object BlazeCancelledNote : BlazeStatusUpdate { + @IgnoredOnParcel override val trackingValue: String = "blaze_cancelled_note" + } + + @Parcelize + data object BlazePerformedNote : BlazeStatusUpdate { + @IgnoredOnParcel override val trackingValue: String = "blaze_performed_note" + } + } } fun NotificationModel.getWooType(): WooNotificationType { return when (this.type) { - NotificationModel.Kind.STORE_ORDER -> WooNotificationType.NEW_ORDER - NotificationModel.Kind.COMMENT -> WooNotificationType.PRODUCT_REVIEW - else -> WooNotificationType.LOCAL_REMINDER + NotificationModel.Kind.STORE_ORDER -> WooNotificationType.NewOrder + NotificationModel.Kind.COMMENT -> WooNotificationType.ProductReview + NotificationModel.Kind.BLAZE_APPROVED_NOTE -> WooNotificationType.BlazeStatusUpdate.BlazeApprovedNote + NotificationModel.Kind.BLAZE_REJECTED_NOTE -> WooNotificationType.BlazeStatusUpdate.BlazeRejectedNote + NotificationModel.Kind.BLAZE_CANCELLED_NOTE -> WooNotificationType.BlazeStatusUpdate.BlazeCancelledNote + NotificationModel.Kind.BLAZE_PERFORMED_NOTE -> WooNotificationType.BlazeStatusUpdate.BlazePerformedNote + else -> WooNotificationType.LocalReminder } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/notifications/local/LocalNotification.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/notifications/local/LocalNotification.kt index 2b88ed02bcd..f39613bb68e 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/notifications/local/LocalNotification.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/notifications/local/LocalNotification.kt @@ -16,9 +16,6 @@ sealed class LocalNotification( open val data: String? = null val id = type.hashCode() - val tag - get() = "$type:$siteId" - fun getTitleString(resourceProvider: ResourceProvider) = resourceProvider.getString(title) fun getDescriptionString(resourceProvider: ResourceProvider) = resourceProvider.getString(description) @@ -34,4 +31,13 @@ sealed class LocalNotification( delay = delay, delayUnit = TimeUnit.MILLISECONDS ) + + data class BlazeAbandonedCampaignReminderNotification(override val siteId: Long) : LocalNotification( + siteId = siteId, + title = R.string.local_notification_blaze_abandoned_campaign_reminder_title, + description = R.string.local_notification_blaze_abandoned_campaign_reminder_description, + type = LocalNotificationType.BLAZE_ABANDONED_CAMPAIGN_REMINDER, + delay = 1, + delayUnit = TimeUnit.DAYS + ) } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/notifications/local/LocalNotificationScheduler.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/notifications/local/LocalNotificationScheduler.kt index ab208f2f25d..895275c3625 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/notifications/local/LocalNotificationScheduler.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/notifications/local/LocalNotificationScheduler.kt @@ -8,6 +8,7 @@ import androidx.work.WorkManager import androidx.work.workDataOf import com.woocommerce.android.analytics.AnalyticsEvent import com.woocommerce.android.analytics.AnalyticsTracker +import com.woocommerce.android.util.WooLog import com.woocommerce.android.viewmodel.ResourceProvider import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject @@ -29,11 +30,11 @@ class LocalNotificationScheduler @Inject constructor( private val workManager = WorkManager.getInstance(appContext) fun scheduleNotification(notification: LocalNotification) { - cancelScheduledNotification(notification.tag) + cancelScheduledNotification(notification.type) workManager .beginUniqueWork( - LOCAL_NOTIFICATION_WORK_NAME + notification.tag, + LOCAL_NOTIFICATION_WORK_NAME + notification.type.value, REPLACE, buildPreconditionCheckWorkRequest(notification) ) @@ -47,6 +48,11 @@ class LocalNotificationScheduler @Inject constructor( AnalyticsTracker.KEY_BLOG_ID to notification.siteId, ) ) + WooLog.d( + tag = WooLog.T.NOTIFICATIONS, + message = "Local notification scheduled: " + + "type=${notification.type}, delay=${notification.delay}${notification.delayUnit}" + ) } private fun buildPreconditionCheckWorkRequest(notification: LocalNotification): OneTimeWorkRequest { @@ -57,7 +63,7 @@ class LocalNotificationScheduler @Inject constructor( ) return OneTimeWorkRequestBuilder() .setInputData(conditionData) - .addTag(notification.tag) + .addTag(notification.type.value) .setInitialDelay(notification.delay, notification.delayUnit) .build() } @@ -72,12 +78,16 @@ class LocalNotificationScheduler @Inject constructor( LOCAL_NOTIFICATION_SITE_ID to notification.siteId ) return OneTimeWorkRequestBuilder() - .addTag(notification.tag) + .addTag(notification.type.value) .setInputData(notificationData) .build() } - private fun cancelScheduledNotification(tag: String) { - workManager.cancelAllWorkByTag(tag) + fun cancelScheduledNotification(type: LocalNotificationType) { + workManager.cancelAllWorkByTag(type.value) + WooLog.d( + tag = WooLog.T.NOTIFICATIONS, + message = "Local notification canceled: $type" + ) } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/notifications/local/LocalNotificationType.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/notifications/local/LocalNotificationType.kt index 55999bca86a..6d43f782c12 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/notifications/local/LocalNotificationType.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/notifications/local/LocalNotificationType.kt @@ -1,7 +1,8 @@ package com.woocommerce.android.notifications.local enum class LocalNotificationType(val value: String) { - BLAZE_NO_CAMPAIGN_REMINDER("blaze_no_campaign_reminder"); + BLAZE_NO_CAMPAIGN_REMINDER("blaze_no_campaign_reminder"), + BLAZE_ABANDONED_CAMPAIGN_REMINDER("blaze_abandoned_campaign_reminder"); override fun toString() = value companion object { diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/notifications/local/LocalNotificationWorker.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/notifications/local/LocalNotificationWorker.kt index 0ea5353bc1b..93804afe187 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/notifications/local/LocalNotificationWorker.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/notifications/local/LocalNotificationWorker.kt @@ -11,7 +11,7 @@ import com.woocommerce.android.analytics.AnalyticsTracker import com.woocommerce.android.model.Notification import com.woocommerce.android.notifications.NotificationChannelType.OTHER import com.woocommerce.android.notifications.WooNotificationBuilder -import com.woocommerce.android.notifications.WooNotificationType.LOCAL_REMINDER +import com.woocommerce.android.notifications.WooNotificationType.LocalReminder import com.woocommerce.android.notifications.local.LocalNotificationScheduler.Companion.LOCAL_NOTIFICATION_DATA import com.woocommerce.android.notifications.local.LocalNotificationScheduler.Companion.LOCAL_NOTIFICATION_DESC import com.woocommerce.android.notifications.local.LocalNotificationScheduler.Companion.LOCAL_NOTIFICATION_ID @@ -49,7 +49,7 @@ class LocalNotificationWorker @AssistedInject constructor( notificationTappedIntent = getIntent(notification), ) - setNotificationShown(type, siteId) + setNotificationShown(type) AnalyticsTracker.track( LOCAL_NOTIFICATION_DISPLAYED, @@ -71,10 +71,15 @@ class LocalNotificationWorker @AssistedInject constructor( } } - private fun setNotificationShown(type: String, siteId: Long) { + private fun setNotificationShown(type: String) { when (LocalNotificationType.fromString(type)) { LocalNotificationType.BLAZE_NO_CAMPAIGN_REMINDER -> { - appsPrefsWrapper.setBlazeNoCampaignReminderShown(siteId) + appsPrefsWrapper.isBlazeNoCampaignReminderShown = true + appsPrefsWrapper.removeBlazeFirstTimeWithoutCampaign() + } + + LocalNotificationType.BLAZE_ABANDONED_CAMPAIGN_REMINDER -> { + appsPrefsWrapper.isBlazeAbandonedCampaignReminderShown = true } else -> {} @@ -98,7 +103,7 @@ class LocalNotificationWorker @AssistedInject constructor( icon = null, noteTitle = title, noteMessage = description, - noteType = LOCAL_REMINDER, + noteType = LocalReminder, channelType = OTHER, data = data ) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/notifications/local/PreconditionCheckWorker.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/notifications/local/PreconditionCheckWorker.kt index 8803f5d1596..dee3c357070 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/notifications/local/PreconditionCheckWorker.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/notifications/local/PreconditionCheckWorker.kt @@ -35,7 +35,8 @@ class PreconditionCheckWorker @AssistedInject constructor( val type = LocalNotificationType.fromString(inputData.getString(LOCAL_NOTIFICATION_TYPE)) val siteId = inputData.getLong(LOCAL_NOTIFICATION_SITE_ID, 0L) return when (type) { - LocalNotificationType.BLAZE_NO_CAMPAIGN_REMINDER -> proceedIfValidSiteAndBlazeAvailable(siteId) + LocalNotificationType.BLAZE_NO_CAMPAIGN_REMINDER, + LocalNotificationType.BLAZE_ABANDONED_CAMPAIGN_REMINDER -> proceedIfValidSiteAndBlazeAvailable(siteId) null -> cancelWork("Notification type is null. Cancelling work.") } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/notifications/push/NotificationAnalyticsTracker.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/notifications/push/NotificationAnalyticsTracker.kt index b25a96054f8..5a92827de51 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/notifications/push/NotificationAnalyticsTracker.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/notifications/push/NotificationAnalyticsTracker.kt @@ -18,7 +18,7 @@ class NotificationAnalyticsTracker @Inject constructor( val isFromSelectedSite = selectedSite.getIfExists()?.siteId == notification.remoteSiteId val properties = mutableMapOf() properties["notification_note_id"] = notification.remoteNoteId - properties["notification_type"] = notification.noteType.name + properties["notification_type"] = notification.noteType.trackingValue properties["push_notification_token"] = appPrefsWrapper.getFCMToken() properties["is_from_selected_site"] = isFromSelectedSite == true analyticsTrackerWrapper.track(stat, properties) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/notifications/push/NotificationMessageHandler.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/notifications/push/NotificationMessageHandler.kt index 05b0cb07713..4bb5d922b9a 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/notifications/push/NotificationMessageHandler.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/notifications/push/NotificationMessageHandler.kt @@ -13,7 +13,7 @@ import com.woocommerce.android.model.isOrderNotification import com.woocommerce.android.model.toAppModel import com.woocommerce.android.notifications.NotificationChannelType import com.woocommerce.android.notifications.WooNotificationBuilder -import com.woocommerce.android.notifications.WooNotificationType.NEW_ORDER +import com.woocommerce.android.notifications.WooNotificationType.NewOrder import com.woocommerce.android.tools.SelectedSite import com.woocommerce.android.util.NotificationsParser import com.woocommerce.android.util.WooLog.T.NOTIFS @@ -52,10 +52,12 @@ class NotificationMessageHandler @Inject constructor( private val ACTIVE_NOTIFICATIONS_MAP = mutableMapOf() } + @Synchronized fun onPushNotificationDismissed(notificationId: Int) { removeNotificationByNotificationIdFromSystemsBar(notificationId) } + @Synchronized fun onLocalNotificationDismissed(notificationId: Int, notificationType: String) { removeNotificationByNotificationIdFromSystemsBar(notificationId) AnalyticsTracker.track( @@ -128,7 +130,7 @@ class NotificationMessageHandler @Inject constructor( } private fun handleWooNotification(notification: Notification) { - val randomNumber = if (notification.noteType == NEW_ORDER) Random.nextInt() else 0 + val randomNumber = if (notification.noteType == NewOrder) Random.nextInt() else 0 val localPushId = getLocalPushIdForNoteId(notification.remoteNoteId, randomNumber) ACTIVE_NOTIFICATIONS_MAP[getLocalPushId(localPushId, randomNumber)] = notification if (notificationBuilder.isNotificationsEnabled()) { @@ -221,6 +223,7 @@ class NotificationMessageHandler @Inject constructor( notificationBuilder.cancelAllNotifications() } + @Synchronized fun removeNotificationByRemoteIdFromSystemsBar(remoteNoteId: Long) { val keptNotifs = HashMap() ACTIVE_NOTIFICATIONS_MAP.asSequence() @@ -237,6 +240,7 @@ class NotificationMessageHandler @Inject constructor( updateNotificationsState() } + @Synchronized fun removeNotificationByNotificationIdFromSystemsBar(localPushId: Int) { val keptNotifs = HashMap() ACTIVE_NOTIFICATIONS_MAP.asSequence() @@ -253,6 +257,7 @@ class NotificationMessageHandler @Inject constructor( updateNotificationsState() } + @Synchronized fun removeNotificationsOfTypeFromSystemsBar(type: NotificationChannelType, remoteSiteId: Long) { val keptNotifs = HashMap() // Using a copy of the map to avoid concurrency problems diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/analytics/hub/sync/AnalyticsUpdateDataStore.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/analytics/hub/sync/AnalyticsUpdateDataStore.kt index dd835112d22..b9ed4bac4d6 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/analytics/hub/sync/AnalyticsUpdateDataStore.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/analytics/hub/sync/AnalyticsUpdateDataStore.kt @@ -122,7 +122,7 @@ class AnalyticsUpdateDataStore @Inject constructor( true } } - .map { lastUpdateValues -> lastUpdateValues.min() } + .map { lastUpdateValues -> lastUpdateValues.minOrNull() } } private fun observeLastUpdate(timestampKey: String): Flow { diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/appwidgets/IsDeviceBatterySaverActive.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/appwidgets/IsDeviceBatterySaverActive.kt new file mode 100644 index 00000000000..9ea3b148de4 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/appwidgets/IsDeviceBatterySaverActive.kt @@ -0,0 +1,16 @@ +package com.woocommerce.android.ui.appwidgets + +import android.content.Context +import android.os.PowerManager +import javax.inject.Inject + +class IsDeviceBatterySaverActive @Inject constructor( + private val appContext: Context +) { + operator fun invoke(): Boolean { + return appContext.getSystemService(Context.POWER_SERVICE) + .run { this as? PowerManager } + ?.isPowerSaveMode + ?: false + } +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/appwidgets/stats/GetWidgetStats.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/appwidgets/stats/GetWidgetStats.kt index 34a843dc76b..514d57d8e96 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/appwidgets/stats/GetWidgetStats.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/appwidgets/stats/GetWidgetStats.kt @@ -7,6 +7,12 @@ import com.woocommerce.android.tools.connectionType import com.woocommerce.android.ui.analytics.ranges.StatsTimeRangeSelection import com.woocommerce.android.ui.analytics.ranges.revenueStatsGranularity import com.woocommerce.android.ui.analytics.ranges.visitorStatsGranularity +import com.woocommerce.android.ui.appwidgets.IsDeviceBatterySaverActive +import com.woocommerce.android.ui.appwidgets.stats.GetWidgetStats.WidgetStatsResult.WidgetStatsAPINotSupportedFailure +import com.woocommerce.android.ui.appwidgets.stats.GetWidgetStats.WidgetStatsResult.WidgetStatsAuthFailure +import com.woocommerce.android.ui.appwidgets.stats.GetWidgetStats.WidgetStatsResult.WidgetStatsBatterySaverActive +import com.woocommerce.android.ui.appwidgets.stats.GetWidgetStats.WidgetStatsResult.WidgetStatsFailure +import com.woocommerce.android.ui.appwidgets.stats.GetWidgetStats.WidgetStatsResult.WidgetStatsNetworkFailure import com.woocommerce.android.ui.dashboard.data.StatsRepository import com.woocommerce.android.ui.login.AccountRepository import com.woocommerce.android.util.CoroutineDispatchers @@ -20,6 +26,7 @@ class GetWidgetStats @Inject constructor( private val appPrefsWrapper: AppPrefsWrapper, private val statsRepository: StatsRepository, private val coroutineDispatchers: CoroutineDispatchers, + private val isDeviceBatterySaverActive: IsDeviceBatterySaverActive, private val networkStatus: NetworkStatus ) { suspend operator fun invoke( @@ -28,14 +35,15 @@ class GetWidgetStats @Inject constructor( ): WidgetStatsResult { return withContext(coroutineDispatchers.io) { when { + isDeviceBatterySaverActive() -> WidgetStatsBatterySaverActive // If user is not logged in, exit the function with WidgetStatsAuthFailure - accountRepository.isUserLoggedIn().not() -> WidgetStatsResult.WidgetStatsAuthFailure + accountRepository.isUserLoggedIn().not() -> WidgetStatsAuthFailure // If V4 stats is not supported, exit the function with WidgetStatsAPINotSupportedFailure - appPrefsWrapper.isV4StatsSupported().not() -> WidgetStatsResult.WidgetStatsAPINotSupportedFailure + appPrefsWrapper.isV4StatsSupported().not() -> WidgetStatsAPINotSupportedFailure // If network is not available, exit the function with WidgetStatsNetworkFailure - networkStatus.isConnected().not() -> WidgetStatsResult.WidgetStatsNetworkFailure + networkStatus.isConnected().not() -> WidgetStatsNetworkFailure // If siteModel is null, exit the function with WidgetStatsFailure - siteModel == null -> WidgetStatsResult.WidgetStatsFailure("No site selected") + siteModel == null -> WidgetStatsFailure("No site selected") else -> { val areVisitorStatsSupported = siteModel.connectionType == SiteConnectionType.Jetpack @@ -53,7 +61,7 @@ class GetWidgetStats @Inject constructor( WidgetStatsResult.WidgetStats(stats) }, onFailure = { error -> - WidgetStatsResult.WidgetStatsFailure(error.message) + WidgetStatsFailure(error.message) } ) } @@ -62,9 +70,10 @@ class GetWidgetStats @Inject constructor( } sealed class WidgetStatsResult { - object WidgetStatsAuthFailure : WidgetStatsResult() - object WidgetStatsNetworkFailure : WidgetStatsResult() - object WidgetStatsAPINotSupportedFailure : WidgetStatsResult() + data object WidgetStatsBatterySaverActive : WidgetStatsResult() + data object WidgetStatsAuthFailure : WidgetStatsResult() + data object WidgetStatsNetworkFailure : WidgetStatsResult() + data object WidgetStatsAPINotSupportedFailure : WidgetStatsResult() data class WidgetStatsFailure(val errorMessage: String?) : WidgetStatsResult() data class WidgetStats( private val revenueModel: WCRevenueStatsModel?, diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/appwidgets/stats/today/UpdateTodayStatsWorker.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/appwidgets/stats/today/UpdateTodayStatsWorker.kt index 7cbb4155aa8..b9544d5ca6e 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/appwidgets/stats/today/UpdateTodayStatsWorker.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/appwidgets/stats/today/UpdateTodayStatsWorker.kt @@ -11,6 +11,13 @@ import com.woocommerce.android.tools.SelectedSite import com.woocommerce.android.ui.analytics.ranges.StatsTimeRangeSelection import com.woocommerce.android.ui.analytics.ranges.StatsTimeRangeSelection.SelectionType import com.woocommerce.android.ui.appwidgets.stats.GetWidgetStats +import com.woocommerce.android.ui.appwidgets.stats.GetWidgetStats.WidgetStatsResult +import com.woocommerce.android.ui.appwidgets.stats.GetWidgetStats.WidgetStatsResult.WidgetStats +import com.woocommerce.android.ui.appwidgets.stats.GetWidgetStats.WidgetStatsResult.WidgetStatsAPINotSupportedFailure +import com.woocommerce.android.ui.appwidgets.stats.GetWidgetStats.WidgetStatsResult.WidgetStatsAuthFailure +import com.woocommerce.android.ui.appwidgets.stats.GetWidgetStats.WidgetStatsResult.WidgetStatsBatterySaverActive +import com.woocommerce.android.ui.appwidgets.stats.GetWidgetStats.WidgetStatsResult.WidgetStatsFailure +import com.woocommerce.android.ui.appwidgets.stats.GetWidgetStats.WidgetStatsResult.WidgetStatsNetworkFailure import com.woocommerce.android.util.DateUtils import com.woocommerce.android.util.locale.LocaleProvider import dagger.assisted.Assisted @@ -68,10 +75,19 @@ class UpdateTodayStatsWorker @AssistedInject constructor( widgetId: Int, remoteViews: RemoteViews, site: SiteModel?, - todayStatsResult: GetWidgetStats.WidgetStatsResult + todayStatsResult: WidgetStatsResult ): Result { return when (todayStatsResult) { - GetWidgetStats.WidgetStatsResult.WidgetStatsNetworkFailure -> { + WidgetStatsBatterySaverActive -> { + todayStatsWidgetUIHelper.displayError( + remoteViews = remoteViews, + errorMessageRes = R.string.stats_widget_battery_saver_error, + withRetryButton = false, + widgetId = widgetId + ) + Result.failure() + } + WidgetStatsNetworkFailure -> { todayStatsWidgetUIHelper.displayError( remoteViews = remoteViews, errorMessageRes = R.string.stats_widget_offline_error, @@ -80,7 +96,7 @@ class UpdateTodayStatsWorker @AssistedInject constructor( ) Result.retry() } - GetWidgetStats.WidgetStatsResult.WidgetStatsAPINotSupportedFailure -> { + WidgetStatsAPINotSupportedFailure -> { todayStatsWidgetUIHelper.displayError( remoteViews = remoteViews, errorMessageRes = R.string.stats_widget_availability_message, @@ -89,7 +105,7 @@ class UpdateTodayStatsWorker @AssistedInject constructor( ) Result.failure() } - GetWidgetStats.WidgetStatsResult.WidgetStatsAuthFailure -> { + WidgetStatsAuthFailure -> { todayStatsWidgetUIHelper.displayError( remoteViews = remoteViews, errorMessageRes = R.string.stats_widget_log_in_message, @@ -98,7 +114,7 @@ class UpdateTodayStatsWorker @AssistedInject constructor( ) Result.failure() } - is GetWidgetStats.WidgetStatsResult.WidgetStatsFailure -> { + is WidgetStatsFailure -> { todayStatsWidgetUIHelper.displayError( remoteViews = remoteViews, errorMessageRes = R.string.stats_widget_error_no_data, @@ -107,23 +123,32 @@ class UpdateTodayStatsWorker @AssistedInject constructor( ) Result.retry() } - is GetWidgetStats.WidgetStatsResult.WidgetStats -> { - if (site != null) { - todayStatsWidgetUIHelper.displayInformation( - stats = todayStatsResult, - remoteViews = remoteViews - ) - Result.success() - } else { - todayStatsWidgetUIHelper.displayError( - remoteViews = remoteViews, - errorMessageRes = R.string.stats_widget_error_no_data, - withRetryButton = true, - widgetId = widgetId - ) - Result.retry() - } + is WidgetStats -> { + handleStatsAvailable(todayStatsResult, site, remoteViews, widgetId) } } } + + private fun handleStatsAvailable( + todayStatsResult: WidgetStats, + site: SiteModel?, + remoteViews: RemoteViews, + widgetId: Int + ): Result { + if (site != null) { + todayStatsWidgetUIHelper.displayInformation( + stats = todayStatsResult, + remoteViews = remoteViews + ) + return Result.success() + } else { + todayStatsWidgetUIHelper.displayError( + remoteViews = remoteViews, + errorMessageRes = R.string.stats_widget_error_no_data, + withRetryButton = true, + widgetId = widgetId + ) + return Result.retry() + } + } } 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 a5327c57c75..2d94bf3da21 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 @@ -9,6 +9,7 @@ import com.woocommerce.android.media.MediaFilesRepository import com.woocommerce.android.model.CreditCardType import com.woocommerce.android.tools.SelectedSite import com.woocommerce.android.ui.products.details.ProductDetailRepository +import com.woocommerce.android.util.FeatureFlag import com.woocommerce.android.util.WooLog import com.woocommerce.android.util.joinToUrl import kotlinx.coroutines.flow.catch @@ -39,13 +40,39 @@ class BlazeRepository @Inject constructor( ) { companion object { private const val BLAZE_CAMPAIGN_CREATION_ORIGIN = "wc-android" + const val CAMPAIGN_BUDGET_MODE_TOTAL = "total" // "total" for campaigns with defined end date + const val CAMPAIGN_BUDGET_MODE_DAILY = "daily" // "daily" for endless/evergreen campaigns 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_MAX_DURATION = 28 // Days - const val DEFAULT_CAMPAIGN_BUDGET_MODE = "total" // "total" or "daily" for campaigns that run without end date const val BLAZE_IMAGE_MINIMUM_SIZE_IN_PIXELS = 400 // Must be at least 400 x 400 pixels + const val WEEKLY_DURATION = 7 // Used to calculate weekly budget in endless campaigns + } + + fun observeObjectives() = blazeCampaignsStore.observeBlazeCampaignObjectives().map { + it.map { objective -> + Objective( + objective.id, + objective.title, + objective.description, + objective.suitableForDescription + ) + } + } + + suspend fun fetchObjectives(): Result { + val result = blazeCampaignsStore.fetchBlazeCampaignObjectives(selectedSite.get()) + + return when { + result.isError -> { + WooLog.w(WooLog.T.BLAZE, "Failed to fetch objectives: ${result.error}") + Result.failure(OnChangedException(result.error)) + } + + else -> Result.success(Unit) + } } fun observeLanguages() = blazeCampaignsStore.observeBlazeTargetingLanguages() @@ -142,10 +169,11 @@ class BlazeRepository @Inject constructor( currencyCode = BLAZE_DEFAULT_CURRENCY_CODE, durationInDays = DEFAULT_CAMPAIGN_DURATION, startDate = Date().apply { time += 1.days.inWholeMilliseconds }, // By default start tomorrow + isEndlessCampaign = FeatureFlag.ENDLESS_CAMPAIGNS_SUPPORT.isEnabled() ) val product = productDetailRepository.getProduct(productId) - ?: productDetailRepository.fetchProductOrLoadFromCache(productId)!! + ?: productDetailRepository.fetchAndGetProduct(productId)!! return CampaignDetails( productId = productId, @@ -269,8 +297,14 @@ class BlazeRepository @Inject constructor( startDate = campaignDetails.budget.startDate, endDate = campaignDetails.budget.endDate, budget = BlazeCampaignCreationRequestBudget( - mode = DEFAULT_CAMPAIGN_BUDGET_MODE, - amount = campaignDetails.budget.totalBudget.toDouble(), + mode = when { + campaignDetails.budget.isEndlessCampaign -> CAMPAIGN_BUDGET_MODE_DAILY + else -> CAMPAIGN_BUDGET_MODE_TOTAL + }, + amount = when { + campaignDetails.budget.isEndlessCampaign -> campaignDetails.budget.totalBudget / WEEKLY_DURATION + else -> campaignDetails.budget.totalBudget + }.toDouble(), currency = BLAZE_DEFAULT_CURRENCY_CODE // To be replaced when more currencies are supported ), targetUrl = campaignDetails.destinationParameters.targetUrl, @@ -283,7 +317,8 @@ class BlazeRepository @Inject constructor( devices = it.devices.map { device -> device.id }, topics = it.interests.map { interest -> interest.id } ) - } + }, + isEndlessCampaign = campaignDetails.budget.isEndlessCampaign ) ) @@ -367,6 +402,14 @@ class BlazeRepository @Inject constructor( data class RemoteImage(val mediaId: Long, override val uri: String) : BlazeCampaignImage } + @Parcelize + data class Objective( + val id: String, + val title: String, + val description: String, + val suitableForDescription: String + ) : Parcelable + @Parcelize data class TargetingParameters( val locations: List = emptyList(), @@ -397,6 +440,7 @@ class BlazeRepository @Inject constructor( val currencyCode: String, val durationInDays: Int, val startDate: Date, + val isEndlessCampaign: Boolean ) : Parcelable { val endDate: Date get() = Date(startDate.time + durationInDays.days.inWholeMilliseconds) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/BlazeUiMapper.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/BlazeUiMapper.kt new file mode 100644 index 00000000000..ff449ff3320 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/BlazeUiMapper.kt @@ -0,0 +1,42 @@ +package com.woocommerce.android.ui.blaze + +import com.woocommerce.android.R +import com.woocommerce.android.extensions.NumberExtensionsWrapper +import com.woocommerce.android.util.CurrencyFormatter +import org.wordpress.android.fluxc.model.blaze.BlazeCampaignModel + +fun BlazeCampaignModel.toUiState( + currencyFormatter: CurrencyFormatter, + numberExtensionsWrapper: NumberExtensionsWrapper +) = BlazeCampaignUi( + product = BlazeProductUi( + name = title, + imgUrl = imageUrl.orEmpty(), + ), + status = CampaignStatusUi.fromString(uiStatus), + isEndlessCampaign = isEndlessCampaign, + impressions = numberExtensionsWrapper.compactNumberCompat(impressions), + clicks = numberExtensionsWrapper.compactNumberCompat(clicks), + formattedBudget = getBudgetValue(this, currencyFormatter), + budgetLabel = getBudgetTitle(this) +) + +private fun getBudgetTitle(campaign: BlazeCampaignModel) = + when { + campaign.isEndlessCampaign -> R.string.blaze_campaign_status_budget_weekly + CampaignStatusUi.isActive(campaign.uiStatus) -> R.string.blaze_campaign_status_budget_remaining + else -> R.string.blaze_campaign_status_budget_total + } + +private fun getBudgetValue(campaign: BlazeCampaignModel, currencyFormatter: CurrencyFormatter): String = + currencyFormatter.formatCurrencyRounded( + when { + campaign.isEndlessCampaign -> getWeeklyBudget(campaign) + CampaignStatusUi.isActive(campaign.uiStatus) -> (campaign.totalBudget - campaign.spentBudget) + else -> campaign.totalBudget + }, + BlazeRepository.BLAZE_DEFAULT_CURRENCY_CODE + ) + +private fun getWeeklyBudget(campaign: BlazeCampaignModel): Double = + (campaign.totalBudget / campaign.durationInDays) * BlazeRepository.WEEKLY_DURATION diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/BlazeUiModels.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/BlazeUiModels.kt index 5695e2e78dd..45dc6e6ee6a 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/BlazeUiModels.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/BlazeUiModels.kt @@ -12,12 +12,11 @@ data class BlazeProductUi( data class BlazeCampaignUi( val product: BlazeProductUi, val status: CampaignStatusUi?, - val stats: List, -) - -data class BlazeCampaignStat( - @StringRes val name: Int, - val value: String + val isEndlessCampaign: Boolean, + val impressions: String, + val clicks: String, + val formattedBudget: String, + @StringRes val budgetLabel: Int ) enum class CampaignStatusUi( @@ -74,5 +73,10 @@ enum class CampaignStatusUi( else -> null } } + + fun isActive(status: String): Boolean { + val campaignStatus = fromString(status) + return campaignStatus == Active || campaignStatus == Scheduled || campaignStatus == InModeration + } } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/IsBlazeEnabled.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/IsBlazeEnabled.kt index 6ab18cb14a2..fe0f8a5c864 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/IsBlazeEnabled.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/IsBlazeEnabled.kt @@ -1,17 +1,28 @@ package com.woocommerce.android.ui.blaze import com.woocommerce.android.tools.SelectedSite -import com.woocommerce.android.tools.SiteConnectionType -import com.woocommerce.android.util.IsRemoteFeatureFlagEnabled -import com.woocommerce.android.util.RemoteFeatureFlag.WOO_BLAZE +import com.woocommerce.android.tools.SiteConnectionType.Jetpack import javax.inject.Inject class IsBlazeEnabled @Inject constructor( - private val selectedSite: SelectedSite, - private val isRemoteFeatureFlagEnabled: IsRemoteFeatureFlagEnabled, + private val selectedSite: SelectedSite ) { - suspend operator fun invoke(): Boolean = selectedSite.getIfExists()?.isAdmin ?: false && - selectedSite.connectionType == SiteConnectionType.Jetpack && - selectedSite.getIfExists()?.canBlaze ?: false && - isRemoteFeatureFlagEnabled(WOO_BLAZE) + companion object { + private const val BLAZE_FOR_WOOCOMMERCE_PLUGIN_SLUG = "blaze-ads" + } + + operator fun invoke(): Boolean = selectedSite.getIfExists()?.isAdmin ?: false && + hasAValidJetpackConnectionForBlaze() && + selectedSite.getIfExists()?.canBlaze ?: false + + /** + * In order for Blaze to work, the site requires the Jetpack Sync module to be enabled. This means not all + * Jetpack connection will work. For now, Blaze will only be enabled for sites with Jetpack plugin installed and + * active, or for sites with Blaze for WooCommerce plugin installed and connected. + */ + private fun hasAValidJetpackConnectionForBlaze() = + selectedSite.connectionType == Jetpack || isBlazeForWooCommercePluginActive() + + private fun isBlazeForWooCommercePluginActive(): Boolean = + selectedSite.get().activeJetpackConnectionPlugins?.contains(BLAZE_FOR_WOOCOMMERCE_PLUGIN_SLUG) == true } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/campaigs/BlazeCampaignItem.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/campaigs/BlazeCampaignItem.kt index f984f461c68..7543c13c145 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/campaigs/BlazeCampaignItem.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/campaigs/BlazeCampaignItem.kt @@ -1,11 +1,14 @@ package com.woocommerce.android.ui.blaze.campaigs +import android.content.res.Configuration import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.MaterialTheme import androidx.compose.material.Text @@ -13,10 +16,12 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.woocommerce.android.R import com.woocommerce.android.ui.blaze.BlazeCampaignUi @@ -65,13 +70,28 @@ fun BlazeCampaignItem( style = MaterialTheme.typography.subtitle1, fontWeight = FontWeight.Bold, ) - Row(modifier = Modifier.padding(top = dimensionResource(id = R.dimen.major_100))) { - campaign.stats.forEach { - CampaignStat( - statName = stringResource(id = it.name), - statValue = it.value + Row( + modifier = Modifier.padding(top = dimensionResource(id = R.dimen.major_100)) + ) { + CampaignStat( + statName = stringResource(R.string.blaze_campaign_status_ctr_label), + statValue = stringResource( + id = R.string.blaze_campaign_status_ctr_value_shortened, + campaign.impressions, + campaign.clicks, ) + ) + val orientation = LocalConfiguration.current.orientation + if (orientation == Configuration.ORIENTATION_PORTRAIT) { + Spacer(modifier = Modifier.weight(1f)) + } else { + Spacer(modifier = Modifier.width(16.dp)) } + CampaignStat( + modifier = Modifier.padding(start = 16.dp), + statName = stringResource(campaign.budgetLabel), + statValue = campaign.formattedBudget + ) } } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/campaigs/BlazeCampaignListScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/campaigs/BlazeCampaignListScreen.kt index bd426606c19..62ebf6c0156 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/campaigs/BlazeCampaignListScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/campaigs/BlazeCampaignListScreen.kt @@ -43,7 +43,6 @@ 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.blaze.BlazeCampaignStat import com.woocommerce.android.ui.blaze.BlazeCampaignUi import com.woocommerce.android.ui.blaze.BlazeProductUi import com.woocommerce.android.ui.blaze.CampaignStatusUi.Active @@ -232,20 +231,11 @@ fun BlazeCampaignListScreenPreview() { imgUrl = "https://picsum.photos/200/300", ), status = Active, - stats = listOf( - BlazeCampaignStat( - name = R.string.blaze_campaign_status_impressions, - value = 100.toString() - ), - BlazeCampaignStat( - name = R.string.blaze_campaign_status_clicks, - value = 10.toString() - ), - BlazeCampaignStat( - name = R.string.blaze_campaign_status_budget, - value = 1000.toString() - ), - ), + isEndlessCampaign = false, + impressions = "6k", + clicks = "10", + formattedBudget = "$100", + budgetLabel = R.string.blaze_campaign_status_budget_total ), onCampaignClicked = {} ) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/campaigs/BlazeCampaignListViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/campaigs/BlazeCampaignListViewModel.kt index fefb1569e3a..c0f99231dfa 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/campaigs/BlazeCampaignListViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/campaigs/BlazeCampaignListViewModel.kt @@ -7,13 +7,12 @@ import com.woocommerce.android.R import com.woocommerce.android.analytics.AnalyticsEvent.BLAZE_CAMPAIGN_DETAIL_SELECTED import com.woocommerce.android.analytics.AnalyticsTracker import com.woocommerce.android.analytics.AnalyticsTrackerWrapper +import com.woocommerce.android.extensions.NumberExtensionsWrapper import com.woocommerce.android.tools.SelectedSite -import com.woocommerce.android.ui.blaze.BlazeCampaignStat import com.woocommerce.android.ui.blaze.BlazeCampaignUi -import com.woocommerce.android.ui.blaze.BlazeProductUi import com.woocommerce.android.ui.blaze.BlazeUrlsHelper import com.woocommerce.android.ui.blaze.BlazeUrlsHelper.BlazeFlowSource -import com.woocommerce.android.ui.blaze.CampaignStatusUi +import com.woocommerce.android.ui.blaze.toUiState import com.woocommerce.android.util.CurrencyFormatter import com.woocommerce.android.viewmodel.MultiLiveEvent.Event import com.woocommerce.android.viewmodel.ScopedViewModel @@ -26,7 +25,6 @@ import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.withIndex import kotlinx.coroutines.launch -import org.wordpress.android.fluxc.persistence.blaze.BlazeCampaignsDao.BlazeCampaignEntity import org.wordpress.android.fluxc.store.blaze.BlazeCampaignsStore import javax.inject.Inject @@ -39,7 +37,8 @@ class BlazeCampaignListViewModel @Inject constructor( private val blazeUrlsHelper: BlazeUrlsHelper, private val appPrefsWrapper: AppPrefsWrapper, private val analyticsTrackerWrapper: AnalyticsTrackerWrapper, - private val currencyFormatter: CurrencyFormatter + private val currencyFormatter: CurrencyFormatter, + private val numberExtensionsWrapper: NumberExtensionsWrapper ) : ScopedViewModel(savedStateHandle) { companion object { private const val LOADING_TRANSITION_DELAY = 200L @@ -64,7 +63,12 @@ class BlazeCampaignListViewModel @Inject constructor( isCampaignCelebrationShown ) { campaigns, loadingMore, isBlazeCelebrationScreenShown -> BlazeCampaignListState( - campaigns = campaigns.map { mapToUiState(it) }, + campaigns = campaigns.map { + ClickableCampaign( + campaignUi = it.toUiState(currencyFormatter, numberExtensionsWrapper), + onCampaignClicked = { onCampaignClicked(it.campaignId) } + ) + }, onAddNewCampaignClicked = { onAddNewCampaignClicked() }, isLoading = loadingMore, isCampaignCelebrationShown = isBlazeCelebrationScreenShown @@ -75,6 +79,14 @@ class BlazeCampaignListViewModel @Inject constructor( if (navArgs.isPostCampaignCreation) { showCampaignCelebrationIfNeeded() } + if (navArgs.campaignId != null) { + triggerEvent( + ShowCampaignDetails( + url = blazeUrlsHelper.buildCampaignDetailsUrl(navArgs.campaignId!!), + urlToTriggerExit = blazeUrlsHelper.buildCampaignsListUrl() + ) + ) + } launch { loadCampaigns(offset = 0) } @@ -105,45 +117,18 @@ class BlazeCampaignListViewModel @Inject constructor( } private fun onCampaignClicked(campaignId: String) { - val url = blazeUrlsHelper.buildCampaignDetailsUrl(campaignId) analyticsTrackerWrapper.track( stat = BLAZE_CAMPAIGN_DETAIL_SELECTED, properties = mapOf(AnalyticsTracker.KEY_BLAZE_SOURCE to BlazeFlowSource.CAMPAIGN_LIST.trackingName) ) triggerEvent( ShowCampaignDetails( - url = url, + url = blazeUrlsHelper.buildCampaignDetailsUrl(campaignId), urlToTriggerExit = blazeUrlsHelper.buildCampaignsListUrl() ) ) } - private fun mapToUiState(campaignEntity: BlazeCampaignEntity) = - ClickableCampaign( - campaignUi = BlazeCampaignUi( - product = BlazeProductUi( - name = campaignEntity.title, - imgUrl = campaignEntity.imageUrl.orEmpty(), - ), - status = CampaignStatusUi.fromString(campaignEntity.uiStatus), - stats = listOf( - BlazeCampaignStat( - name = R.string.blaze_campaign_status_impressions, - value = campaignEntity.impressions.toString() - ), - BlazeCampaignStat( - name = R.string.blaze_campaign_status_clicks, - value = campaignEntity.clicks.toString() - ), - BlazeCampaignStat( - name = R.string.blaze_campaign_status_budget, - value = currencyFormatter.formatCurrencyRounded(campaignEntity.totalBudget) - ) - ) - ), - onCampaignClicked = { onCampaignClicked(campaignEntity.campaignId) } - ) - private fun onAddNewCampaignClicked() { triggerEvent(LaunchBlazeCampaignCreation(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 0ddcd99c364..817dc891260 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 @@ -16,6 +16,7 @@ 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.blaze.notification.AbandonedCampaignReminder import com.woocommerce.android.ui.products.ProductStatus import com.woocommerce.android.ui.products.list.ProductListRepository import com.woocommerce.android.ui.products.selector.ProductSelectorFragment @@ -35,6 +36,7 @@ import javax.inject.Inject class BlazeCampaignCreationDispatcher @Inject constructor( private val blazeRepository: BlazeRepository, private val productListRepository: ProductListRepository, + private val abandonedCampaignReminder: AbandonedCampaignReminder, private val coroutineDispatchers: CoroutineDispatchers, private val analyticsTracker: AnalyticsTrackerWrapper, ) { @@ -68,6 +70,7 @@ class BlazeCampaignCreationDispatcher @Inject constructor( startCampaignCreationWithoutIntro(productId, source, handler) } } + abandonedCampaignReminder.scheduleReminderIfNeeded() } private suspend fun startCampaignCreationWithoutIntro( 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 da5c5dfb273..feafc124a8a 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 @@ -7,13 +7,17 @@ import android.view.View 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.mediapicker.MediaPickerHelper import com.woocommerce.android.mediapicker.MediaPickerHelper.MediaPickerResultHandler import com.woocommerce.android.model.Product.Image import com.woocommerce.android.ui.base.BaseFragment import com.woocommerce.android.ui.blaze.creation.ad.BlazeCampaignCreationEditAdViewModel.EditAdResult import com.woocommerce.android.ui.blaze.creation.ad.BlazeCampaignCreationEditAdViewModel.ShowMediaLibrary +import com.woocommerce.android.ui.blaze.creation.ad.BlazeCampaignCreationEditAdViewModel.ShowProductImagePicker +import com.woocommerce.android.ui.blaze.creation.ad.ProductImagePickerViewModel.ImageSelectedResult import com.woocommerce.android.ui.compose.composeView import com.woocommerce.android.ui.main.AppBarStatus import com.woocommerce.android.viewmodel.MultiLiveEvent.Event.Exit @@ -43,10 +47,12 @@ class BlazeCampaignCreationEditAdFragment : BaseFragment(), MediaPickerResultHan } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + handleResults() viewModel.event.observe(viewLifecycleOwner) { event -> when (event) { is Exit -> findNavController().popBackStack() is ShowMediaLibrary -> mediaPickerHelper.showMediaPicker(event.source) + is ShowProductImagePicker -> navigateToProductImagePicker(event.productId) is ShowDialog -> event.showDialog() is ExitWithResult<*> -> { navigateBackWithResult(EDIT_AD_RESULT, event.data as EditAdResult) @@ -55,6 +61,19 @@ class BlazeCampaignCreationEditAdFragment : BaseFragment(), MediaPickerResultHan } } + private fun navigateToProductImagePicker(productId: Long) { + findNavController().navigateSafely( + BlazeCampaignCreationEditAdFragmentDirections + .actionBlazeCampaignCreationEditAdFragmentToProductImagePickerFragment(productId) + ) + } + + private fun handleResults() { + handleResult(ProductImagePickerFragment.ON_PRODUCT_IMAGE_SELECTED) { + viewModel.onWPMediaSelected(it.productImage) + } + } + override fun onDeviceMediaSelected(imageUris: List, source: String) { if (imageUris.isNotEmpty()) { viewModel.onLocalImageSelected(imageUris.first().toString()) 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 b3fdd044dd5..f880be2d77a 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 @@ -69,6 +69,7 @@ fun BlazeCampaignCreationPreviewScreen(viewModel: BlazeCampaignCreationEditAdVie onNextSuggestionTapped = viewModel::onNextSuggestionTapped, onBackButtonTapped = viewModel::onBackButtonTapped, onMediaPickerDialogDismissed = viewModel::onMediaPickerDialogDismissed, + onProductImagesRequested = viewModel::onProductImagesRequested, onMediaLibraryRequested = viewModel::onMediaLibraryRequested, onSaveTapped = viewModel::onSaveTapped ) @@ -85,13 +86,16 @@ private fun BlazeCampaignCreationEditAdScreen( onNextSuggestionTapped: () -> Unit, onBackButtonTapped: () -> Unit, onMediaPickerDialogDismissed: () -> Unit, + onProductImagesRequested: () -> Unit, onMediaLibraryRequested: (DataSource) -> Unit, onSaveTapped: () -> Unit ) { if (viewState.isMediaPickerDialogVisible) { MediaPickerDialog( - onMediaPickerDialogDismissed, - onMediaLibraryRequested + onDismissRequest = onMediaPickerDialogDismissed, + onMediaLibraryRequested = onMediaLibraryRequested, + withProductImagePicker = true, + onProductImagesRequested = onProductImagesRequested ) } 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 e61e33d138e..ca7fcfe6396 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 @@ -170,7 +170,13 @@ class BlazeCampaignCreationEditAdViewModel @Inject constructor( } } + fun onProductImagesRequested() { + triggerEvent(ShowProductImagePicker(navArgs.productId)) + setMediaPickerDialogVisibility(false) + } + data class ShowMediaLibrary(val source: MediaPickerSetup.DataSource) : Event() + data class ShowProductImagePicker(val productId: Long) : Event() @Parcelize data class ViewState( diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/ad/ProductImagePickerFragment.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/ad/ProductImagePickerFragment.kt new file mode 100644 index 00000000000..fe204073a8a --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/ad/ProductImagePickerFragment.kt @@ -0,0 +1,45 @@ +package com.woocommerce.android.ui.blaze.creation.ad + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import com.woocommerce.android.extensions.navigateBackWithResult +import com.woocommerce.android.ui.base.BaseFragment +import com.woocommerce.android.ui.blaze.creation.ad.ProductImagePickerViewModel.ImageSelectedResult +import com.woocommerce.android.ui.compose.composeView +import com.woocommerce.android.ui.main.AppBarStatus +import com.woocommerce.android.viewmodel.MultiLiveEvent.Event.Exit +import com.woocommerce.android.viewmodel.MultiLiveEvent.Event.ExitWithResult +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class ProductImagePickerFragment : BaseFragment() { + companion object { + const val ON_PRODUCT_IMAGE_SELECTED = "on_product_image_selected" + } + + override val activityAppBarStatus: AppBarStatus + get() = AppBarStatus.Hidden + + val viewModel: ProductImagePickerViewModel by viewModels() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + return composeView { + ProductImagePickerScreen(viewModel = viewModel) + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + viewModel.event.observe(viewLifecycleOwner) { event -> + when (event) { + is Exit -> findNavController().popBackStack() + is ExitWithResult<*> -> { + navigateBackWithResult(ON_PRODUCT_IMAGE_SELECTED, event.data as ImageSelectedResult) + } + } + } + } +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/ad/ProductImagePickerViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/ad/ProductImagePickerViewModel.kt new file mode 100644 index 00000000000..572a77d651a --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/ad/ProductImagePickerViewModel.kt @@ -0,0 +1,61 @@ +package com.woocommerce.android.ui.blaze.creation.ad + +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.products.details.ProductDetailRepository +import com.woocommerce.android.viewmodel.MultiLiveEvent.Event.Exit +import com.woocommerce.android.viewmodel.MultiLiveEvent.Event.ExitWithResult +import com.woocommerce.android.viewmodel.ScopedViewModel +import com.woocommerce.android.viewmodel.getStateFlow +import com.woocommerce.android.viewmodel.navArgs +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize +import javax.inject.Inject + +@HiltViewModel +class ProductImagePickerViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val productRepository: ProductDetailRepository, +) : ScopedViewModel(savedStateHandle) { + private val navArgs: ProductImagePickerFragmentArgs by savedStateHandle.navArgs() + + private val _viewState = savedStateHandle.getStateFlow( + scope = viewModelScope, + initialValue = ViewState(emptyList()) + ) + val viewState = _viewState.asLiveData() + + init { + launch { + val product = productRepository.getProduct(navArgs.productId) + product?.let { + _viewState.update { it.copy(productImages = product.images) } + } + } + } + + fun onImageSelected(productImage: Product.Image) { + triggerEvent( + ExitWithResult( + ImageSelectedResult(productImage = productImage) + ) + ) + } + + fun onBackButtonTapped() { + triggerEvent(Exit) + } + + @Parcelize + data class ViewState( + val productImages: List + ) : Parcelable + + @Parcelize + data class ImageSelectedResult(val productImage: Product.Image) : Parcelable +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/ad/ProductImageScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/ad/ProductImageScreen.kt new file mode 100644 index 00000000000..cc0773cf5b0 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/ad/ProductImageScreen.kt @@ -0,0 +1,121 @@ +package com.woocommerce.android.ui.blaze.creation.ad + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells.Adaptive +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.runtime.Composable +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import coil.request.ImageRequest.Builder +import com.woocommerce.android.R +import com.woocommerce.android.model.Product +import com.woocommerce.android.ui.compose.component.Toolbar + +@Composable +fun ProductImagePickerScreen(viewModel: ProductImagePickerViewModel) { + viewModel.viewState.observeAsState().value?.let { viewState -> + ProductImagePickerScreen( + viewState = viewState, + onImageSelected = viewModel::onImageSelected, + onBackButtonTapped = viewModel::onBackButtonTapped + ) + } +} + +@Composable +fun ProductImagePickerScreen( + viewState: ProductImagePickerViewModel.ViewState, + onImageSelected: (Product.Image) -> Unit, + onBackButtonTapped: () -> Unit +) { + Scaffold( + topBar = { + Toolbar( + title = stringResource(id = R.string.blaze_campaign_product_photo_picker_title), + onNavigationButtonClick = onBackButtonTapped, + navigationIcon = Icons.AutoMirrored.Filled.ArrowBack, + ) + }, + backgroundColor = MaterialTheme.colors.surface + ) { paddingValues -> + when { + viewState.productImages.isEmpty() -> ProductPhotosEmpty() + + else -> ProductImageGrid( + viewState = viewState, + onImageSelected = onImageSelected, + modifier = Modifier.padding(paddingValues) + ) + } + } +} + +@Composable +private fun ProductPhotosEmpty() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(id = R.string.blaze_campaign_product_photo_picker_empty), + style = MaterialTheme.typography.h6, + fontWeight = FontWeight.Bold + ) + } +} + +@Composable +private fun ProductImageGrid( + viewState: ProductImagePickerViewModel.ViewState, + onImageSelected: (Product.Image) -> Unit, + modifier: Modifier = Modifier +) { + LazyVerticalGrid( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + columns = Adaptive(minSize = 128.dp), + verticalArrangement = Arrangement.spacedBy(3.dp), + horizontalArrangement = Arrangement.spacedBy(3.dp) + ) { + items(viewState.productImages) { photoUrl -> + AsyncImage( + model = Builder(LocalContext.current) + .data(photoUrl.source) + .crossfade(true) + .build(), + fallback = painterResource(R.drawable.blaze_campaign_product_placeholder), + placeholder = painterResource(R.drawable.blaze_campaign_product_placeholder), + error = painterResource(R.drawable.blaze_campaign_product_placeholder), + contentDescription = stringResource( + id = R.string.blaze_campaign_product_photo_picker_photo_content_description + ), + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .height(128.dp) + .clickable { onImageSelected(photoUrl) } + ) + } + } +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/budget/BlazeCampaignBudgetScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/budget/BlazeCampaignBudgetScreen.kt index dddb01891c5..b97bc64a505 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/budget/BlazeCampaignBudgetScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/budget/BlazeCampaignBudgetScreen.kt @@ -1,7 +1,9 @@ package com.woocommerce.android.ui.blaze.creation.budget +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -9,10 +11,12 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent import androidx.compose.foundation.verticalScroll -import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Divider import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Icon @@ -28,6 +32,7 @@ import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -39,6 +44,9 @@ import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -47,15 +55,23 @@ import com.woocommerce.android.R import com.woocommerce.android.R.color import com.woocommerce.android.R.dimen import com.woocommerce.android.R.drawable -import com.woocommerce.android.extensions.formatToMMMddYYYY +import com.woocommerce.android.extensions.formatToLocalizedMedium +import com.woocommerce.android.ui.blaze.BlazeRepository.Companion.CAMPAIGN_MAXIMUM_DAILY_SPEND +import com.woocommerce.android.ui.blaze.BlazeRepository.Companion.CAMPAIGN_MINIMUM_DAILY_SPEND +import com.woocommerce.android.ui.blaze.creation.budget.BlazeCampaignBudgetViewModel.BudgetUiState import com.woocommerce.android.ui.blaze.creation.budget.BlazeCampaignBudgetViewModel.Companion.MAX_DATE_LIMIT_IN_DAYS +import com.woocommerce.android.ui.compose.animations.SkeletonView import com.woocommerce.android.ui.compose.component.BottomSheetHandle +import com.woocommerce.android.ui.compose.component.BottomSheetSwitchColors import com.woocommerce.android.ui.compose.component.DatePickerDialog import com.woocommerce.android.ui.compose.component.Toolbar import com.woocommerce.android.ui.compose.component.WCColoredButton import com.woocommerce.android.ui.compose.component.WCModalBottomSheetLayout +import com.woocommerce.android.ui.compose.component.WCSwitch import com.woocommerce.android.ui.compose.component.WCTextButton import com.woocommerce.android.ui.compose.preview.LightDarkThemePreviews +import com.woocommerce.android.util.FeatureFlag +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.util.Calendar import java.util.Date @@ -68,11 +84,12 @@ fun CampaignBudgetScreen(viewModel: BlazeCampaignBudgetViewModel) { onBackPressed = viewModel::onBackPressed, onEditDurationTapped = viewModel::onEditDurationTapped, onImpressionsInfoTapped = viewModel::onImpressionsInfoTapped, - onBudgetUpdated = viewModel::onBudgetUpdated, + onBudgetUpdated = viewModel::onDailyBudgetUpdated, onStartDateChanged = viewModel::onStartDateChanged, onBudgetChangeFinished = viewModel::onBudgetChangeFinished, onUpdateTapped = viewModel::onUpdateTapped, - onApplyDurationTapped = viewModel::onApplyDurationTapped + onApplyDurationTapped = viewModel::onApplyDurationTapped, + onDurationSliderUpdated = viewModel::onDurationSliderUpdated ) } } @@ -80,7 +97,7 @@ fun CampaignBudgetScreen(viewModel: BlazeCampaignBudgetViewModel) { @OptIn(ExperimentalMaterialApi::class) @Composable private fun CampaignBudgetScreen( - state: BlazeCampaignBudgetViewModel.BudgetUiState, + state: BudgetUiState, onBackPressed: () -> Unit, onEditDurationTapped: () -> Unit, onImpressionsInfoTapped: () -> Unit, @@ -88,7 +105,8 @@ private fun CampaignBudgetScreen( onStartDateChanged: (Long) -> Unit, onBudgetChangeFinished: () -> Unit, onUpdateTapped: () -> Unit, - onApplyDurationTapped: (Int) -> Unit + onApplyDurationTapped: (Int, Boolean, Long) -> Unit, + onDurationSliderUpdated: (Int, Long) -> Unit, ) { val coroutineScope = rememberCoroutineScope() val modalSheetState = rememberModalBottomSheetState( @@ -100,7 +118,6 @@ private fun CampaignBudgetScreen( Scaffold( topBar = { Toolbar( - title = stringResource(id = R.string.blaze_campaign_budget_toolbar_title), onNavigationButtonClick = onBackPressed, navigationIcon = Icons.AutoMirrored.Filled.ArrowBack ) @@ -121,9 +138,13 @@ private fun CampaignBudgetScreen( state.showCampaignDurationBottomSheet -> EditDurationBottomSheet( budgetUiState = state, onStartDateChanged = { onStartDateChanged(it) }, - onApplyTapped = { - onApplyDurationTapped(it) + onApplyTapped = { duration, isEndlessCampaign, startDate -> + onApplyDurationTapped(duration, isEndlessCampaign, startDate) coroutineScope.launch { modalSheetState.hide() } + }, + onCancelTapped = { coroutineScope.launch { modalSheetState.hide() } }, + onDurationSliderUpdated = { duration, startDate -> + onDurationSliderUpdated(duration, startDate) } ) } @@ -143,14 +164,16 @@ private fun CampaignBudgetScreen( }, onBudgetUpdated = onBudgetUpdated, onBudgetChangeFinished = onBudgetChangeFinished, - modifier = Modifier.weight(1f) - ) - EditDurationSection( - campaignDurationDates = state.campaignDurationDates, onEditDurationTapped = { onEditDurationTapped() coroutineScope.launch { modalSheetState.show() } }, + modifier = Modifier.weight(1f) + ) + CampaignBudgetFooter( + isEndlessCampaign = state.isEndlessCampaign, + formattedBudget = state.formattedTotalBudget, + durationInDays = state.durationInDays, onUpdateTapped = onUpdateTapped ) } @@ -160,77 +183,116 @@ private fun CampaignBudgetScreen( @Composable private fun EditBudgetSection( - state: BlazeCampaignBudgetViewModel.BudgetUiState, + state: BudgetUiState, onBudgetUpdated: (Float) -> Unit, onImpressionsInfoTapped: () -> Unit, onBudgetChangeFinished: () -> Unit, + onEditDurationTapped: () -> Unit, modifier: Modifier = Modifier ) { Column( modifier = modifier - .padding(start = 28.dp, end = 28.dp) + .padding(start = 16.dp, end = 16.dp) .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally, ) { Text( - modifier = Modifier.padding( - top = 40.dp, - bottom = 90.dp - ), - text = stringResource(id = R.string.blaze_campaign_budget_subtitle), + modifier = Modifier.padding(16.dp), + text = stringResource(id = R.string.blaze_campaign_budget_toolbar_title), + style = MaterialTheme.typography.h4, + fontWeight = FontWeight.Bold, + ) + Text( + modifier = Modifier.padding(bottom = 64.dp), + text = stringResource(id = R.string.blaze_campaign_budget_duration_subtitle), style = MaterialTheme.typography.subtitle1, textAlign = TextAlign.Center, - lineHeight = 24.sp, color = colorResource(id = color.color_on_surface_medium) ) Text( modifier = Modifier.padding(bottom = 8.dp), - text = stringResource(id = R.string.blaze_campaign_budget_total_spend), + text = stringResource(id = R.string.blaze_campaign_budget_daily_spend_label), style = MaterialTheme.typography.body1, color = colorResource(id = color.color_on_surface_medium) ) Text( - text = " $${state.totalBudget.toInt()} USD", + text = state.formattedDailySpending, style = MaterialTheme.typography.h4, fontWeight = FontWeight.Bold, ) - Text( - text = stringResource(id = R.string.blaze_campaign_budget_days_duration, state.durationInDays), - style = MaterialTheme.typography.h4, - color = colorResource(id = color.color_on_surface_medium) - ) - Text( - modifier = Modifier.padding(top = 40.dp), - text = stringResource(id = R.string.blaze_campaign_budget_daily_spend, state.dailySpending), - color = colorResource(id = color.color_on_surface_medium), - style = MaterialTheme.typography.subtitle1, - ) Slider( modifier = Modifier.padding(top = 8.dp, bottom = 8.dp), - value = state.totalBudget, - valueRange = state.budgetRangeMin..state.budgetRangeMax, + value = state.dailySpend, + valueRange = CAMPAIGN_MINIMUM_DAILY_SPEND..CAMPAIGN_MAXIMUM_DAILY_SPEND, onValueChange = { onBudgetUpdated(it) }, onValueChangeFinished = { onBudgetChangeFinished() }, colors = SliderDefaults.colors( inactiveTrackColor = colorResource(id = color.divider_color) ) ) - Row(verticalAlignment = Alignment.CenterVertically) { - Text(text = stringResource(id = R.string.blaze_campaign_budget_reach_forecast)) - Icon( - modifier = Modifier - .padding(start = 4.dp) - .clickable { onImpressionsInfoTapped() }, - painter = painterResource(id = drawable.ic_info_outline_20dp), - contentDescription = null + CampaignDurationRow( + formattedStartDate = state.formattedStartDate, + formattedEndDate = state.formattedEndDate, + isEndlessCampaign = state.isEndlessCampaign, + onEditDurationTapped = onEditDurationTapped, + modifier = Modifier.padding(top = 24.dp) + ) + CampaignImpressionsRow( + state = state, + onImpressionsInfoTapped = onImpressionsInfoTapped, + onBudgetChangeFinished = onBudgetChangeFinished, + modifier = Modifier.padding(top = 18.dp) + ) + } +} + +@Composable +private fun CampaignImpressionsRow( + state: BudgetUiState, + onImpressionsInfoTapped: () -> Unit, + onBudgetChangeFinished: () -> Unit, + modifier: Modifier = Modifier +) { + Column(modifier = modifier) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onImpressionsInfoTapped() } + .padding(top = 8.dp, bottom = 8.dp, end = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val id = "inlineIcon" + val text = buildAnnotatedString { + append(stringResource(id = R.string.blaze_campaign_budget_reach_forecast)) + appendInlineContent(id = id, alternateText = "[Icon]") + } + val inlineContent = mapOf( + id to InlineTextContent( + Placeholder( + width = 24.sp, + height = 20.sp, + placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter + ) + ) { + Icon( + modifier = Modifier.padding(start = 4.dp), + painter = painterResource(id = drawable.ic_info_outline_20dp), + contentDescription = "" + ) + } + ) + Text( + text = text, + inlineContent = inlineContent, + style = MaterialTheme.typography.body1, + color = colorResource(id = color.color_on_surface_medium) ) } - Spacer(modifier = Modifier.height(6.dp)) if (state.forecast.isLoading) { - CircularProgressIndicator( + SkeletonView( modifier = Modifier - .align(Alignment.CenterHorizontally) - .size(20.dp), + .size(height = 20.dp, width = 140.dp) + .padding(top = 8.dp) ) } else { if (state.forecast.isError) { @@ -241,57 +303,111 @@ private fun EditBudgetSection( ) } else { Text( - text = "${state.forecast.impressionsMin} - ${state.forecast.impressionsMax}", + modifier = Modifier.padding(top = 6.dp), + text = "${state.forecast.formattedImpressionsMin} - ${state.forecast.formattedImpressionsMax}", + style = MaterialTheme.typography.h6, fontWeight = FontWeight.SemiBold, - lineHeight = 24.sp, ) } } - Spacer(modifier = Modifier.height(24.dp)) } } @Composable -private fun EditDurationSection( - campaignDurationDates: String, +private fun CampaignDurationRow( + formattedStartDate: String, + formattedEndDate: String, + isEndlessCampaign: Boolean, onEditDurationTapped: () -> Unit, - onUpdateTapped: () -> Unit, + modifier: Modifier = Modifier ) { - Column { - Divider() - Column( - modifier = Modifier.padding( - start = 16.dp, - end = 16.dp, - top = 16.dp, - bottom = 24.dp - ) + Column(modifier = modifier) { + Text( + text = stringResource(id = R.string.blaze_campaign_budget_scheduled_section_title), + style = MaterialTheme.typography.body1, + color = colorResource(id = color.color_on_surface_medium) + ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically ) { - Text( - text = stringResource(id = R.string.blaze_campaign_budget_duration_section_title), - style = MaterialTheme.typography.body1, - color = colorResource(id = color.color_on_surface_medium) - ) - Row( - verticalAlignment = Alignment.CenterVertically - ) { + when { + isEndlessCampaign -> + Text( + text = stringResource( + id = R.string.blaze_campaign_budget_duration_endless_campaign_value, + formattedStartDate + ), + style = MaterialTheme.typography.h6, + fontWeight = FontWeight.SemiBold, + ) + + else -> { + Text( + text = "$formattedStartDate - $formattedEndDate", + style = MaterialTheme.typography.h6, + fontWeight = FontWeight.SemiBold, + ) + } + } + Spacer(modifier = Modifier.weight(1f)) + WCTextButton(onClick = onEditDurationTapped) { Text( - text = campaignDurationDates, - style = MaterialTheme.typography.subtitle2, + text = stringResource(id = R.string.blaze_campaign_budget_edit_duration_button), + style = MaterialTheme.typography.h6, fontWeight = FontWeight.SemiBold, ) - WCTextButton( - onClick = onEditDurationTapped - ) { - Text(text = stringResource(id = R.string.blaze_campaign_budget_edit_duration_button)) - } } - WCColoredButton( - modifier = Modifier.fillMaxWidth(), - onClick = onUpdateTapped, - text = stringResource(id = R.string.blaze_campaign_budget_update_button) + } + } +} + +@Composable +private fun CampaignBudgetFooter( + isEndlessCampaign: Boolean, + formattedBudget: String, + durationInDays: Int, + onUpdateTapped: () -> Unit +) { + Column { + Divider() + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.Center + ) { + Text( + text = formattedBudget, + style = MaterialTheme.typography.subtitle1, + fontWeight = FontWeight.SemiBold, + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = if (isEndlessCampaign) { + stringResource(id = R.string.blaze_campaign_budget_footer_weekly_spend) + } else { + stringResource( + id = R.string.blaze_campaign_budget_days_duration, + durationInDays + ) + }, + style = MaterialTheme.typography.body1, + color = colorResource(id = color.color_on_surface_medium), + fontWeight = FontWeight.SemiBold, ) } + WCColoredButton( + modifier = Modifier + .fillMaxWidth() + .padding( + start = 16.dp, + end = 16.dp, + bottom = 24.dp + ), + onClick = onUpdateTapped, + text = stringResource(id = R.string.blaze_campaign_budget_update_button) + ) } } @@ -316,7 +432,10 @@ private fun ImpressionsInfoBottomSheet( WCTextButton( onClick = onDoneTapped ) { - Text(text = stringResource(id = R.string.blaze_campaign_budget_impressions_done_button)) + Text( + style = MaterialTheme.typography.h6, + text = stringResource(id = R.string.blaze_campaign_budget_impressions_done_button) + ) } } Divider() @@ -331,88 +450,164 @@ private fun ImpressionsInfoBottomSheet( @Composable private fun EditDurationBottomSheet( - budgetUiState: BlazeCampaignBudgetViewModel.BudgetUiState, + budgetUiState: BudgetUiState, onStartDateChanged: (Long) -> Unit, - onApplyTapped: (Int) -> Unit, + onApplyTapped: (Int, Boolean, Long) -> Unit, + onCancelTapped: () -> Unit, + onDurationSliderUpdated: (Int, Long) -> Unit, modifier: Modifier = Modifier, ) { + val coroutineScope = rememberCoroutineScope() var showDatePicker by remember { mutableStateOf(false) } + var selectedStartDate by remember { mutableStateOf(Date(budgetUiState.confirmedCampaignStartDateMillis)) } + var isEndlessCampaign by remember { mutableStateOf(budgetUiState.isEndlessCampaign) } + var sliderPosition by remember { mutableFloatStateOf(budgetUiState.durationInDays.toFloat()) } + if (showDatePicker) { DatePickerDialog( - currentDate = Date(budgetUiState.confirmedCampaignStartDateMillis), + currentDate = selectedStartDate, minDate = Calendar.getInstance().apply { add(Calendar.DAY_OF_YEAR, 1) }.time, maxDate = Calendar.getInstance().apply { add(Calendar.DAY_OF_YEAR, MAX_DATE_LIMIT_IN_DAYS) }.time, onDateSelected = { onStartDateChanged(it.time) + selectedStartDate = it showDatePicker = false }, onDismissRequest = { showDatePicker = false } ) } - var sliderPosition by remember { mutableStateOf(budgetUiState.durationInDays.toFloat()) } Column( modifier = modifier - .padding(16.dp) + .padding(vertical = 16.dp) .verticalScroll(rememberScrollState()) ) { - Text( - text = stringResource(id = R.string.blaze_campaign_budget_duration_bottom_sheet_title), - style = MaterialTheme.typography.h6, - fontWeight = FontWeight.SemiBold, - ) - Text( - modifier = Modifier - .padding(top = 40.dp) - .fillMaxWidth(), - text = stringResource( - id = R.string.blaze_campaign_budget_duration_bottom_sheet_duration, - sliderPosition.toInt() - ), - style = MaterialTheme.typography.subtitle1, - fontWeight = FontWeight.SemiBold, - textAlign = TextAlign.Center - ) - Slider( - modifier = Modifier - .padding(top = 8.dp, bottom = 8.dp) - .fillMaxWidth(), - value = sliderPosition, - valueRange = budgetUiState.durationRangeMin..budgetUiState.durationRangeMax, - onValueChange = { sliderPosition = it }, - colors = SliderDefaults.colors( - inactiveTrackColor = colorResource(id = color.divider_color) - ) - ) Row( - modifier = Modifier.padding(top = 40.dp), + modifier = Modifier.padding( + start = 16.dp, + end = 16.dp, + bottom = 8.dp + ), verticalAlignment = Alignment.CenterVertically ) { Text( modifier = Modifier.weight(1f), - text = stringResource(id = R.string.blaze_campaign_budget_duration_bottom_sheet_starts), - style = MaterialTheme.typography.body1, + text = stringResource(id = R.string.blaze_campaign_budget_scheduled_section_title), + style = MaterialTheme.typography.h6, ) - Text( + WCTextButton( + onClick = { + sliderPosition = budgetUiState.durationInDays.toFloat() + isEndlessCampaign = budgetUiState.isEndlessCampaign + selectedStartDate = Date(budgetUiState.confirmedCampaignStartDateMillis) + coroutineScope.launch { + @Suppress("MagicNumber") + delay(400) + onCancelTapped() + } + } + ) { + Text( + text = stringResource(id = R.string.blaze_campaign_budget_duration_cancel_button), + style = MaterialTheme.typography.h6, + ) + } + } + Divider() + Column(modifier = Modifier.padding(horizontal = 16.dp)) { + Row( + modifier = Modifier.padding(top = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier.weight(1f), + text = stringResource(id = R.string.blaze_campaign_budget_duration_bottom_sheet_start_date), + style = MaterialTheme.typography.body1, + ) + Text( + modifier = Modifier + .clickable { showDatePicker = !showDatePicker } + .clip(RoundedCornerShape(4.dp)) + .background(colorResource(id = color.divider_color)) + .padding(8.dp), + text = selectedStartDate.formatToLocalizedMedium(), + style = MaterialTheme.typography.body1, + ) + } + if (FeatureFlag.ENDLESS_CAMPAIGNS_SUPPORT.isEnabled()) { + WCSwitch( + text = stringResource(id = R.string.blaze_campaign_budget_duration_endless_switch_label), + checked = !isEndlessCampaign, + onCheckedChange = { isEndlessCampaign = !it }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + colors = BottomSheetSwitchColors() + ) + AnimatedVisibility(isEndlessCampaign) { + Text( + modifier = Modifier.padding(top = 10.dp), + text = stringResource(id = R.string.blaze_campaign_budget_duration_endless_description), + style = MaterialTheme.typography.body1, + color = colorResource(id = color.color_on_surface_medium) + ) + } + } + AnimatedVisibility(isEndlessCampaign.not()) { + Column(modifier = Modifier.padding(top = 16.dp)) { + Row(modifier = Modifier.fillMaxWidth()) { + Text( + modifier = Modifier.weight(1f), + text = stringResource(id = R.string.blaze_campaign_budget_duration_current_duration), + style = MaterialTheme.typography.body1, + ) + Text( + text = stringResource( + id = R.string.blaze_campaign_budget_duration_bottom_sheet_duration, + sliderPosition.toInt() + ), + style = MaterialTheme.typography.subtitle1, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center + ) + Text( + modifier = Modifier.padding(start = 6.dp), + text = stringResource( + id = R.string.blaze_campaign_budget_duration_bottom_sheet_end_date, + budgetUiState.formattedEndDate + ), + style = MaterialTheme.typography.body1, + ) + } + Slider( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + value = sliderPosition, + valueRange = budgetUiState.durationRangeMin..budgetUiState.durationRangeMax, + onValueChange = { + sliderPosition = it + onDurationSliderUpdated(it.toInt(), selectedStartDate.time) + }, + colors = SliderDefaults.colors( + inactiveTrackColor = colorResource(id = color.divider_color) + ) + ) + } + } + WCColoredButton( modifier = Modifier - .clickable { showDatePicker = !showDatePicker } - .clip(RoundedCornerShape(4.dp)) - .background(colorResource(id = color.divider_color)) - .padding(8.dp), - text = Date(budgetUiState.bottomSheetCampaignStartDateMillis).formatToMMMddYYYY(), - style = MaterialTheme.typography.body1, + .padding( + top = 30.dp, + bottom = 16.dp + ) + .fillMaxWidth(), + onClick = { + onApplyTapped(sliderPosition.toInt(), isEndlessCampaign, selectedStartDate.time) + }, + text = stringResource(id = R.string.blaze_campaign_budget_duration_bottom_sheet_apply_button) ) } - WCColoredButton( - modifier = Modifier - .padding( - top = 30.dp, - bottom = 16.dp - ) - .fillMaxWidth(), - onClick = { onApplyTapped(sliderPosition.toInt()) }, - text = stringResource(id = R.string.blaze_campaign_budget_duration_bottom_sheet_apply_button) - ) } } @@ -420,26 +615,27 @@ private fun EditDurationBottomSheet( @Composable private fun CampaignBudgetScreenPreview() { CampaignBudgetScreen( - state = BlazeCampaignBudgetViewModel.BudgetUiState( + state = BudgetUiState( currencyCode = "USD", totalBudget = 35f, - budgetRangeMin = 5f, - budgetRangeMax = 35f, - dailySpending = "$5", + formattedTotalBudget = "$35", + dailySpend = 5f, + formattedDailySpending = "$5", durationInDays = 7, durationRangeMin = 1f, durationRangeMax = 28f, forecast = BlazeCampaignBudgetViewModel.ForecastUi( isLoading = false, - impressionsMin = 0, - impressionsMax = 0, + formattedImpressionsMin = "0", + formattedImpressionsMax = "0", isError = false ), confirmedCampaignStartDateMillis = Date().time, - bottomSheetCampaignStartDateMillis = Date().time, - campaignDurationDates = "Dec 13 - Dec 20, 2023", showImpressionsBottomSheet = false, - showCampaignDurationBottomSheet = false + showCampaignDurationBottomSheet = false, + isEndlessCampaign = true, + formattedStartDate = "Dec 13", + formattedEndDate = "Dec 20, 2023" ), onBackPressed = {}, onEditDurationTapped = {}, @@ -448,7 +644,8 @@ private fun CampaignBudgetScreenPreview() { onStartDateChanged = {}, onUpdateTapped = {}, onBudgetChangeFinished = {}, - onApplyDurationTapped = {} + onApplyDurationTapped = { _, _, _ -> }, + onDurationSliderUpdated = { _, _ -> }, ) } 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 8d993523072..bc9829cb836 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 @@ -8,13 +8,12 @@ import com.woocommerce.android.analytics.AnalyticsEvent.BLAZE_CREATION_EDIT_BUDG import com.woocommerce.android.analytics.AnalyticsEvent.BLAZE_CREATION_EDIT_BUDGET_SET_DURATION_APPLIED import com.woocommerce.android.analytics.AnalyticsTracker import com.woocommerce.android.analytics.AnalyticsTrackerWrapper +import com.woocommerce.android.extensions.formatToLocalizedMedium import com.woocommerce.android.extensions.formatToMMMdd -import com.woocommerce.android.extensions.formatToMMMddYYYY import com.woocommerce.android.ui.blaze.BlazeRepository import com.woocommerce.android.ui.blaze.BlazeRepository.Budget -import com.woocommerce.android.ui.blaze.BlazeRepository.Companion.CAMPAIGN_MAXIMUM_DAILY_SPEND import com.woocommerce.android.ui.blaze.BlazeRepository.Companion.CAMPAIGN_MAX_DURATION -import com.woocommerce.android.ui.blaze.BlazeRepository.Companion.CAMPAIGN_MINIMUM_DAILY_SPEND +import com.woocommerce.android.ui.blaze.BlazeRepository.Companion.WEEKLY_DURATION import com.woocommerce.android.util.CurrencyFormatter import com.woocommerce.android.viewmodel.MultiLiveEvent.Event.Exit import com.woocommerce.android.viewmodel.MultiLiveEvent.Event.ExitWithResult @@ -25,8 +24,11 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize +import java.text.NumberFormat import java.util.Date +import java.util.Locale import javax.inject.Inject +import kotlin.math.roundToInt import kotlin.time.Duration.Companion.days @HiltViewModel @@ -34,7 +36,7 @@ class BlazeCampaignBudgetViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val currencyFormatter: CurrencyFormatter, private val repository: BlazeRepository, - private val analyticsTrackerWrapper: AnalyticsTrackerWrapper + private val analyticsTrackerWrapper: AnalyticsTrackerWrapper, ) : ScopedViewModel(savedStateHandle) { companion object { const val MAX_DATE_LIMIT_IN_DAYS = 60 @@ -47,23 +49,27 @@ class BlazeCampaignBudgetViewModel @Inject constructor( BudgetUiState( currencyCode = navArgs.budget.currencyCode, totalBudget = navArgs.budget.totalBudget, - budgetRangeMin = navArgs.budget.durationInDays * CAMPAIGN_MINIMUM_DAILY_SPEND, - budgetRangeMax = navArgs.budget.durationInDays * CAMPAIGN_MAXIMUM_DAILY_SPEND, - dailySpending = formatDailySpend( - dailySpend = navArgs.budget.totalBudget / navArgs.budget.durationInDays + dailySpend = navArgs.budget.totalBudget / navArgs.budget.durationInDays, + formattedTotalBudget = formatBudget(rawValue = navArgs.budget.totalBudget), + formattedDailySpending = formatBudget( + rawValue = navArgs.budget.totalBudget / navArgs.budget.durationInDays ), forecast = getLoadingForecastUi(), durationInDays = navArgs.budget.durationInDays, durationRangeMin = 1f, durationRangeMax = CAMPAIGN_MAX_DURATION.toFloat(), confirmedCampaignStartDateMillis = navArgs.budget.startDate.time, - bottomSheetCampaignStartDateMillis = navArgs.budget.startDate.time, - campaignDurationDates = getCampaignDurationDisplayDate( - navArgs.budget.startDate.time, - navArgs.budget.durationInDays - ), showImpressionsBottomSheet = false, - showCampaignDurationBottomSheet = false + showCampaignDurationBottomSheet = false, + isEndlessCampaign = navArgs.budget.isEndlessCampaign, + formattedStartDate = getFormattedStartDate( + startDateMillis = navArgs.budget.startDate.time, + isEndlessCampaign = navArgs.budget.isEndlessCampaign + ), + formattedEndDate = getFormattedEndDate( + startDateMillis = navArgs.budget.startDate.time, + duration = navArgs.budget.durationInDays + ) ) ) @@ -92,6 +98,7 @@ class BlazeCampaignBudgetViewModel @Inject constructor( durationInDays = budgetUiState.value.durationInDays, startDate = Date(budgetUiState.value.confirmedCampaignStartDateMillis), currencyCode = budgetUiState.value.currencyCode, + isEndlessCampaign = budgetUiState.value.isEndlessCampaign ) ) ) @@ -100,6 +107,10 @@ class BlazeCampaignBudgetViewModel @Inject constructor( properties = mapOf( AnalyticsTracker.KEY_BLAZE_DURATION to budgetUiState.value.durationInDays, AnalyticsTracker.KEY_BLAZE_TOTAL_BUDGET to budgetUiState.value.totalBudget, + AnalyticsTracker.KEY_BLAZE_CAMPAIGN_TYPE to when { + budgetUiState.value.isEndlessCampaign -> AnalyticsTracker.VALUE_EVERGREEN_CAMPAIGN + else -> AnalyticsTracker.VALUE_START_END_CAMPAIGN + }, ) ) } @@ -122,29 +133,41 @@ class BlazeCampaignBudgetViewModel @Inject constructor( } } - fun onBudgetUpdated(sliderValue: Float) { + fun onDailyBudgetUpdated(sliderValue: Float) { + val totalBudget = sliderValue.roundToInt().toFloat() * budgetUiState.value.durationInDays budgetUiState.update { it.copy( - totalBudget = sliderValue, - dailySpending = formatDailySpend(sliderValue / it.durationInDays) + dailySpend = sliderValue, + formattedDailySpending = formatBudget(sliderValue), + totalBudget = totalBudget, + formattedTotalBudget = formatBudget(totalBudget) ) } } - fun onApplyDurationTapped(newDuration: Int) { - val currentDailySpend = calculateDailySpending(newDuration) + fun onApplyDurationTapped( + newDuration: Int, + isEndlessCampaign: Boolean, + startDateMillis: Long + ) { + val duration = if (isEndlessCampaign) WEEKLY_DURATION else newDuration + val totalBudget = duration * budgetUiState.value.dailySpend budgetUiState.update { it.copy( - durationInDays = newDuration, - budgetRangeMin = newDuration * CAMPAIGN_MINIMUM_DAILY_SPEND, - budgetRangeMax = newDuration * CAMPAIGN_MAXIMUM_DAILY_SPEND, - dailySpending = formatDailySpend(currentDailySpend), - totalBudget = newDuration * currentDailySpend, - confirmedCampaignStartDateMillis = it.bottomSheetCampaignStartDateMillis, - campaignDurationDates = getCampaignDurationDisplayDate( - it.bottomSheetCampaignStartDateMillis, - newDuration - ) + durationInDays = duration, + formattedDailySpending = formatBudget(budgetUiState.value.dailySpend), + totalBudget = totalBudget, + formattedTotalBudget = formatBudget(rawValue = totalBudget), + confirmedCampaignStartDateMillis = startDateMillis, + isEndlessCampaign = isEndlessCampaign, + formattedStartDate = getFormattedStartDate( + startDateMillis = startDateMillis, + isEndlessCampaign = isEndlessCampaign + ), + formattedEndDate = getFormattedEndDate( + startDateMillis = startDateMillis, + duration = duration + ), ) } fetchAdForecast() @@ -152,6 +175,10 @@ class BlazeCampaignBudgetViewModel @Inject constructor( stat = BLAZE_CREATION_EDIT_BUDGET_SET_DURATION_APPLIED, properties = mapOf( AnalyticsTracker.KEY_BLAZE_DURATION to budgetUiState.value.durationInDays, + AnalyticsTracker.KEY_BLAZE_CAMPAIGN_TYPE to when { + isEndlessCampaign -> AnalyticsTracker.VALUE_EVERGREEN_CAMPAIGN + else -> AnalyticsTracker.VALUE_START_END_CAMPAIGN + } ) ) } @@ -159,10 +186,9 @@ class BlazeCampaignBudgetViewModel @Inject constructor( fun onStartDateChanged(newStartDateMillis: Long) { budgetUiState.update { it.copy( - bottomSheetCampaignStartDateMillis = newStartDateMillis, - campaignDurationDates = getCampaignDurationDisplayDate( - newStartDateMillis, - it.durationInDays + formattedEndDate = getFormattedEndDate( + startDateMillis = newStartDateMillis, + duration = it.durationInDays ) ) } @@ -170,16 +196,6 @@ class BlazeCampaignBudgetViewModel @Inject constructor( fun onBudgetChangeFinished() { fetchAdForecast() - val roundedBudgetToDurationMultiple = - calculateDailySpending(budgetUiState.value.durationInDays) * budgetUiState.value.durationInDays - budgetUiState.update { - it.copy(totalBudget = roundedBudgetToDurationMultiple) - } - } - - private fun calculateDailySpending(duration: Int): Float { - val dailySpend = budgetUiState.value.totalBudget / duration - return dailySpend.coerceIn(CAMPAIGN_MINIMUM_DAILY_SPEND, CAMPAIGN_MAXIMUM_DAILY_SPEND) } private fun fetchAdForecast() { @@ -191,11 +207,12 @@ class BlazeCampaignBudgetViewModel @Inject constructor( totalBudget = budgetUiState.value.totalBudget, targetingParameters = navArgs.targetingParameters ).onSuccess { fetchAdForecastResult -> + val formatter = NumberFormat.getInstance(Locale.getDefault()) campaignForecastState = campaignForecastState.copy( isLoading = false, isError = false, - impressionsMin = fetchAdForecastResult.minImpressions, - impressionsMax = fetchAdForecastResult.maxImpressions + formattedImpressionsMin = formatter.format(fetchAdForecastResult.minImpressions), + formattedImpressionsMax = formatter.format(fetchAdForecastResult.maxImpressions) ) }.onFailure { campaignForecastState = campaignForecastState.copy( @@ -206,28 +223,43 @@ class BlazeCampaignBudgetViewModel @Inject constructor( } } - private fun getCampaignDurationDisplayDate(startDateMillis: Long, duration: Int): String { - val endDate = Date(startDateMillis + duration.days.inWholeMilliseconds) - return "${Date(startDateMillis).formatToMMMdd()} - ${endDate.formatToMMMddYYYY()}" - } + private fun getFormattedStartDate(startDateMillis: Long, isEndlessCampaign: Boolean) = + when { + isEndlessCampaign -> Date(startDateMillis).formatToLocalizedMedium() + else -> Date(startDateMillis).formatToMMMdd() + } + + private fun getFormattedEndDate(startDateMillis: Long, duration: Int) = + Date(startDateMillis + duration.days.inWholeMilliseconds).formatToLocalizedMedium() - private fun formatDailySpend(dailySpend: Float) = - currencyFormatter.formatCurrencyRounded(dailySpend.toDouble(), navArgs.budget.currencyCode) + private fun formatBudget(rawValue: Float) = + currencyFormatter.formatCurrencyRounded(rawValue.toDouble(), navArgs.budget.currencyCode) private fun getLoadingForecastUi() = ForecastUi( isLoading = true, - impressionsMin = 0, - impressionsMax = 0, + formattedImpressionsMin = "0", + formattedImpressionsMax = "0", isError = false ) + fun onDurationSliderUpdated(durationInDays: Int, selectedStartDateMillis: Long) { + budgetUiState.update { + it.copy( + formattedEndDate = getFormattedEndDate( + startDateMillis = selectedStartDateMillis, + duration = durationInDays + ), + ) + } + } + @Parcelize data class BudgetUiState( val currencyCode: String, val totalBudget: Float, - val budgetRangeMin: Float, - val budgetRangeMax: Float, - val dailySpending: String, + val dailySpend: Float, + val formattedTotalBudget: String, + val formattedDailySpending: String, val forecast: ForecastUi, val durationInDays: Int, val durationRangeMin: Float, @@ -235,15 +267,16 @@ class BlazeCampaignBudgetViewModel @Inject constructor( val showImpressionsBottomSheet: Boolean, val showCampaignDurationBottomSheet: Boolean, val confirmedCampaignStartDateMillis: Long, - val bottomSheetCampaignStartDateMillis: Long, - val campaignDurationDates: String, + val isEndlessCampaign: Boolean, + val formattedStartDate: String, + val formattedEndDate: String ) : Parcelable @Parcelize data class ForecastUi( val isLoading: Boolean, - val impressionsMin: Long, - val impressionsMax: Long, + val formattedImpressionsMin: String, + val formattedImpressionsMax: String, val isError: Boolean ) : Parcelable } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/payment/BlazeCampaignPaymentMethodsListScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/payment/BlazeCampaignPaymentMethodsListScreen.kt index faf6ed1887c..b3c1e7f3e0a 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/payment/BlazeCampaignPaymentMethodsListScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/creation/payment/BlazeCampaignPaymentMethodsListScreen.kt @@ -39,7 +39,7 @@ import com.woocommerce.android.ui.blaze.BlazeRepository.PaymentMethod import com.woocommerce.android.ui.compose.component.Toolbar import com.woocommerce.android.ui.compose.component.WCColoredButton import com.woocommerce.android.ui.compose.component.WCTextButton -import com.woocommerce.android.ui.compose.component.WCWebView +import com.woocommerce.android.ui.compose.component.web.WCWebView import com.woocommerce.android.ui.compose.theme.WooThemeWithBackground @Composable 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 283ffb5a5b6..caadcc342db 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 @@ -123,7 +123,7 @@ private fun PaymentSummaryContent( ) PaymentTotals( - budget = state.budgetFormatted, + displayBudget = state.displayBudget, modifier = Modifier.fillMaxWidth() ) @@ -263,7 +263,7 @@ private fun CampaignCreationErrorUi( @Composable private fun PaymentTotals( - budget: String, + displayBudget: String, modifier: Modifier ) { Column( @@ -285,7 +285,7 @@ private fun PaymentTotals( ) Text( - text = budget, + text = displayBudget, style = MaterialTheme.typography.body2 ) } @@ -300,7 +300,7 @@ private fun PaymentTotals( ) Text( - text = budget, + text = displayBudget, style = MaterialTheme.typography.subtitle2 ) } @@ -403,7 +403,7 @@ private fun BlazeCampaignPaymentSummaryScreenPreview() { WooThemeWithBackground { BlazeCampaignPaymentSummaryScreen( state = BlazeCampaignPaymentSummaryViewModel.ViewState( - budgetFormatted = "100 USD", + displayBudget = "100 USD", paymentMethodsState = BlazeCampaignPaymentSummaryViewModel.PaymentMethodsState.Success( BlazeRepository.PaymentMethodsData( listOf( 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 df84b5264e1..e151408d1d1 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 @@ -12,9 +12,11 @@ import com.woocommerce.android.model.DashboardWidget 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.ui.blaze.notification.AbandonedCampaignReminder import com.woocommerce.android.ui.dashboard.data.DashboardRepository 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 dagger.hilt.android.lifecycle.HiltViewModel @@ -26,16 +28,15 @@ import javax.inject.Inject @HiltViewModel class BlazeCampaignPaymentSummaryViewModel @Inject constructor( savedStateHandle: SavedStateHandle, - private val blazeRepository: BlazeRepository, currencyFormatter: CurrencyFormatter, + private val blazeRepository: BlazeRepository, + private val abandonedCampaignReminder: AbandonedCampaignReminder, private val analyticsTrackerWrapper: AnalyticsTrackerWrapper, - private val dashboardRepository: DashboardRepository + private val dashboardRepository: DashboardRepository, + private val resourceProvider: ResourceProvider ) : ScopedViewModel(savedStateHandle) { private val navArgs = BlazeCampaignPaymentSummaryFragmentArgs.fromSavedStateHandle(savedStateHandle) - private val budgetFormatted = currencyFormatter.formatCurrency( - amount = navArgs.campaignDetails.budget.totalBudget.toBigDecimal(), - currencyCode = navArgs.campaignDetails.budget.currencyCode - ) + private val budgetFormatted = getBudgetDisplayValue(currencyFormatter) private val selectedPaymentMethodId = savedStateHandle.getNullableStateFlow( scope = viewModelScope, @@ -52,7 +53,7 @@ class BlazeCampaignPaymentSummaryViewModel @Inject constructor( campaignCreationState ) { selectedPaymentMethodId, paymentMethodState, campaignCreationState -> ViewState( - budgetFormatted = budgetFormatted, + displayBudget = budgetFormatted, paymentMethodsState = paymentMethodState, selectedPaymentMethodId = selectedPaymentMethodId, campaignCreationState = campaignCreationState @@ -124,7 +125,17 @@ class BlazeCampaignPaymentSummaryViewModel @Inject constructor( ).fold( onSuccess = { campaignCreationState.value = null - analyticsTrackerWrapper.track(stat = AnalyticsEvent.BLAZE_CAMPAIGN_CREATION_SUCCESS) + abandonedCampaignReminder.setBlazeCampaignCreated() + analyticsTrackerWrapper.track( + stat = AnalyticsEvent.BLAZE_CAMPAIGN_CREATION_SUCCESS, + properties = mapOf( + AnalyticsTracker.KEY_BLAZE_CAMPAIGN_TYPE to when { + navArgs.campaignDetails.budget.isEndlessCampaign -> + AnalyticsTracker.VALUE_EVERGREEN_CAMPAIGN + else -> AnalyticsTracker.VALUE_START_END_CAMPAIGN + } + ) + ) dashboardRepository.updateWidgetVisibility(type = DashboardWidget.Type.ONBOARDING, isVisible = true) triggerEvent(NavigateToStartingScreenWithSuccessBottomSheet) }, @@ -150,8 +161,23 @@ class BlazeCampaignPaymentSummaryViewModel @Inject constructor( } } + private fun getBudgetDisplayValue(currencyFormatter: CurrencyFormatter): String { + val formattedBudget = currencyFormatter.formatCurrency( + amount = navArgs.campaignDetails.budget.totalBudget.toBigDecimal(), + currencyCode = navArgs.campaignDetails.budget.currencyCode + ) + return when { + navArgs.campaignDetails.budget.isEndlessCampaign -> resourceProvider.getString( + R.string.blaze_campaign_budget_weekly_spending, + formattedBudget + ) + + else -> formattedBudget + } + } + data class ViewState( - val budgetFormatted: String, + val displayBudget: String, val paymentMethodsState: PaymentMethodsState, private val selectedPaymentMethodId: String?, val campaignCreationState: CampaignCreationState? = null 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 f57accd88c3..9865dff0cbd 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 @@ -380,7 +380,8 @@ private fun CampaignPropertyItem( Column( Modifier .padding(end = 16.dp) - .weight(1f) + .weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp) ) { Text( text = item.displayTitle, 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 d0060ca235a..25c770092c3 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 @@ -9,6 +9,7 @@ import com.woocommerce.android.analytics.AnalyticsEvent.BLAZE_CREATION_EDIT_AD_T import com.woocommerce.android.analytics.AnalyticsEvent.BLAZE_CREATION_FORM_DISPLAYED import com.woocommerce.android.analytics.AnalyticsTracker import com.woocommerce.android.analytics.AnalyticsTrackerWrapper +import com.woocommerce.android.extensions.formatToLocalizedMedium import com.woocommerce.android.extensions.formatToMMMdd import com.woocommerce.android.support.help.HelpOrigin import com.woocommerce.android.ui.blaze.BlazeRepository @@ -165,7 +166,13 @@ class BlazeCampaignCreationPreviewViewModel @Inject constructor( analyticsTrackerWrapper.track( stat = BLAZE_CREATION_CONFIRM_DETAILS_TAPPED, properties = mapOf( - AnalyticsTracker.KEY_BLAZE_IS_AI_CONTENT to isAdContentGeneratedByAi(campaignDetails.value) + AnalyticsTracker.KEY_BLAZE_IS_AI_CONTENT to isAdContentGeneratedByAi(campaignDetails.value), + AnalyticsTracker.KEY_BLAZE_CAMPAIGN_TYPE to when { + campaignDetails.value?.budget?.isEndlessCampaign == true -> + AnalyticsTracker.VALUE_EVERGREEN_CAMPAIGN + + else -> AnalyticsTracker.VALUE_START_END_CAMPAIGN + } ) ) campaignDetails.value?.let { @@ -315,12 +322,20 @@ class BlazeCampaignCreationPreviewViewModel @Inject constructor( totalBudget.toBigDecimal(), currencyCode ) - val duration = resourceProvider.getString( - R.string.blaze_campaign_preview_days_duration, - durationInDays, - startDate.formatToMMMdd() - ) - return "$totalBudgetWithCurrency, $duration" + return when { + isEndlessCampaign -> resourceProvider.getString( + R.string.blaze_campaign_preview_days_duration_endless, + totalBudgetWithCurrency, + startDate.formatToLocalizedMedium() + ) + + else -> + "$totalBudgetWithCurrency, " + resourceProvider.getString( + R.string.blaze_campaign_preview_days_duration, + durationInDays, + startDate.formatToMMMdd() + ) + } } data class CampaignPreviewUiState( diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/notification/AbandonedCampaignReminder.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/notification/AbandonedCampaignReminder.kt new file mode 100644 index 00000000000..4fcf16521ca --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/notification/AbandonedCampaignReminder.kt @@ -0,0 +1,30 @@ +package com.woocommerce.android.ui.blaze.notification + +import com.woocommerce.android.AppPrefsWrapper +import com.woocommerce.android.notifications.local.LocalNotification.BlazeAbandonedCampaignReminderNotification +import com.woocommerce.android.notifications.local.LocalNotificationScheduler +import com.woocommerce.android.tools.SelectedSite +import javax.inject.Inject + +/** + * A utility class that schedules a reminder after a campaign is abandoned. + */ +class AbandonedCampaignReminder @Inject constructor( + private val selectedSite: SelectedSite, + private val appPrefsWrapper: AppPrefsWrapper, + private val localNotificationScheduler: LocalNotificationScheduler, +) { + private val notification + get() = BlazeAbandonedCampaignReminderNotification(selectedSite.get().siteId) + + fun scheduleReminderIfNeeded() { + if (!appPrefsWrapper.getBlazeCampaignCreated() && !appPrefsWrapper.isBlazeAbandonedCampaignReminderShown) { + localNotificationScheduler.scheduleNotification(notification) + } + } + + fun setBlazeCampaignCreated() { + appPrefsWrapper.setBlazeCampaignCreated() + localNotificationScheduler.cancelScheduledNotification(notification.type) + } +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/notification/BlazeCampaignsObserver.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/notification/BlazeCampaignsObserver.kt index 37f07d16977..40f6e84a014 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/notification/BlazeCampaignsObserver.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/notification/BlazeCampaignsObserver.kt @@ -4,15 +4,18 @@ import com.woocommerce.android.AppPrefsWrapper import com.woocommerce.android.extensions.daysLater import com.woocommerce.android.notifications.local.LocalNotification.BlazeNoCampaignReminderNotification import com.woocommerce.android.notifications.local.LocalNotificationScheduler +import com.woocommerce.android.notifications.local.LocalNotificationType import com.woocommerce.android.tools.SelectedSite +import com.woocommerce.android.ui.blaze.CampaignStatusUi import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterNotNull import org.wordpress.android.fluxc.model.SiteModel -import org.wordpress.android.fluxc.persistence.blaze.BlazeCampaignsDao +import org.wordpress.android.fluxc.model.blaze.BlazeCampaignModel import org.wordpress.android.fluxc.store.blaze.BlazeCampaignsStore import java.util.Calendar +import java.util.concurrent.TimeUnit import javax.inject.Inject /** @@ -28,7 +31,7 @@ class BlazeCampaignsObserver @Inject constructor( suspend fun observeAndScheduleNotifications() { selectedSite.observe() .filterNotNull() - .filter { !appPrefsWrapper.getBlazeNoCampaignReminderShown(it.siteId) } + .filter { !appPrefsWrapper.isBlazeNoCampaignReminderShown } .distinctUntilChanged { old, new -> new.id == old.id } .collectLatest { observeBlazeCampaigns(it) } } @@ -36,29 +39,57 @@ class BlazeCampaignsObserver @Inject constructor( private suspend fun observeBlazeCampaigns(site: SiteModel) { blazeCampaignsStore.observeBlazeCampaigns(site) .filter { it.isNotEmpty() } - .collectLatest { scheduleNotification(it) } + .collectLatest { processCampaigns(it) } } - private fun scheduleNotification(campaigns: List) { + private fun processCampaigns(campaigns: List) { if (campaigns.isEmpty()) { // There are no campaigns. Skip scheduling the notification. return + } else if (hasActiveEndlessCampaigns(campaigns)) { + appPrefsWrapper.removeBlazeFirstTimeWithoutCampaign() + localNotificationScheduler.cancelScheduledNotification(LocalNotificationType.BLAZE_NO_CAMPAIGN_REMINDER) + } else if (campaigns.any { CampaignStatusUi.isActive(it.uiStatus) }) { + // There are active limited campaigns. + val latestEndTime = getLatestEndTimeOfActiveLimitedCampaigns(campaigns) + scheduleNotification(latestEndTime) + } else if (!appPrefsWrapper.existsBlazeFirstTimeWithoutCampaign() || + appPrefsWrapper.blazeFirstTimeWithoutCampaign > Calendar.getInstance().time.time + ) { + scheduleNotification(Calendar.getInstance().time.time) } + } - val delayForNotification = calculateDelayForNotification(campaigns) + private fun hasActiveEndlessCampaigns(campaigns: List) = campaigns.any { + it.isEndlessCampaign && CampaignStatusUi.isActive(it.uiStatus) + } - localNotificationScheduler.scheduleNotification( - BlazeNoCampaignReminderNotification(selectedSite.get().siteId, delayForNotification) - ) + private fun getLatestEndTimeOfActiveLimitedCampaigns(campaigns: List): Long { + val activeLimitedCampaigns = campaigns.filter { + !it.isEndlessCampaign && CampaignStatusUi.isActive(it.uiStatus) + } + return activeLimitedCampaigns.maxOf { it.startTime.daysLater(it.durationInDays) }.time } - private fun calculateDelayForNotification(campaigns: List): Long { - val latestEndTime = campaigns.maxOf { it.startTime.daysLater(it.durationInDays) } - val notificationTime = latestEndTime.daysLater(DAYS_DURATION_NO_CAMPAIGN_REMINDER_NOTIFICATION) - return notificationTime.time - Calendar.getInstance().time.time + private fun scheduleNotification(firstTimeWithoutCampaign: Long) { + if (appPrefsWrapper.blazeFirstTimeWithoutCampaign == firstTimeWithoutCampaign) { + // There is already a scheduled notification for firstTimeWithoutCampaign. + return + } + appPrefsWrapper.blazeFirstTimeWithoutCampaign = firstTimeWithoutCampaign + + val notificationTime = firstTimeWithoutCampaign + + TimeUnit.DAYS.toMillis(DAYS_DURATION_NO_CAMPAIGN_REMINDER_NOTIFICATION) + + val notification = BlazeNoCampaignReminderNotification( + siteId = selectedSite.get().siteId, + delay = notificationTime - Calendar.getInstance().time.time + ) + + localNotificationScheduler.scheduleNotification(notification) } companion object { - const val DAYS_DURATION_NO_CAMPAIGN_REMINDER_NOTIFICATION = 30 + private const val DAYS_DURATION_NO_CAMPAIGN_REMINDER_NOTIFICATION = 30L } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/common/wpcomwebview/WPComWebViewScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/common/wpcomwebview/WPComWebViewScreen.kt index a9e008e94f0..eabef5c24d8 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/common/wpcomwebview/WPComWebViewScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/common/wpcomwebview/WPComWebViewScreen.kt @@ -14,7 +14,7 @@ import com.woocommerce.android.R import com.woocommerce.android.ui.common.wpcomwebview.WPComWebViewViewModel.DisplayMode.MODAL import com.woocommerce.android.ui.common.wpcomwebview.WPComWebViewViewModel.DisplayMode.REGULAR import com.woocommerce.android.ui.compose.component.Toolbar -import com.woocommerce.android.ui.compose.component.WCWebView +import com.woocommerce.android.ui.compose.component.web.WCWebView import org.wordpress.android.fluxc.network.UserAgent @Composable diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/compose/component/ExpandableTopBanner.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/compose/component/ExpandableTopBanner.kt new file mode 100644 index 00000000000..4bdd3ad03c5 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/compose/component/ExpandableTopBanner.kt @@ -0,0 +1,127 @@ +package com.woocommerce.android.ui.compose.component + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.Card +import androidx.compose.material.ContentAlpha +import androidx.compose.material.Icon +import androidx.compose.material.LocalContentColor +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Campaign +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.woocommerce.android.R +import com.woocommerce.android.ui.compose.preview.LightDarkThemePreviews +import com.woocommerce.android.ui.compose.theme.WooThemeWithBackground + +@Composable +fun ExpandableTopBanner( + title: String, + message: String, + buttons: @Composable RowScope.() -> Unit, + modifier: Modifier = Modifier, + expandedByDefault: Boolean = false, +) { + Card( + shape = RectangleShape, + modifier = modifier + ) { + var isExpanded by rememberSaveable { mutableStateOf(expandedByDefault) } + + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable { isExpanded = !isExpanded } + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Icon( + imageVector = Icons.Default.Campaign, + contentDescription = null, + tint = MaterialTheme.colors.primary + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = title, + style = MaterialTheme.typography.subtitle1, + ) + Spacer(modifier = Modifier.weight(1f)) + Icon( + imageVector = if (isExpanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, + contentDescription = null, + tint = MaterialTheme.colors.primary + ) + } + + AnimatedVisibility(visible = isExpanded) { + Text( + text = message, + style = MaterialTheme.typography.body2, + color = LocalContentColor.current.copy(alpha = ContentAlpha.medium), + modifier = Modifier.padding(start = 48.dp, end = 16.dp) + ) + } + + Row( + modifier = Modifier + .align(Alignment.End) + .padding(horizontal = 16.dp) + ) { + buttons() + } + } + } +} + +@Composable +fun ExpandableTopBanner( + title: String, + message: String, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + expandedByDefault: Boolean = false, +) { + ExpandableTopBanner( + title = title, + message = message, + buttons = { + WCTextButton( + onClick = onDismiss, + ) { + Text(stringResource(id = R.string.dismiss)) + } + }, + modifier = modifier, + expandedByDefault = expandedByDefault, + ) +} + +@Composable +@LightDarkThemePreviews +private fun ExpandableTopBannerPreview() { + WooThemeWithBackground { + ExpandableTopBanner( + title = "Title", + message = "Message", + onDismiss = {}, + ) + } +} 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 index d00e3354df0..08877698aaa 100644 --- 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 @@ -1,9 +1,6 @@ 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 @@ -26,6 +23,7 @@ import androidx.compose.material.contentColorFor import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -39,6 +37,7 @@ import androidx.compose.ui.res.colorResource import androidx.compose.ui.unit.Dp import androidx.core.view.WindowCompat import com.woocommerce.android.R +import com.woocommerce.android.extensions.findActivity /* * This is a custom implementation of the ModalBottomSheetLayout that fixes the scrim color of the status bar @@ -83,7 +82,7 @@ fun ModalStatusBarBottomSheetLayout( var statusBarColor by remember { mutableStateOf(Color.Transparent) } val backgroundColor = remember { val typedValue = TypedValue() - if (context.findActivity().theme.resolveAttribute(attr.windowBackground, typedValue, true)) { + if (context.findActivity()?.theme?.resolveAttribute(attr.windowBackground, typedValue, true) == true) { Color(typedValue.data) } else { sheetBackgroundColor @@ -106,12 +105,17 @@ fun ModalStatusBarBottomSheetLayout( } } - val window = remember { context.findActivity().window } + val window = remember { context.findActivity()?.window } + if (window == null) return@ModalBottomSheetLayout + val originalNavigationBarColor = remember { window.navigationBarColor } - if (sheetState.currentValue != Hidden) { - window.navigationBarColor = sheetBackgroundColor.toArgb() - } else { - window.navigationBarColor = originalNavigationBarColor + + LaunchedEffect(sheetState.currentValue) { + if (sheetState.currentValue != Hidden) { + window.navigationBarColor = sheetBackgroundColor.toArgb() + } else { + window.navigationBarColor = originalNavigationBarColor + } } DisposableEffect(Unit) { @@ -135,12 +139,3 @@ private fun scrimColor() = if (isSystemInDarkTheme()) { } else { ModalBottomSheetDefaults.scrimColor } - -fun Context.findActivity(): Activity { - var context = this - while (context is ContextWrapper) { - if (context is Activity) return context - context = context.baseContext - } - error("Permissions should be called in the context of an Activity") -} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/compose/component/OverflowMenu.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/compose/component/OverflowMenu.kt index 428dbc1a59a..e13f8ea6199 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/compose/component/OverflowMenu.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/compose/component/OverflowMenu.kt @@ -7,6 +7,8 @@ import androidx.compose.material.DropdownMenu import androidx.compose.material.DropdownMenuItem import androidx.compose.material.Icon import androidx.compose.material.IconButton +import androidx.compose.material.LocalContentColor +import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.material.icons.Icons.Outlined import androidx.compose.material.icons.outlined.MoreVert @@ -30,7 +32,8 @@ fun WCOverflowMenu( onSelected: (T) -> Unit, modifier: Modifier = Modifier, mapper: @Composable (T) -> String = { it.toString() }, - tint: Color = Color.Black + itemColor: @Composable (T) -> Color = { LocalContentColor.current }, + tint: Color = MaterialTheme.colors.primary ) { var showMenu by remember { mutableStateOf(false) } Box(modifier = modifier) { @@ -57,7 +60,10 @@ fun WCOverflowMenu( onSelected(item) } ) { - Text(mapper(item)) + Text( + text = mapper(item), + color = itemColor(item) + ) } if (index < items.size - 1) { Spacer(modifier = Modifier.height(dimensionResource(id = dimen.minor_100))) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/compose/component/Switch.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/compose/component/Switch.kt index c572832f607..94004dd1b1b 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/compose/component/Switch.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/compose/component/Switch.kt @@ -1,6 +1,7 @@ package com.woocommerce.android.ui.compose.component import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.material.MaterialTheme @@ -12,11 +13,25 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import com.woocommerce.android.R @Composable private fun defaultSwitchColors(): SwitchColors = SwitchDefaults.colors(checkedThumbColor = MaterialTheme.colors.primary) +@Composable +fun BottomSheetSwitchColors(): SwitchColors = + SwitchDefaults.colors( + checkedThumbColor = colorResource(id = R.color.color_primary), + checkedTrackColor = colorResource(id = R.color.color_primary), + uncheckedThumbColor = + when { + isSystemInDarkTheme() -> colorResource(id = R.color.color_on_surface_medium) + else -> MaterialTheme.colors.onSurface + } + ) + @Composable fun WCSwitch( checked: Boolean, diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/compose/component/WebChromeClientWithImageChooser.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/compose/component/WebChromeClientWithImageChooser.kt deleted file mode 100644 index 530184c77b7..00000000000 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/compose/component/WebChromeClientWithImageChooser.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.woocommerce.android.ui.compose.component - -import android.content.ActivityNotFoundException -import android.net.Uri -import android.webkit.ValueCallback -import android.webkit.WebChromeClient -import android.webkit.WebView -import androidx.activity.result.ActivityResultRegistry -import androidx.activity.result.contract.ActivityResultContracts.GetContent -import com.woocommerce.android.util.WooLog -import com.woocommerce.android.util.WooLog.T - -class WebChromeClientWithImageChooser( - registry: ActivityResultRegistry, - private val onProgressChanged: (Int) -> Unit -) : WebChromeClient() { - companion object { - private const val FILE_CHOOSER_RESULT_KEY = "file_chooser_result_key" - } - - private lateinit var fileChooserValueCallback: ValueCallback> - private val getImageContent = registry.register(FILE_CHOOSER_RESULT_KEY, GetContent()) { uri -> - uri?.let { - fileChooserValueCallback.onReceiveValue(arrayOf(uri)) - } ?: fileChooserValueCallback.onReceiveValue(null) - } - - override fun onShowFileChooser( - webView: WebView?, - filePathCallback: ValueCallback>, - fileChooserParams: FileChooserParams - ): Boolean { - try { - fileChooserValueCallback = filePathCallback - getImageContent.launch("image/*") - } catch (e: ActivityNotFoundException) { - WooLog.d( - T.UTILS, - "WebChromeClientWithImageChooser. No activity found to handle image selection: ${e.message}" - ) - } - return true - } - - override fun onProgressChanged(view: WebView?, newProgress: Int) { - onProgressChanged(newProgress) - } -} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/compose/component/aztec/AztecEditor.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/compose/component/aztec/AztecEditor.kt new file mode 100644 index 00000000000..788b0d7517c --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/compose/component/aztec/AztecEditor.kt @@ -0,0 +1,451 @@ +package com.woocommerce.android.ui.compose.component.aztec + +import android.content.Context +import android.view.LayoutInflater +import android.view.View.OnFocusChangeListener +import android.view.ViewGroup +import android.widget.EditText +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.isImeVisible +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalTextInputService +import androidx.compose.ui.text.InternalTextApi +import androidx.compose.ui.text.input.TextInputService +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.widget.doAfterTextChanged +import com.google.android.material.textfield.TextInputLayout +import com.woocommerce.android.databinding.ViewAztecBinding +import com.woocommerce.android.databinding.ViewAztecOutlinedBinding +import com.woocommerce.android.ui.compose.theme.WooThemeWithBackground +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import org.wordpress.aztec.Aztec +import org.wordpress.aztec.AztecText +import org.wordpress.aztec.ITextFormat +import org.wordpress.aztec.glideloader.GlideImageLoader +import org.wordpress.aztec.source.SourceViewEditText +import org.wordpress.aztec.toolbar.IAztecToolbar +import org.wordpress.aztec.toolbar.IAztecToolbarClickListener + +/** + * An Aztec editor that can be used in Compose, with an outlined style. + * + * @param content The content of the editor + * @param onContentChanged A callback that will be called when the content of the editor changes + * @param modifier The modifier to apply to the editor + * @param label The label to display above the editor + * @param minLines The minimum number of lines the editor should have + * @param maxLines The maximum number of lines the editor should have + * @param calypsoMode Whether the editor should be in calypso mode, for more information on calypso mode see https://github.com/wordpress-mobile/AztecEditor-Android/pull/309 + */ +@Composable +fun OutlinedAztecEditor( + content: String, + onContentChanged: (String) -> Unit, + modifier: Modifier = Modifier, + label: String? = null, + minLines: Int = 1, + maxLines: Int = Int.MAX_VALUE, + calypsoMode: Boolean = false +) { + val state = rememberAztecEditorState(initialContent = content) + val contentState by rememberUpdatedState(content) + + LaunchedEffect(Unit) { + snapshotFlow { contentState } + .onEach { state.updateContent(it) } + .launchIn(this) + + snapshotFlow { state.content } + .onEach { onContentChanged(it) } + .launchIn(this) + } + + OutlinedAztecEditor( + state = state, + modifier = modifier, + label = label, + minLines = minLines, + maxLines = maxLines, + calypsoMode = calypsoMode + ) +} + +/** + * An Aztec editor that can be used in Compose, with an outlined style. + * + * @param state The state of the editor, see [rememberAztecEditorState] + * @param modifier The modifier to apply to the editor + * @param label The label to display above the editor + * @param minLines The minimum number of lines the editor should have + * @param maxLines The maximum number of lines the editor should have + * @param calypsoMode Whether the editor should be in calypso mode, for more information on calypso mode see https://github.com/wordpress-mobile/AztecEditor-Android/pull/309 + */ +@Composable +fun OutlinedAztecEditor( + state: AztecEditorState, + modifier: Modifier = Modifier, + label: String? = null, + minLines: Int = 1, + maxLines: Int = Int.MAX_VALUE, + calypsoMode: Boolean = false +) { + InternalAztecEditor( + state = state, + aztecViewsProvider = { context -> + val binding = ViewAztecOutlinedBinding.inflate(LayoutInflater.from(context)).apply { + visualEditor.background = null + sourceEditor.background = null + } + + AztecViewsHolder( + layout = binding.root, + visualEditor = binding.visualEditor, + sourceEditor = binding.sourceEditor, + toolbar = binding.toolbar + ) + }, + modifier = modifier, + label = label, + minLines = minLines, + maxLines = maxLines, + calypsoMode = calypsoMode + ) +} + +/** + * An Aztec editor that can be used in Compose. + * + * @param content The content of the editor + * @param onContentChanged A callback that will be called when the content of the editor changes + * @param modifier The modifier to apply to the editor + * @param label The label to display above the editor + * @param minLines The minimum number of lines the editor should have + * @param maxLines The maximum number of lines the editor should have + * @param calypsoMode Whether the editor should be in calypso mode, for more information on calypso mode see https://github.com/wordpress-mobile/AztecEditor-Android/pull/309 + */ +@Composable +fun AztecEditor( + content: String, + onContentChanged: (String) -> Unit, + modifier: Modifier = Modifier, + label: String? = null, + minLines: Int = 1, + maxLines: Int = Int.MAX_VALUE, + calypsoMode: Boolean = false +) { + val state = rememberAztecEditorState(initialContent = content) + val contentState by rememberUpdatedState(content) + + LaunchedEffect(Unit) { + snapshotFlow { contentState } + .onEach { state.updateContent(it) } + .launchIn(this) + + snapshotFlow { state.content } + .onEach { onContentChanged(it) } + .launchIn(this) + } + + AztecEditor( + state = state, + modifier = modifier, + label = label, + minLines = minLines, + maxLines = maxLines, + calypsoMode = calypsoMode + ) +} + +/** + * An Aztec editor that can be used in Compose. + * + * @param state The state of the editor, see [rememberAztecEditorState] + * @param modifier The modifier to apply to the editor + * @param label The label to display above the editor + * @param minLines The minimum number of lines the editor should have + * @param maxLines The maximum number of lines the editor should have + * @param calypsoMode Whether the editor should be in calypso mode, for more information on calypso mode see https://github.com/wordpress-mobile/AztecEditor-Android/pull/309 + */ +@Composable +fun AztecEditor( + state: AztecEditorState, + modifier: Modifier = Modifier, + label: String? = null, + minLines: Int = 1, + maxLines: Int = Int.MAX_VALUE, + calypsoMode: Boolean = false +) { + InternalAztecEditor( + state = state, + aztecViewsProvider = { context -> + val binding = ViewAztecBinding.inflate(LayoutInflater.from(context)) + + AztecViewsHolder( + layout = binding.root, + visualEditor = binding.visualEditor, + sourceEditor = binding.sourceEditor, + toolbar = binding.toolbar + ) + }, + modifier = modifier, + label = label, + minLines = minLines, + maxLines = maxLines, + calypsoMode = calypsoMode + ) +} + +@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class) +@Composable +private fun InternalAztecEditor( + state: AztecEditorState, + aztecViewsProvider: (context: Context) -> AztecViewsHolder, + modifier: Modifier = Modifier, + label: String? = null, + minLines: Int = 1, + maxLines: Int = Int.MAX_VALUE, + calypsoMode: Boolean = false +) { + val localContext = LocalContext.current + val bringIntoViewRequester = remember { BringIntoViewRequester() } + val textInputService = LocalTextInputService.current + + val viewsHolder = remember(localContext) { aztecViewsProvider(localContext) } + val listener = remember { createToolbarListener { state.toggleHtmlEditor() } } + val aztec = remember(localContext) { + Aztec.with(viewsHolder.visualEditor, viewsHolder.sourceEditor, viewsHolder.toolbar, listener) + .setImageGetter(GlideImageLoader(localContext)) + } + var sourceEditorMinHeight by rememberSaveable { mutableStateOf(0) } + + // Toggle the editor mode when the state changes + LaunchedEffect(Unit) { + snapshotFlow { state.isHtmlEditorEnabled } + .drop(1) // Skip the initial value to avoid toggling the editor when it's first created + .collect { aztec.toolbar.toggleEditorMode() } + } + + // Update the content of the editor when the state changes + LaunchedEffect(state.content) { + if (state.isHtmlEditorEnabled) { + if (aztec.visualEditor.toHtml() != state.content) { + aztec.visualEditor.fromHtml(state.content) + } + } else { + if (aztec.sourceEditor?.getPureHtml() != state.content) { + aztec.sourceEditor?.displayStyledAndFormattedHtml(state.content) + } + } + } + + val focusState = remember { MutableStateFlow(false) } + val isImeVisible = rememberUpdatedState(WindowInsets.isImeVisible) + + LaunchedEffect(Unit) { + handleFocus( + focusState = focusState, + imeVisibility = isImeVisible, + bringIntoViewRequester = bringIntoViewRequester, + textInputService = textInputService + ) + } + + AndroidView( + factory = { + // Set initial content + aztec.visualEditor.fromHtml(state.content) + aztec.sourceEditor?.displayStyledAndFormattedHtml(state.content) + + aztec.visualEditor.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + // Because the editors could have different number of lines, we don't set the minLines + // of the source editor, so we set the minHeight instead to match the visual editor + sourceEditorMinHeight = aztec.visualEditor.height + } + + aztec.visualEditor.doAfterTextChanged { + if (!state.isHtmlEditorEnabled) return@doAfterTextChanged + state.updateContent(aztec.visualEditor.toHtml()) + } + aztec.sourceEditor?.doAfterTextChanged { + val sourceEditor = aztec.sourceEditor + if (state.isHtmlEditorEnabled || sourceEditor == null) return@doAfterTextChanged + state.updateContent(sourceEditor.getPureHtml()) + } + + val focusChangeListener = OnFocusChangeListener { _, focused -> + focusState.value = focused + } + aztec.visualEditor.onFocusChangeListener = focusChangeListener + aztec.sourceEditor?.onFocusChangeListener = focusChangeListener + + viewsHolder.layout + }, + update = { + if (aztec.visualEditor.isInCalypsoMode != calypsoMode) { + aztec.visualEditor.isInCalypsoMode = calypsoMode + aztec.sourceEditor?.setCalypsoMode(calypsoMode) + } + + if (sourceEditorMinHeight != aztec.sourceEditor?.minHeight) { + aztec.sourceEditor?.minHeight = sourceEditorMinHeight + } + if (minLines != -1 && minLines != aztec.visualEditor.minLines) { + aztec.visualEditor.minLines = minLines + } + if (maxLines != Int.MAX_VALUE && maxLines != aztec.visualEditor.maxLines) { + aztec.visualEditor.maxLines = maxLines + aztec.sourceEditor?.maxLines = maxLines + } + + if (aztec.visualEditor.label != label) { + aztec.visualEditor.label = label + aztec.sourceEditor?.label = label + } + }, + modifier = modifier + .bringIntoViewRequester(bringIntoViewRequester) + ) +} + +@OptIn(ExperimentalFoundationApi::class, InternalTextApi::class) +private suspend fun handleFocus( + focusState: StateFlow, + imeVisibility: State, + bringIntoViewRequester: BringIntoViewRequester, + textInputService: TextInputService? +) = coroutineScope { + launch(Dispatchers.Main.immediate) { + // In Compose, text fields use input sessions to manage the input, when focus moves to a non-input field + // the session is closed and this hides the keyboard. + // This behavior doesn't work well when focus moves to a non-Compose input field, like the Aztec editor. + // see: https://issuetracker.google.com/issues/318530776 and https://issuetracker.google.com/issues/363544352 + // To get around the issue, we are using the internal API to start/stop the input session. + // This is safe to do because even if the API changes, we can remove the logic temporarily until the bug is + // fixed, as this bug is not critical for the editor. + focusState.collect { + if (it) { + textInputService?.startInput() + } else { + textInputService?.stopInput() + } + } + } + + launch { + // Use collectLatest to make sure the nested collection is cancelled when the focus state changes + focusState.collectLatest { hasFocus -> + if (!hasFocus) return@collectLatest + bringIntoViewRequester.bringIntoView() + + snapshotFlow { imeVisibility.value } + .filter { it } + .collect { bringIntoViewRequester.bringIntoView() } + } + } +} + +private fun createToolbarListener(onHtmlButtonClicked: () -> Unit) = object : IAztecToolbarClickListener { + override fun onToolbarCollapseButtonClicked() = Unit + + override fun onToolbarExpandButtonClicked() = Unit + + override fun onToolbarFormatButtonClicked(format: ITextFormat, isKeyboardShortcut: Boolean) = Unit + + override fun onToolbarHeadingButtonClicked() = Unit + + override fun onToolbarHtmlButtonClicked() { + onHtmlButtonClicked() + } + + override fun onToolbarListButtonClicked() = Unit + + override fun onToolbarMediaButtonClicked(): Boolean = false +} + +/** + * Helper to set the label of an [EditText] depending on whether it is wrapped in a [TextInputLayout] + */ +private var EditText.label + get() = (parent?.parent as? TextInputLayout)?.hint ?: hint + set(value) { + (parent?.parent as? TextInputLayout)?.let { it.hint = value } ?: run { + hint = value + } + } + +private data class AztecViewsHolder( + val layout: ViewGroup, + val visualEditor: AztecText, + val sourceEditor: SourceViewEditText, + val toolbar: IAztecToolbar +) + +@Composable +@Preview +private fun OutlinedAztecEditorPreview() { + val state = rememberAztecEditorState("something") + + WooThemeWithBackground { + Column { + OutlinedAztecEditor( + state = state, + label = "Label", + minLines = 5, + modifier = Modifier.padding(16.dp) + ) + + TextButton(onClick = { state.toggleHtmlEditor() }) { + Text("Toggle Html Mode") + } + } + } +} + +@Composable +@Preview +private fun AztecEditorPreview() { + val state = rememberAztecEditorState("") + + WooThemeWithBackground { + Column { + AztecEditor( + state = state, + label = "Label", + ) + + TextButton(onClick = { state.toggleHtmlEditor() }) { + Text("Toggle Html Mode") + } + } + } +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/compose/component/aztec/AztecEditorState.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/compose/component/aztec/AztecEditorState.kt new file mode 100644 index 00000000000..b2979f7ed07 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/compose/component/aztec/AztecEditorState.kt @@ -0,0 +1,51 @@ +package com.woocommerce.android.ui.compose.component.aztec + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue + +class AztecEditorState( + initialContent: String +) { + var content by mutableStateOf(initialContent) + private set + + var isHtmlEditorEnabled by mutableStateOf(true) + private set + + fun updateContent(newContent: String) { + content = newContent + } + + fun toggleHtmlEditor() { + isHtmlEditorEnabled = !isHtmlEditorEnabled + } + + companion object { + val Saver = Saver( + save = { arrayListOf(it.content, it.isHtmlEditorEnabled) }, + restore = { + val list = it as List<*> + AztecEditorState(list[0] as String).apply { + isHtmlEditorEnabled = list[1] as Boolean + } + } + ) + } +} + +/** + * Remember a [AztecEditorState] that can be used to manage the state of an Aztec editor. + * The state will be saved to the saved state to survive process death. + * + * @param initialContent The initial content of the editor + */ +@Composable +fun rememberAztecEditorState( + initialContent: String +): AztecEditorState { + return rememberSaveable(saver = AztecEditorState.Saver) { AztecEditorState(initialContent) } +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/compose/component/web/ComposeWebChromeClient.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/compose/component/web/ComposeWebChromeClient.kt new file mode 100644 index 00000000000..bfb7600ade9 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/compose/component/web/ComposeWebChromeClient.kt @@ -0,0 +1,51 @@ +package com.woocommerce.android.ui.compose.component.web + +import android.net.Uri +import android.webkit.ValueCallback +import android.webkit.WebChromeClient +import android.webkit.WebView +import androidx.activity.ComponentActivity +import androidx.activity.result.contract.ActivityResultContracts +import com.woocommerce.android.extensions.findActivity + +open class ComposeWebChromeClient : WebChromeClient() { + var onProgressChanged: (Int) -> Unit = {} + + /** + * This method is called when the user chooses a file for file upload. + * + * Important: this implementation doesn't handle configuration changes, during which the flow + * will just be interrupted. + * Our WebView implementation doesn't survive configuration changes either, so this is fine for now. + * + * If we need to handle configuration changes, we'll need the following: + * - Find a way to keep the WebView instance across configuration changes. + * - Store the [filePathCallback] during configuration changes (possibly in a ViewModel). + * - Move the ActivityResultLauncher outside of this method to make sure it's called after the configuration change. + */ + override fun onShowFileChooser( + webView: WebView, + filePathCallback: ValueCallback>, + fileChooserParams: FileChooserParams + ): Boolean { + val activity = webView.context.findActivity() as? ComponentActivity ?: return false + + val contract = ActivityResultContracts.StartActivityForResult() + val launcher = activity.activityResultRegistry.register( + "WebViewChooser", + contract + ) { result -> + val uris = FileChooserParams.parseResult(result.resultCode, result.data) + filePathCallback.onReceiveValue(uris) + } + + val intent = fileChooserParams.createIntent() + launcher.launch(intent) + + return true + } + + override fun onProgressChanged(view: WebView?, newProgress: Int) { + onProgressChanged(newProgress) + } +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/compose/component/WebView.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/compose/component/web/WebView.kt similarity index 90% rename from WooCommerce/src/main/kotlin/com/woocommerce/android/ui/compose/component/WebView.kt rename to WooCommerce/src/main/kotlin/com/woocommerce/android/ui/compose/component/web/WebView.kt index 4abba3481ca..5a440cc8ff9 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/compose/component/WebView.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/compose/component/web/WebView.kt @@ -1,9 +1,8 @@ -package com.woocommerce.android.ui.compose.component +package com.woocommerce.android.ui.compose.component.web import android.annotation.SuppressLint import android.view.ViewGroup import android.webkit.CookieManager -import android.webkit.WebChromeClient import android.webkit.WebResourceError import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse @@ -11,7 +10,6 @@ import android.webkit.WebStorage import android.webkit.WebView import android.webkit.WebViewClient import androidx.activity.compose.BackHandler -import androidx.activity.result.ActivityResultRegistry import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth @@ -33,8 +31,8 @@ import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.viewinterop.AndroidView import com.woocommerce.android.R.dimen import com.woocommerce.android.ui.common.wpcomwebview.WPComWebViewAuthenticator -import com.woocommerce.android.ui.compose.component.WebViewProgressIndicator.Circular -import com.woocommerce.android.ui.compose.component.WebViewProgressIndicator.Linear +import com.woocommerce.android.ui.compose.component.web.WebViewProgressIndicator.Circular +import com.woocommerce.android.ui.compose.component.web.WebViewProgressIndicator.Linear import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.onCompletion import org.wordpress.android.fluxc.network.UserAgent @@ -52,7 +50,7 @@ fun WCWebView( captureBackPresses: Boolean = true, wpComAuthenticator: WPComWebViewAuthenticator? = null, webViewNavigator: WebViewNavigator = rememberWebViewNavigator(), - activityRegistry: ActivityResultRegistry? = null, + webChromeClient: ComposeWebChromeClient = remember { ComposeWebChromeClient() }, loadWithOverviewMode: Boolean = false, useWideViewPort: Boolean = false, isJavaScriptEnabled: Boolean = true, @@ -135,15 +133,8 @@ fun WCWebView( } } - if (activityRegistry != null) { - this.webChromeClient = - WebChromeClientWithImageChooser(activityRegistry) { newProgress -> progress = newProgress } - } else { - this.webChromeClient = object : WebChromeClient() { - override fun onProgressChanged(view: WebView?, newProgress: Int) { - progress = newProgress - } - } + this.webChromeClient = webChromeClient.apply { + onProgressChanged = { newProgress -> progress = newProgress } } if (isReadOnly) { diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/customfields/CustomFieldModels.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/customfields/CustomFieldModels.kt new file mode 100644 index 00000000000..4316a081c48 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/customfields/CustomFieldModels.kt @@ -0,0 +1,61 @@ +package com.woocommerce.android.ui.customfields + +import android.os.Parcelable +import androidx.core.util.PatternsCompat +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import org.wordpress.android.fluxc.model.metadata.WCMetaData +import org.wordpress.android.fluxc.model.metadata.WCMetaDataValue +import org.wordpress.android.util.HtmlUtils +import java.util.regex.Pattern + +typealias CustomField = WCMetaData + +@Parcelize +data class CustomFieldUiModel( + val key: String, + val value: String, + val id: Long? = null, +) : Parcelable { + constructor(customField: CustomField) : this(customField.key, customField.valueAsString, customField.id) + + val valueStrippedHtml: String + get() = HtmlUtils.fastStripHtml(value) + + @IgnoredOnParcel + val contentType: CustomFieldContentType = CustomFieldContentType.fromMetadataValue(value) + + fun toDomainModel() = CustomField( + id = id ?: 0, // Use 0 for new custom fields + key = key, + value = WCMetaDataValue.StringValue(value) // Treat all updates as string values + ) +} + +enum class CustomFieldContentType { + TEXT, + URL, + EMAIL, + PHONE; + + companion object { + fun fromMetadataValue(value: String): CustomFieldContentType { + return when { + PatternsCompat.WEB_URL.matcher(value).matches() -> URL + PatternsCompat.EMAIL_ADDRESS.matcher(value).matches() -> EMAIL + value.startsWith("tel://") || phonePattern.matcher(value).matches() -> PHONE + else -> TEXT + } + } + + private val phonePattern by lazy { + // Copied from android.util.Patterns.PHONE to make it work with tests + Pattern.compile( + // sdd = space, dot, or dash + "(\\+[0-9]+[\\- .]*)?" + // +* + "(\\([0-9]+\\)[\\- .]*)?" + // ()* + "([0-9][0-9\\- .]+[0-9])" // + + ) + } + } +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/customfields/CustomFieldsRepository.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/customfields/CustomFieldsRepository.kt new file mode 100644 index 00000000000..1af466ddc5c --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/customfields/CustomFieldsRepository.kt @@ -0,0 +1,67 @@ +package com.woocommerce.android.ui.customfields + +import com.woocommerce.android.WooException +import com.woocommerce.android.tools.SelectedSite +import com.woocommerce.android.util.WooLog +import kotlinx.coroutines.flow.Flow +import org.wordpress.android.fluxc.model.metadata.MetaDataParentItemType +import org.wordpress.android.fluxc.model.metadata.UpdateMetadataRequest +import org.wordpress.android.fluxc.store.MetaDataStore +import javax.inject.Inject + +class CustomFieldsRepository @Inject constructor( + private val selectedSite: SelectedSite, + private val metaDataStore: MetaDataStore +) { + fun observeDisplayableCustomFields( + parentItemId: Long, + ): Flow> = metaDataStore.observeDisplayableMetaData(selectedSite.get(), parentItemId) + + suspend fun getDisplayableCustomFields( + parentItemId: Long, + ): List = metaDataStore.getDisplayableMetaData(selectedSite.get(), parentItemId) + + suspend fun refreshCustomFields( + parentItemId: Long, + parentItemType: MetaDataParentItemType + ): Result { + return metaDataStore.refreshMetaData( + site = selectedSite.get(), + parentItemId = parentItemId, + parentItemType = parentItemType + ).let { + if (it.isError) { + WooLog.w(WooLog.T.CUSTOM_FIELDS, "Failed to refresh custom fields: ${it.error}") + Result.failure(WooException(it.error)) + } else { + WooLog.d(WooLog.T.CUSTOM_FIELDS, "Successfully refreshed custom fields") + Result.success(Unit) + } + } + } + + suspend fun updateCustomFields(request: UpdateMetadataRequest): Result { + return metaDataStore.updateMetaData(selectedSite.get(), request).let { + if (it.isError) { + WooLog.w(WooLog.T.CUSTOM_FIELDS, "Failed to update custom fields: ${it.error}") + Result.failure(WooException(it.error)) + } else { + WooLog.d(WooLog.T.CUSTOM_FIELDS, "Successfully updated custom fields") + Result.success(Unit) + } + } + } + + suspend fun hasDisplayableCustomFields( + parentItemId: Long, + ) = metaDataStore.hasDisplayableMetaData(selectedSite.get(), parentItemId) + + suspend fun getCustomFieldById( + parentItemId: Long, + customFieldId: Long, + ): CustomField? = metaDataStore.getMetaDataById( + site = selectedSite.get(), + parentItemId = parentItemId, + metaDataId = customFieldId + ) +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/customfields/editor/CustomFieldsEditorFragment.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/customfields/editor/CustomFieldsEditorFragment.kt new file mode 100644 index 00000000000..d871b51c30a --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/customfields/editor/CustomFieldsEditorFragment.kt @@ -0,0 +1,49 @@ +package com.woocommerce.android.ui.customfields.editor + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import com.woocommerce.android.R +import com.woocommerce.android.extensions.copyToClipboard +import com.woocommerce.android.extensions.navigateBackWithResult +import com.woocommerce.android.ui.base.BaseFragment +import com.woocommerce.android.ui.compose.composeView +import com.woocommerce.android.ui.main.AppBarStatus +import com.woocommerce.android.viewmodel.MultiLiveEvent +import dagger.hilt.android.AndroidEntryPoint +import org.wordpress.android.util.ToastUtils + +@AndroidEntryPoint +class CustomFieldsEditorFragment : BaseFragment() { + override val activityAppBarStatus: AppBarStatus = AppBarStatus.Hidden + + private val viewModel: CustomFieldsEditorViewModel by viewModels() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + return composeView { + CustomFieldsEditorScreen(viewModel) + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + handleEvents() + } + + private fun handleEvents() { + viewModel.event.observe(viewLifecycleOwner) { event -> + when (event) { + is CustomFieldsEditorViewModel.CopyContentToClipboard -> copyToClipboard(event) + is MultiLiveEvent.Event.ExitWithResult<*> -> navigateBackWithResult(event.key!!, event.data) + MultiLiveEvent.Event.Exit -> findNavController().navigateUp() + } + } + } + + private fun copyToClipboard(event: CustomFieldsEditorViewModel.CopyContentToClipboard) { + requireContext().copyToClipboard(getString(event.labelResource), event.content) + ToastUtils.showToast(requireContext(), R.string.copied_to_clipboard) + } +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/customfields/editor/CustomFieldsEditorScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/customfields/editor/CustomFieldsEditorScreen.kt new file mode 100644 index 00000000000..1670091a063 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/customfields/editor/CustomFieldsEditorScreen.kt @@ -0,0 +1,156 @@ +package com.woocommerce.android.ui.customfields.editor + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.LocalContentColor +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.woocommerce.android.R +import com.woocommerce.android.ui.compose.component.DiscardChangesDialog +import com.woocommerce.android.ui.compose.component.Toolbar +import com.woocommerce.android.ui.compose.component.WCOutlinedTextField +import com.woocommerce.android.ui.compose.component.WCOverflowMenu +import com.woocommerce.android.ui.compose.component.WCTextButton +import com.woocommerce.android.ui.compose.component.aztec.OutlinedAztecEditor +import com.woocommerce.android.ui.compose.component.getText +import com.woocommerce.android.ui.compose.preview.LightDarkThemePreviews +import com.woocommerce.android.ui.compose.theme.WooThemeWithBackground +import com.woocommerce.android.ui.customfields.CustomFieldUiModel + +@Composable +fun CustomFieldsEditorScreen(viewModel: CustomFieldsEditorViewModel) { + viewModel.state.observeAsState().value?.let { state -> + CustomFieldsEditorScreen( + state = state, + onKeyChanged = viewModel::onKeyChanged, + onValueChanged = viewModel::onValueChanged, + onDoneClicked = viewModel::onDoneClicked, + onDeleteClicked = viewModel::onDeleteClicked, + onCopyKeyClicked = viewModel::onCopyKeyClicked, + onCopyValueClicked = viewModel::onCopyValueClicked, + onBackButtonClick = viewModel::onBackClick, + ) + } +} + +@Composable +private fun CustomFieldsEditorScreen( + state: CustomFieldsEditorViewModel.UiState, + onKeyChanged: (String) -> Unit, + onValueChanged: (String) -> Unit, + onDoneClicked: () -> Unit, + onDeleteClicked: () -> Unit, + onCopyKeyClicked: () -> Unit, + onCopyValueClicked: () -> Unit, + onBackButtonClick: () -> Unit, +) { + BackHandler { onBackButtonClick() } + + Scaffold( + topBar = { + Toolbar( + title = "Custom Field", + onNavigationButtonClick = onBackButtonClick, + actions = { + if (state.showDoneButton) { + WCTextButton( + onClick = onDoneClicked, + text = stringResource(R.string.done) + ) + } + WCOverflowMenu( + items = listOfNotNull( + R.string.custom_fields_editor_copy_key, + R.string.custom_fields_editor_copy_value, + if (!state.isCreatingNewItem) R.string.delete else null, + ), + mapper = { stringResource(it) }, + itemColor = { + when (it) { + R.string.delete -> MaterialTheme.colors.error + else -> LocalContentColor.current + } + }, + onSelected = { resourceId -> + when (resourceId) { + R.string.delete -> onDeleteClicked() + R.string.custom_fields_editor_copy_key -> onCopyKeyClicked() + R.string.custom_fields_editor_copy_value -> onCopyValueClicked() + else -> error("Unhandled menu item") + } + } + ) + } + ) + }, + backgroundColor = MaterialTheme.colors.surface + ) { paddingValues -> + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(paddingValues) + .padding(16.dp) + ) { + WCOutlinedTextField( + value = state.customField.key, + onValueChange = onKeyChanged, + label = stringResource(R.string.custom_fields_editor_key_label), + helperText = state.keyErrorMessage?.getText(), + isError = state.keyErrorMessage != null, + singleLine = true + ) + + Spacer(modifier = Modifier.height(16.dp)) + + if (state.isHtml) { + OutlinedAztecEditor( + content = state.customField.value, + onContentChanged = onValueChanged, + label = stringResource(R.string.custom_fields_editor_value_label), + minLines = 5 + ) + } else { + WCOutlinedTextField( + value = state.customField.value, + onValueChange = onValueChanged, + label = stringResource(R.string.custom_fields_editor_value_label), + minLines = 5 + ) + } + } + + state.discardChangesDialogState?.let { + DiscardChangesDialog( + discardButton = it.onDiscard, + dismissButton = it.onCancel + ) + } + } +} + +@LightDarkThemePreviews +@Composable +private fun CustomFieldsEditorScreenPreview() { + WooThemeWithBackground { + CustomFieldsEditorScreen( + CustomFieldsEditorViewModel.UiState(customField = CustomFieldUiModel("key", "value")), + onKeyChanged = {}, + onValueChanged = {}, + onDoneClicked = {}, + onDeleteClicked = {}, + onCopyKeyClicked = {}, + onCopyValueClicked = {}, + onBackButtonClick = {} + ) + } +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/customfields/editor/CustomFieldsEditorViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/customfields/editor/CustomFieldsEditorViewModel.kt new file mode 100644 index 00000000000..c2172cb9d29 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/customfields/editor/CustomFieldsEditorViewModel.kt @@ -0,0 +1,172 @@ +package com.woocommerce.android.ui.customfields.editor + +import android.os.Parcelable +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.model.UiString +import com.woocommerce.android.ui.customfields.CustomFieldUiModel +import com.woocommerce.android.ui.customfields.CustomFieldsRepository +import com.woocommerce.android.viewmodel.MultiLiveEvent +import com.woocommerce.android.viewmodel.ScopedViewModel +import com.woocommerce.android.viewmodel.getNullableStateFlow +import com.woocommerce.android.viewmodel.getStateFlow +import com.woocommerce.android.viewmodel.navArgs +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize +import javax.inject.Inject + +@HiltViewModel +class CustomFieldsEditorViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val repository: CustomFieldsRepository +) : ScopedViewModel(savedStateHandle) { + companion object { + const val CUSTOM_FIELD_CREATED_RESULT_KEY = "custom_field_created" + const val CUSTOM_FIELD_UPDATED_RESULT_KEY = "custom_field_updated" + const val CUSTOM_FIELD_DELETED_RESULT_KEY = "custom_field_deleted" + } + + private val navArgs by savedStateHandle.navArgs() + + private val customFieldDraft = savedStateHandle.getStateFlow( + scope = viewModelScope, + initialValue = navArgs.customField ?: CustomFieldUiModel("", ""), + key = "customFieldDraft" + ) + private val showDiscardChangesDialog = savedStateHandle.getStateFlow( + scope = viewModelScope, + initialValue = false, + key = "showDiscardChangesDialog" + ) + private val keyErrorMessage = savedStateHandle.getNullableStateFlow( + scope = viewModelScope, + initialValue = null, + clazz = UiString::class.java, + key = "keyErrorMessage" + ) + private val storedValue = navArgs.customField + private val isHtml = storedValue?.valueStrippedHtml != storedValue?.value + + val state = combine( + customFieldDraft, + showDiscardChangesDialog.mapToState(), + keyErrorMessage + ) { customField, discardChangesDialogState, keyErrorMessage -> + UiState( + customField = customField, + hasChanges = storedValue?.key.orEmpty() != customField.key || + storedValue?.value.orEmpty() != customField.value, + isHtml = isHtml, + discardChangesDialogState = discardChangesDialogState, + keyErrorMessage = keyErrorMessage, + isCreatingNewItem = storedValue == null + ) + }.asLiveData() + + fun onKeyChanged(key: String) { + keyErrorMessage.value = if (key.startsWith("_")) { + UiString.UiStringRes(R.string.custom_fields_editor_key_error_underscore) + } else { + null + } + customFieldDraft.update { it.copy(key = key) } + } + + fun onValueChanged(value: String) { + customFieldDraft.update { it.copy(value = value) } + } + + fun onDoneClicked() { + launch { + val value = requireNotNull(customFieldDraft.value) + if (value.id == null) { + // Check for duplicate keys before inserting the new custom field + // For more context: pe5sF9-33t-p2#comment-3880 + val existingFields = repository.getDisplayableCustomFields(navArgs.parentItemId) + if (existingFields.any { it.key == value.key }) { + keyErrorMessage.value = UiString.UiStringRes(R.string.custom_fields_editor_key_error_duplicate) + return@launch + } + } + + val event = if (storedValue == null) { + MultiLiveEvent.Event.ExitWithResult(data = value, key = CUSTOM_FIELD_CREATED_RESULT_KEY) + } else { + MultiLiveEvent.Event.ExitWithResult( + data = CustomFieldUpdateResult(oldKey = storedValue.key, updatedField = value), + key = CUSTOM_FIELD_UPDATED_RESULT_KEY + ) + } + triggerEvent(event) + } + } + + fun onDeleteClicked() { + triggerEvent( + MultiLiveEvent.Event.ExitWithResult(data = navArgs.customField, key = CUSTOM_FIELD_DELETED_RESULT_KEY) + ) + } + + fun onCopyKeyClicked() { + triggerEvent(CopyContentToClipboard(R.string.custom_fields_editor_key_label, customFieldDraft.value.key)) + } + + fun onCopyValueClicked() { + triggerEvent(CopyContentToClipboard(R.string.custom_fields_editor_value_label, customFieldDraft.value.value)) + } + + fun onBackClick() { + if (state.value?.hasChanges == true) { + showDiscardChangesDialog.value = true + } else { + triggerEvent(MultiLiveEvent.Event.Exit) + } + } + + private fun Flow.mapToState() = map { + if (it) { + DiscardChangesDialogState( + onDiscard = { triggerEvent(MultiLiveEvent.Event.Exit) }, + onCancel = { showDiscardChangesDialog.value = false } + ) + } else { + null + } + } + + data class UiState( + val customField: CustomFieldUiModel = CustomFieldUiModel("", ""), + val hasChanges: Boolean = false, + val isHtml: Boolean = false, + val discardChangesDialogState: DiscardChangesDialogState? = null, + val keyErrorMessage: UiString? = null, + val isCreatingNewItem: Boolean = false + ) { + val showDoneButton + get() = customField.key.isNotEmpty() && hasChanges && keyErrorMessage == null + } + + data class DiscardChangesDialogState( + val onDiscard: () -> Unit, + val onCancel: () -> Unit + ) + + data class CopyContentToClipboard( + @StringRes val labelResource: Int, + val content: String + ) : MultiLiveEvent.Event() + + @Parcelize + data class CustomFieldUpdateResult( + val oldKey: String, + val updatedField: CustomFieldUiModel + ) : Parcelable +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/customfields/list/CustomFieldsFragment.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/customfields/list/CustomFieldsFragment.kt new file mode 100644 index 00000000000..cb52225db4f --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/customfields/list/CustomFieldsFragment.kt @@ -0,0 +1,109 @@ +package com.woocommerce.android.ui.customfields.list + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.material.SnackbarHostState +import androidx.compose.material.SnackbarResult +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import com.woocommerce.android.extensions.handleResult +import com.woocommerce.android.ui.base.BaseFragment +import com.woocommerce.android.ui.compose.composeView +import com.woocommerce.android.ui.customfields.CustomFieldContentType +import com.woocommerce.android.ui.customfields.CustomFieldUiModel +import com.woocommerce.android.ui.customfields.editor.CustomFieldsEditorViewModel +import com.woocommerce.android.ui.customfields.editor.CustomFieldsEditorViewModel.CustomFieldUpdateResult +import com.woocommerce.android.ui.main.AppBarStatus +import com.woocommerce.android.util.ActivityUtils +import com.woocommerce.android.util.ChromeCustomTabUtils +import com.woocommerce.android.viewmodel.MultiLiveEvent +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch + +@AndroidEntryPoint +class CustomFieldsFragment : BaseFragment() { + private val viewModel: CustomFieldsViewModel by viewModels() + + private val snackbarHostState = SnackbarHostState() + + override val activityAppBarStatus = AppBarStatus.Hidden + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + return composeView { + CustomFieldsScreen( + viewModel = viewModel, + snackbarHostState = snackbarHostState + ) + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + handleEvents() + handleResults() + } + + private fun handleEvents() { + viewModel.event.observe(viewLifecycleOwner) { event -> + when (event) { + is CustomFieldsViewModel.OpenCustomFieldEditor -> openEditor(event.field) + is CustomFieldsViewModel.CustomFieldValueClicked -> handleValueClick(event.field) + is MultiLiveEvent.Event.ShowSnackbar -> showSnackbar(getString(event.message)) + is MultiLiveEvent.Event.ShowActionSnackbar -> showSnackbar( + message = event.message, + actionText = event.actionText, + action = { event.action.onClick(null) } + ) + + is MultiLiveEvent.Event.Exit -> { + findNavController().navigateUp() + } + } + } + } + + private fun handleResults() { + handleResult(CustomFieldsEditorViewModel.CUSTOM_FIELD_CREATED_RESULT_KEY) { result -> + viewModel.onCustomFieldInserted(result) + } + handleResult(CustomFieldsEditorViewModel.CUSTOM_FIELD_UPDATED_RESULT_KEY) { result -> + viewModel.onCustomFieldUpdated(result.oldKey, result.updatedField) + } + handleResult(CustomFieldsEditorViewModel.CUSTOM_FIELD_DELETED_RESULT_KEY) { result -> + viewModel.onCustomFieldDeleted(result) + } + } + + private fun openEditor(field: CustomFieldUiModel?) { + findNavController().navigate( + CustomFieldsFragmentDirections.actionCustomFieldsFragmentToCustomFieldsEditorFragment( + parentItemId = viewModel.parentItemId, + customField = field + ) + ) + } + + private fun handleValueClick(field: CustomFieldUiModel) { + when (field.contentType) { + CustomFieldContentType.URL -> ChromeCustomTabUtils.launchUrl(requireContext(), field.value) + CustomFieldContentType.EMAIL -> ActivityUtils.sendEmail(requireContext(), field.value) + CustomFieldContentType.PHONE -> ActivityUtils.dialPhoneNumber(requireContext(), field.value) + CustomFieldContentType.TEXT -> error("Values of type TEXT should not be clickable") + } + } + + private fun showSnackbar( + message: String, + actionText: String? = null, + action: (() -> Unit)? = null + ) { + viewLifecycleOwner.lifecycleScope.launch { + val result = snackbarHostState.showSnackbar(message = message, actionLabel = actionText) + if (actionText != null && action != null && result == SnackbarResult.ActionPerformed) { + action() + } + } + } +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/customfields/list/CustomFieldsScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/customfields/list/CustomFieldsScreen.kt new file mode 100644 index 00000000000..afc35841ae0 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/customfields/list/CustomFieldsScreen.kt @@ -0,0 +1,276 @@ +package com.woocommerce.android.ui.customfields.list + +import android.content.res.Configuration +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.ClickableText +import androidx.compose.material.Divider +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.FloatingActionButton +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.SnackbarHost +import androidx.compose.material.SnackbarHostState +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos +import androidx.compose.material.icons.filled.Add +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.livedata.observeAsState +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.ExperimentalTextApi +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.UrlAnnotation +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +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.DiscardChangesDialog +import com.woocommerce.android.ui.compose.component.ExpandableTopBanner +import com.woocommerce.android.ui.compose.component.ProgressDialog +import com.woocommerce.android.ui.compose.component.Toolbar +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.ui.customfields.CustomField +import com.woocommerce.android.ui.customfields.CustomFieldContentType +import com.woocommerce.android.ui.customfields.CustomFieldUiModel + +@Composable +fun CustomFieldsScreen( + viewModel: CustomFieldsViewModel, + snackbarHostState: SnackbarHostState +) { + viewModel.state.observeAsState().value?.let { state -> + CustomFieldsScreen( + state = state, + onPullToRefresh = viewModel::onPullToRefresh, + onSaveClicked = viewModel::onSaveClicked, + onCustomFieldClicked = viewModel::onCustomFieldClicked, + onCustomFieldValueClicked = viewModel::onCustomFieldValueClicked, + onAddCustomFieldClicked = viewModel::onAddCustomFieldClicked, + onBackClick = viewModel::onBackClick, + snackbarHostState = snackbarHostState + ) + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +private fun CustomFieldsScreen( + state: CustomFieldsViewModel.UiState, + onPullToRefresh: () -> Unit, + onSaveClicked: () -> Unit, + onCustomFieldClicked: (CustomFieldUiModel) -> Unit, + onCustomFieldValueClicked: (CustomFieldUiModel) -> Unit, + onAddCustomFieldClicked: () -> Unit, + onBackClick: () -> Unit, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() } +) { + BackHandler { onBackClick() } + + Scaffold( + topBar = { + Toolbar( + title = stringResource(id = R.string.custom_fields_list_title), + onNavigationButtonClick = onBackClick, + actions = { + if (state.hasChanges) { + WCTextButton( + text = stringResource(id = R.string.save), + onClick = onSaveClicked + ) + } + } + ) + }, + floatingActionButton = { + FloatingActionButton( + onClick = onAddCustomFieldClicked, + backgroundColor = MaterialTheme.colors.primary, + contentColor = Color.White + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(id = R.string.custom_fields_add_button) + ) + } + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + backgroundColor = MaterialTheme.colors.surface + ) { paddingValues -> + val pullToRefreshState = rememberPullRefreshState(state.isRefreshing, onPullToRefresh) + + Column( + modifier = Modifier + .padding(paddingValues) + ) { + if (state.topBannerState != null) { + ExpandableTopBanner( + title = stringResource(id = R.string.custom_fields_list_top_banner_title), + message = stringResource(id = R.string.custom_fields_list_top_banner_message), + onDismiss = state.topBannerState.onDismiss, + expandedByDefault = LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT, + modifier = Modifier.fillMaxWidth() + ) + } + Box( + modifier = Modifier + .padding(paddingValues) + .pullRefresh(state = pullToRefreshState) + ) { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(state.customFields) { customField -> + CustomFieldItem( + customField = customField, + onClicked = onCustomFieldClicked, + onValueClicked = onCustomFieldValueClicked, + modifier = Modifier.fillMaxWidth() + ) + Divider() + } + } + + PullRefreshIndicator( + refreshing = state.isRefreshing, + state = pullToRefreshState, + modifier = Modifier.align(Alignment.TopCenter) + ) + } + } + + if (state.isSaving) { + ProgressDialog( + title = stringResource(id = R.string.custom_fields_list_progress_dialog_title), + subtitle = stringResource(id = R.string.please_wait) + ) + } + + state.discardChangesDialogState?.let { + DiscardChangesDialog( + discardButton = it.onDiscard, + dismissButton = it.onCancel + ) + } + } +} + +@OptIn(ExperimentalTextApi::class) +@Composable +private fun CustomFieldItem( + customField: CustomFieldUiModel, + onClicked: (CustomFieldUiModel) -> Unit, + onValueClicked: (CustomFieldUiModel) -> Unit, + modifier: Modifier = Modifier +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .clickable(onClick = { onClicked(customField) }) + .padding(16.dp) + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = customField.key, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.subtitle2 + ) + Spacer(modifier = Modifier.height(4.dp)) + + if (customField.contentType != CustomFieldContentType.TEXT) { + val text = buildAnnotatedString { + withStyle(SpanStyle(color = MaterialTheme.colors.primary)) { + pushUrlAnnotation(UrlAnnotation(customField.value)) + append(customField.value) + } + } + ClickableText( + text = text, + style = MaterialTheme.typography.body2.copy( + color = MaterialTheme.colors.onSurface + ), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + onClick = { offset -> + text.getUrlAnnotations( + start = offset, + end = offset + ).firstOrNull()?.let { _ -> + onValueClicked(customField) + } + } + ) + } else { + Text( + text = customField.value, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.body2 + ) + } + } + + Icon( + imageVector = Icons.AutoMirrored.Default.ArrowForwardIos, + contentDescription = null, + modifier = Modifier + .padding(start = 8.dp) + .size(16.dp) + ) + } +} + +@LightDarkThemePreviews +@Preview +@Composable +private fun CustomFieldsScreenPreview() { + WooThemeWithBackground { + CustomFieldsScreen( + state = CustomFieldsViewModel.UiState( + customFields = listOf( + CustomFieldUiModel(CustomField(0, "key1", "Value 1")), + CustomFieldUiModel(CustomField(1, "key2", "Value 2")), + CustomFieldUiModel( + CustomField( + id = 2, + key = "key3", + value = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. " + + "Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." + ) + ), + CustomFieldUiModel(CustomField(3, "key4", "https://url.com")), + ), + topBannerState = CustomFieldsViewModel.TopBannerState { } + ), + onPullToRefresh = {}, + onSaveClicked = {}, + onCustomFieldClicked = {}, + onCustomFieldValueClicked = {}, + onAddCustomFieldClicked = {}, + onBackClick = {} + ) + } +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/customfields/list/CustomFieldsViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/customfields/list/CustomFieldsViewModel.kt new file mode 100644 index 00000000000..c9dc9e8653f --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/customfields/list/CustomFieldsViewModel.kt @@ -0,0 +1,216 @@ +package com.woocommerce.android.ui.customfields.list + +import android.os.Parcelable +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.asLiveData +import androidx.lifecycle.viewModelScope +import com.woocommerce.android.AppPrefsWrapper +import com.woocommerce.android.R +import com.woocommerce.android.extensions.combine +import com.woocommerce.android.ui.customfields.CustomFieldUiModel +import com.woocommerce.android.ui.customfields.CustomFieldsRepository +import com.woocommerce.android.viewmodel.MultiLiveEvent +import com.woocommerce.android.viewmodel.ResourceProvider +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.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize +import org.wordpress.android.fluxc.model.metadata.UpdateMetadataRequest +import javax.inject.Inject + +@HiltViewModel +class CustomFieldsViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val repository: CustomFieldsRepository, + private val appPrefs: AppPrefsWrapper, + private val resourceProvider: ResourceProvider +) : ScopedViewModel(savedStateHandle) { + private val args: CustomFieldsFragmentArgs by savedStateHandle.navArgs() + val parentItemId: Long = args.parentItemId + + private val isRefreshing = MutableStateFlow(false) + private val isSaving = MutableStateFlow(false) + private val showDiscardChangesDialog = savedStateHandle.getStateFlow( + scope = viewModelScope, + initialValue = false, + key = "showDiscardChangesDialog" + ) + private val customFields = repository.observeDisplayableCustomFields(args.parentItemId) + private val pendingChanges = savedStateHandle.getStateFlow(viewModelScope, PendingChanges()) + private val bannerDismissed = appPrefs.observePrefs() + .onStart { emit(Unit) } + .map { appPrefs.isCustomFieldsTopBannerDismissed } + .distinctUntilChanged() + + val state = combine( + customFields, + pendingChanges, + isRefreshing, + isSaving, + showDiscardChangesDialog, + bannerDismissed + ) { customFields, pendingChanges, isLoading, isSaving, isShowingDiscardDialog, bannerDismissed -> + UiState( + customFields = customFields.map { CustomFieldUiModel(it) }.combineWithChanges(pendingChanges), + isRefreshing = isLoading, + isSaving = isSaving, + hasChanges = pendingChanges.hasChanges, + discardChangesDialogState = isShowingDiscardDialog.takeIf { it }?.let { + DiscardChangesDialogState( + onDiscard = { triggerEvent(MultiLiveEvent.Event.Exit) }, + onCancel = { showDiscardChangesDialog.value = false } + ) + }, + topBannerState = bannerDismissed.takeIf { !it }?.let { + TopBannerState { + appPrefs.isCustomFieldsTopBannerDismissed = true + } + } + ) + }.asLiveData() + + fun onBackClick() { + if (pendingChanges.value.hasChanges) { + showDiscardChangesDialog.value = true + } else { + triggerEvent(MultiLiveEvent.Event.Exit) + } + } + + fun onPullToRefresh() { + launch { + isRefreshing.value = true + repository.refreshCustomFields(args.parentItemId, args.parentItemType).onFailure { + triggerEvent(MultiLiveEvent.Event.ShowSnackbar(R.string.custom_fields_list_loading_error)) + } + isRefreshing.value = false + } + } + + fun onCustomFieldClicked(field: CustomFieldUiModel) { + triggerEvent(OpenCustomFieldEditor(field)) + } + + fun onCustomFieldValueClicked(field: CustomFieldUiModel) { + triggerEvent(CustomFieldValueClicked(field)) + } + + fun onAddCustomFieldClicked() { + triggerEvent(OpenCustomFieldEditor(null)) + } + + fun onCustomFieldInserted(result: CustomFieldUiModel) { + pendingChanges.update { + it.copy(insertedFields = it.insertedFields + result) + } + } + + fun onCustomFieldUpdated(oldValueKey: String, result: CustomFieldUiModel) { + pendingChanges.update { + if (result.id == null) { + // We are updating a field that was just added and hasn't been saved yet + it.copy(insertedFields = it.insertedFields.filterNot { field -> field.key == oldValueKey } + result) + } else { + it.copy(editedFields = it.editedFields.filterNot { field -> field.id == result.id } + result) + } + } + } + + fun onCustomFieldDeleted(field: CustomFieldUiModel) { + pendingChanges.update { + if (field.id == null) { + // This field was just added and hasn't been saved yet + it.copy(insertedFields = it.insertedFields - field) + } else { + it.copy(deletedFieldIds = it.deletedFieldIds + field.id) + } + } + + triggerEvent( + MultiLiveEvent.Event.ShowActionSnackbar( + message = resourceProvider.getString(R.string.custom_fields_list_field_deleted), + actionText = resourceProvider.getString(R.string.undo), + action = { + pendingChanges.update { + if (field.id == null) { + it.copy(insertedFields = it.insertedFields + field) + } else { + it.copy(deletedFieldIds = it.deletedFieldIds - field.id) + } + } + } + ) + ) + } + + fun onSaveClicked() { + launch { + isSaving.value = true + val currentPendingChanges = pendingChanges.value + val request = UpdateMetadataRequest( + parentItemId = args.parentItemId, + parentItemType = args.parentItemType, + updatedMetadata = currentPendingChanges.editedFields.map { it.toDomainModel() }, + insertedMetadata = currentPendingChanges.insertedFields.map { it.toDomainModel() }, + deletedMetadataIds = currentPendingChanges.deletedFieldIds + ) + + repository.updateCustomFields(request) + .fold( + onSuccess = { + pendingChanges.value = PendingChanges() + triggerEvent(MultiLiveEvent.Event.ShowSnackbar(R.string.custom_fields_list_saving_succeeded)) + }, + onFailure = { + triggerEvent(MultiLiveEvent.Event.ShowSnackbar(R.string.custom_fields_list_saving_failed)) + } + ) + isSaving.value = false + } + } + + private fun List.combineWithChanges(pendingChanges: PendingChanges) = + filterNot { it.id in pendingChanges.deletedFieldIds } + .map { customField -> + pendingChanges.editedFields.find { it.id == customField.id } ?: customField + } + .plus(pendingChanges.insertedFields) + + data class UiState( + val customFields: List, + val isRefreshing: Boolean = false, + val isSaving: Boolean = false, + val hasChanges: Boolean = false, + val discardChangesDialogState: DiscardChangesDialogState? = null, + val topBannerState: TopBannerState? = null + ) + + data class DiscardChangesDialogState( + val onDiscard: () -> Unit, + val onCancel: () -> Unit + ) + + data class TopBannerState( + val onDismiss: () -> Unit + ) + + @Parcelize + private data class PendingChanges( + val editedFields: List = emptyList(), + val insertedFields: List = emptyList(), + val deletedFieldIds: List = emptyList() + ) : Parcelable { + val hasChanges: Boolean + get() = editedFields.isNotEmpty() || insertedFields.isNotEmpty() || deletedFieldIds.isNotEmpty() + } + + data class OpenCustomFieldEditor(val field: CustomFieldUiModel?) : MultiLiveEvent.Event() + data class CustomFieldValueClicked(val field: CustomFieldUiModel) : MultiLiveEvent.Event() +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/blaze/DashboardBlazeCard.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/blaze/DashboardBlazeCard.kt index 81406411386..7397e6f4904 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/blaze/DashboardBlazeCard.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/blaze/DashboardBlazeCard.kt @@ -33,7 +33,6 @@ import com.woocommerce.android.R import com.woocommerce.android.R.string import com.woocommerce.android.extensions.navigateSafely import com.woocommerce.android.model.DashboardWidget -import com.woocommerce.android.ui.blaze.BlazeCampaignStat import com.woocommerce.android.ui.blaze.BlazeCampaignUi import com.woocommerce.android.ui.blaze.BlazeProductUi import com.woocommerce.android.ui.blaze.BlazeUrlsHelper.BlazeFlowSource @@ -348,20 +347,11 @@ fun MyStoreBlazeViewCampaignPreview() { campaign = BlazeCampaignUi( product = product, status = CampaignStatusUi.Active, - stats = listOf( - BlazeCampaignStat( - name = string.blaze_campaign_status_impressions, - value = 100.toString() - ), - BlazeCampaignStat( - name = string.blaze_campaign_status_clicks, - value = 10.toString() - ), - BlazeCampaignStat( - name = string.blaze_campaign_status_budget, - value = 1000.toString() - ), - ), + isEndlessCampaign = false, + impressions = "32435", + clicks = "43", + formattedBudget = "$100", + budgetLabel = R.string.blaze_campaign_status_budget_total ), onCampaignClicked = {}, onCreateCampaignClicked = {}, diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/blaze/DashboardBlazeViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/blaze/DashboardBlazeViewModel.kt index 4c5e361710c..e9c828d3d44 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/blaze/DashboardBlazeViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/blaze/DashboardBlazeViewModel.kt @@ -9,15 +9,15 @@ import com.woocommerce.android.analytics.AnalyticsEvent.BLAZE_CAMPAIGN_LIST_ENTR import com.woocommerce.android.analytics.AnalyticsEvent.BLAZE_ENTRY_POINT_DISPLAYED import com.woocommerce.android.analytics.AnalyticsTracker import com.woocommerce.android.analytics.AnalyticsTrackerWrapper +import com.woocommerce.android.extensions.NumberExtensionsWrapper import com.woocommerce.android.model.DashboardWidget import com.woocommerce.android.model.Product -import com.woocommerce.android.ui.blaze.BlazeCampaignStat import com.woocommerce.android.ui.blaze.BlazeCampaignUi import com.woocommerce.android.ui.blaze.BlazeProductUi import com.woocommerce.android.ui.blaze.BlazeUrlsHelper import com.woocommerce.android.ui.blaze.BlazeUrlsHelper.BlazeFlowSource.MY_STORE_SECTION -import com.woocommerce.android.ui.blaze.CampaignStatusUi import com.woocommerce.android.ui.blaze.ObserveMostRecentBlazeCampaign +import com.woocommerce.android.ui.blaze.toUiState import com.woocommerce.android.ui.dashboard.DashboardViewModel import com.woocommerce.android.ui.dashboard.DashboardViewModel.DashboardWidgetAction import com.woocommerce.android.ui.dashboard.DashboardViewModel.DashboardWidgetMenu @@ -27,6 +27,7 @@ import com.woocommerce.android.ui.dashboard.blaze.DashboardBlazeViewModel.Dashbo import com.woocommerce.android.ui.dashboard.defaultHideMenuEntry import com.woocommerce.android.ui.products.ProductStatus import com.woocommerce.android.ui.products.list.ProductListRepository +import com.woocommerce.android.util.CurrencyFormatter import com.woocommerce.android.viewmodel.MultiLiveEvent import com.woocommerce.android.viewmodel.ScopedViewModel import dagger.assisted.Assisted @@ -55,7 +56,9 @@ class DashboardBlazeViewModel @AssistedInject constructor( observeMostRecentBlazeCampaign: ObserveMostRecentBlazeCampaign, private val productListRepository: ProductListRepository, private val blazeUrlsHelper: BlazeUrlsHelper, - private val analyticsTrackerWrapper: AnalyticsTrackerWrapper + private val analyticsTrackerWrapper: AnalyticsTrackerWrapper, + private val currencyFormatter: CurrencyFormatter, + private val numberExtensionsWrapper: NumberExtensionsWrapper ) : ScopedViewModel(savedStateHandle) { private val _refreshTrigger = MutableSharedFlow(extraBufferCapacity = 1) private val refreshTrigger = merge(_refreshTrigger, (parentViewModel.refreshTrigger)) @@ -141,23 +144,7 @@ class DashboardBlazeViewModel @AssistedInject constructor( private fun showUiForCampaign(campaign: BlazeCampaignModel): DashboardBlazeCampaignState { return Campaign( - campaign = BlazeCampaignUi( - product = BlazeProductUi( - name = campaign.title, - imgUrl = campaign.imageUrl.orEmpty(), - ), - status = CampaignStatusUi.fromString(campaign.uiStatus), - stats = listOf( - BlazeCampaignStat( - name = string.blaze_campaign_status_impressions, - value = campaign.impressions.toString() - ), - BlazeCampaignStat( - name = string.blaze_campaign_status_clicks, - value = campaign.clicks.toString() - ) - ) - ), + campaign = campaign.toUiState(currencyFormatter, numberExtensionsWrapper), onCampaignClicked = { parentViewModel.trackCardInteracted(DashboardWidget.Type.BLAZE.trackingIdentifier) analyticsTrackerWrapper.track( diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/data/DashboardDataStore.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/data/DashboardDataStore.kt index ca33c920ea7..fe54b89941f 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/data/DashboardDataStore.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/data/DashboardDataStore.kt @@ -58,7 +58,8 @@ class DashboardDataStore @Inject constructor( this == DashboardWidget.Type.STATS || this == DashboardWidget.Type.POPULAR_PRODUCTS || this == DashboardWidget.Type.ONBOARDING || - this == DashboardWidget.Type.BLAZE + this == DashboardWidget.Type.BLAZE || + this == DashboardWidget.Type.GOOGLE_ADS return DashboardWidget.Type.supportedWidgets.map { DashboardWidgetDataModel.newBuilder() diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/stats/DashboardStatsView.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/stats/DashboardStatsView.kt index 28a514da900..4903eaaa88f 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/stats/DashboardStatsView.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/stats/DashboardStatsView.kt @@ -552,6 +552,7 @@ class DashboardStatsView @JvmOverloads constructor( fadeInLabelValue(ordersValue, orders) if (chartRevenueStats.isEmpty() || revenueStatsModel?.totalSales == 0.toDouble()) { + binding.chart.clear() isRequestingStats = false return } @@ -648,8 +649,7 @@ class DashboardStatsView @JvmOverloads constructor( } private fun generateLineDataSet(revenueStats: Map): LineDataSet { - chartRevenueStats = revenueStats - val entries = chartRevenueStats.values.mapIndexed { index, value -> + val entries = revenueStats.values.mapIndexed { index, value -> Entry((index + 1).toFloat(), value.toFloat()) } return LineDataSet(entries, "") diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/google/webview/GoogleAdsWebViewScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/google/webview/GoogleAdsWebViewScreen.kt index c80deb2016f..5720ba9d669 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/google/webview/GoogleAdsWebViewScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/google/webview/GoogleAdsWebViewScreen.kt @@ -13,7 +13,7 @@ import androidx.compose.ui.res.stringResource import com.woocommerce.android.R import com.woocommerce.android.ui.common.wpcomwebview.WPComWebViewAuthenticator import com.woocommerce.android.ui.compose.component.Toolbar -import com.woocommerce.android.ui.compose.component.WCWebView +import com.woocommerce.android.ui.compose.component.web.WCWebView import com.woocommerce.android.ui.google.webview.GoogleAdsWebViewViewModel.DisplayMode.MODAL import com.woocommerce.android.ui.google.webview.GoogleAdsWebViewViewModel.DisplayMode.REGULAR import org.wordpress.android.fluxc.network.UserAgent diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/jetpack/JetpackCPInstallViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/jetpack/JetpackCPInstallViewModel.kt index fbe2f1289ef..ecdec05ce9c 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/jetpack/JetpackCPInstallViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/jetpack/JetpackCPInstallViewModel.kt @@ -24,7 +24,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize -import org.wordpress.android.fluxc.store.WooCommerceStore +import org.wordpress.android.fluxc.store.SiteStore import javax.inject.Inject @HiltViewModel @@ -32,7 +32,7 @@ class JetpackCPInstallViewModel @Inject constructor( savedState: SavedStateHandle, private val repository: PluginRepository, private val selectedSite: SelectedSite, - private val wooCommerceStore: WooCommerceStore + private val siteStore: SiteStore ) : ScopedViewModel(savedState) { companion object { const val JETPACK_SLUG = "jetpack" @@ -114,10 +114,10 @@ class JetpackCPInstallViewModel @Inject constructor( private suspend fun isJetpackConnectedAfterInstallation(): Boolean { var attempt = 0 while (attempt < ATTEMPT_LIMIT) { - val result = wooCommerceStore.fetchWooCommerceSites() - val sites = result.model - if (sites != null) { - val syncedSite = sites.firstOrNull { it.siteId == selectedSite.get().siteId } + val siteId = selectedSite.get().siteId + val resultIsNotError = siteStore.fetchSite(selectedSite.get()).let { !it.isError } + if (resultIsNotError) { + val syncedSite = siteStore.getSiteBySiteId(siteId) if (syncedSite?.isJetpackConnected == true && syncedSite.hasWooCommerce) { selectedSite.set(syncedSite) return true @@ -125,6 +125,8 @@ class JetpackCPInstallViewModel @Inject constructor( attempt++ delay(SYNC_CHECK_DELAY) } + } else { + attempt++ } } return false diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/jitm/JitmStoreInMemoryCache.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/jitm/JitmStoreInMemoryCache.kt index 8947d42baee..3feda096a66 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/jitm/JitmStoreInMemoryCache.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/jitm/JitmStoreInMemoryCache.kt @@ -2,20 +2,20 @@ package com.woocommerce.android.ui.jitm import com.woocommerce.android.tools.SelectedSite import com.woocommerce.android.util.WooLog +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import org.wordpress.android.fluxc.network.rest.wpcom.wc.WooResult import org.wordpress.android.fluxc.network.rest.wpcom.wc.jitm.JITMApiResponse import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CopyOnWriteArrayList import javax.inject.Inject import javax.inject.Singleton -import kotlin.coroutines.Continuation -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine @Singleton class JitmStoreInMemoryCache @@ -29,49 +29,42 @@ class JitmStoreInMemoryCache ) { private val cache = ConcurrentHashMap>() - @Volatile - private var cacheInitContinuation: Continuation? = null - - @Volatile - private var initialisationStatus = InitStatus.NOT_STARTED + private val initStatus = CompletableDeferred() + private val initMutex = Mutex() suspend fun init() { - if (!selectedSite.exists() || initialisationStatus != InitStatus.NOT_STARTED) return - - initialisationStatus = InitStatus.STARTED + if (!selectedSite.exists()) return + + initMutex.withLock { + if (initStatus.isCompleted) return + + supervisorScope { + pathsProvider.paths.map { path -> + async { + WooLog.d(WooLog.T.JITM, "Fetching JITM message for path: $path") + val response = jitmStore.fetchJitmMessage( + selectedSite.get(), + path, + jitmQueryParamsEncoder.getEncodedQueryParams(), + ) + handleResponse(path, response) + } + }.awaitAll() + } - supervisorScope { - pathsProvider.paths.map { path -> - async { - WooLog.d(WooLog.T.JITM, "Fetching JITM message for path: $path") - val response = jitmStore.fetchJitmMessage( - selectedSite.get(), - path, - jitmQueryParamsEncoder.getEncodedQueryParams(), - ) - handleResponse(path, response) - } - }.awaitAll() + initStatus.complete(Unit) } - - cacheInitContinuation?.resume(Unit) - initialisationStatus = InitStatus.DONE } suspend fun getMessagesForPath(messagePath: String): List { WooLog.d(WooLog.T.JITM, "Getting JITM messages for path: $messagePath") if (!selectedSite.exists()) return emptyList() - when (initialisationStatus) { - InitStatus.NOT_STARTED -> { - appCoroutineScope.launch { init() } - suspendCoroutine { cacheInitContinuation = it } - } - InitStatus.STARTED -> suspendCoroutine { cacheInitContinuation = it } - InitStatus.DONE -> { - // cache initialization is done, use it - } + if (!initStatus.isCompleted) { + appCoroutineScope.launch { init() } + initStatus.await() } + cache.putIfAbsent(messagePath, CopyOnWriteArrayList()) return cache[messagePath]!! } @@ -104,8 +97,4 @@ class JitmStoreInMemoryCache private fun evictFirstMessage(messagePath: String) { cache[messagePath]?.let { if (it.isNotEmpty()) it.removeAt(0) } } - - private enum class InitStatus { - NOT_STARTED, STARTED, DONE, - } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/login/accountmismatch/AccountMismatchErrorScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/login/accountmismatch/AccountMismatchErrorScreen.kt index 696c73379ad..49d8baf2646 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/login/accountmismatch/AccountMismatchErrorScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/login/accountmismatch/AccountMismatchErrorScreen.kt @@ -58,9 +58,9 @@ import com.woocommerce.android.ui.compose.component.WCOutlinedButton import com.woocommerce.android.ui.compose.component.WCOutlinedTextField import com.woocommerce.android.ui.compose.component.WCPasswordField import com.woocommerce.android.ui.compose.component.WCTextButton -import com.woocommerce.android.ui.compose.component.WCWebView -import com.woocommerce.android.ui.compose.component.WebViewNavigator -import com.woocommerce.android.ui.compose.component.rememberWebViewNavigator +import com.woocommerce.android.ui.compose.component.web.WCWebView +import com.woocommerce.android.ui.compose.component.web.WebViewNavigator +import com.woocommerce.android.ui.compose.component.web.rememberWebViewNavigator import com.woocommerce.android.ui.compose.theme.WooThemeWithBackground import com.woocommerce.android.ui.login.accountmismatch.AccountMismatchErrorViewModel.ViewState import com.woocommerce.android.util.ChromeCustomTabUtils diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/login/jetpack/connection/JetpackActivationWebViewScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/login/jetpack/connection/JetpackActivationWebViewScreen.kt index a73d369f075..7c51dd33570 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/login/jetpack/connection/JetpackActivationWebViewScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/login/jetpack/connection/JetpackActivationWebViewScreen.kt @@ -10,7 +10,7 @@ import androidx.compose.ui.res.stringResource import com.woocommerce.android.R import com.woocommerce.android.ui.common.wpcomwebview.WPComWebViewAuthenticator import com.woocommerce.android.ui.compose.component.Toolbar -import com.woocommerce.android.ui.compose.component.WCWebView +import com.woocommerce.android.ui.compose.component.web.WCWebView import org.wordpress.android.fluxc.network.UserAgent @Composable diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/login/sitecredentials/LoginSiteCredentialsScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/login/sitecredentials/LoginSiteCredentialsScreen.kt index 4e3edf67884..7040debd012 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/login/sitecredentials/LoginSiteCredentialsScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/login/sitecredentials/LoginSiteCredentialsScreen.kt @@ -35,8 +35,8 @@ import com.woocommerce.android.ui.compose.component.WCColoredButton import com.woocommerce.android.ui.compose.component.WCOutlinedTextField import com.woocommerce.android.ui.compose.component.WCPasswordField import com.woocommerce.android.ui.compose.component.WCTextButton -import com.woocommerce.android.ui.compose.component.WCWebView import com.woocommerce.android.ui.compose.component.getText +import com.woocommerce.android.ui.compose.component.web.WCWebView import com.woocommerce.android.ui.compose.theme.WooThemeWithBackground @Composable diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/login/sitecredentials/applicationpassword/ApplicationPasswordTutorialScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/login/sitecredentials/applicationpassword/ApplicationPasswordTutorialScreen.kt index 9847ac74750..d072961f9ea 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/login/sitecredentials/applicationpassword/ApplicationPasswordTutorialScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/login/sitecredentials/applicationpassword/ApplicationPasswordTutorialScreen.kt @@ -32,7 +32,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import com.woocommerce.android.R import com.woocommerce.android.ui.compose.component.Toolbar -import com.woocommerce.android.ui.compose.component.WCWebView +import com.woocommerce.android.ui.compose.component.web.WCWebView import com.woocommerce.android.ui.compose.theme.WooThemeWithBackground import org.wordpress.android.fluxc.network.UserAgent 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 f20b5b142d3..f5efbf9010b 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 @@ -81,6 +81,8 @@ import com.woocommerce.android.ui.main.MainActivityViewModel.RestartActivityForP import com.woocommerce.android.ui.main.MainActivityViewModel.ShortcutOpenOrderCreation import com.woocommerce.android.ui.main.MainActivityViewModel.ShortcutOpenPayments import com.woocommerce.android.ui.main.MainActivityViewModel.ShowFeatureAnnouncement +import com.woocommerce.android.ui.main.MainActivityViewModel.ViewBlazeCampaignDetail +import com.woocommerce.android.ui.main.MainActivityViewModel.ViewBlazeCampaignList import com.woocommerce.android.ui.main.MainActivityViewModel.ViewMyStoreStats import com.woocommerce.android.ui.main.MainActivityViewModel.ViewOrderDetail import com.woocommerce.android.ui.main.MainActivityViewModel.ViewOrderList @@ -113,6 +115,7 @@ import dagger.hilt.android.AndroidEntryPoint import org.wordpress.android.login.LoginAnalyticsListener import org.wordpress.android.login.LoginMode import org.wordpress.android.util.NetworkUtils +import java.lang.ref.WeakReference import java.math.BigDecimal import java.util.Locale import javax.inject.Inject @@ -207,8 +210,30 @@ class MainActivity : private var progressDialog: ProgressDialog? = null private val fragmentLifecycleObserver: FragmentLifecycleCallbacks = object : FragmentLifecycleCallbacks() { + private var lastFragment = WeakReference(null) + override fun onFragmentViewCreated(fm: FragmentManager, f: Fragment, v: View, savedInstanceState: Bundle?) { + updateAppBarAndBottomNav(f) + } + + override fun onFragmentStarted(fm: FragmentManager, f: Fragment) { + // This logic is needed to handle this case: + // 1. User navigates from Fragment A to Fragment B + // 2. Fragment B's view gets created, and onFragmentViewCreated is called, updating the AppBar. + // 3. Quickly the user goes back to Fragment A + // 4. Fragment A's view wasn't destroyed yet, so it doesn't go through the creation lifecycle, + // which means onFragmentViewCreated won't be called, and the AppBar won't be updated. + // + // In this case, lastFragment will be pointing to Fragment B, so we can compare it with the fragment being + // started (Fragment A), and we can update the AppBar accordingly. + if (lastFragment.get() != f) { + updateAppBarAndBottomNav(f) + } + } + + private fun updateAppBarAndBottomNav(f: Fragment) { if (f is DialogFragment) return + lastFragment = WeakReference(f) when (val appBarStatus = (f as? BaseFragment)?.activityAppBarStatus ?: AppBarStatus.Visible()) { is AppBarStatus.Visible -> { @@ -763,7 +788,7 @@ class MainActivity : intent.removeExtra(FIELD_REMOTE_NOTIFICATION) intent.removeExtra(FIELD_PUSH_ID) - viewModel.handleIncomingNotification(localPushId, notification) + viewModel.onPushNotificationTapped(localPushId, notification) } else if (localNotification != null) { intent.removeExtra(FIELD_LOCAL_NOTIFICATION) viewModel.onLocalNotificationTapped(localNotification) @@ -780,6 +805,8 @@ class MainActivity : is ViewOrderDetail -> showOrderDetail(event) is ViewReviewDetail -> showReviewDetail(event.uniqueId, launchedFromNotification = true) is ViewReviewList -> showReviewList() + is ViewBlazeCampaignDetail -> showBlazeCampaignList(event.campaignId, event.isOpenedFromPush) + ViewBlazeCampaignList -> showBlazeCampaignList(campaignId = null) is RestartActivityEvent -> onRestartActivityEvent(event) is ShowFeatureAnnouncement -> navigateToFeatureAnnouncement(event) is ViewUrlInWebView -> navigateToWebView(event) @@ -820,6 +847,18 @@ class MainActivity : observeBottomBarState() } + private fun showBlazeCampaignList(campaignId: String?, isOpenedFromPush: Boolean = false) { + binding.bottomNav.currentPosition = MORE + binding.bottomNav.active(MORE.position) + + navController.navigateSafely( + MoreMenuFragmentDirections.actionMoreMenuToBlazeCampaignListFragment( + campaignId = campaignId + ), + skipThrottling = isOpenedFromPush + ) + } + private fun observeNotificationsPermissionBarVisibility() { viewModel.isNotificationsPermissionCardVisible.observe(this) { isVisible -> if (isVisible) { diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/main/MainActivityViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/main/MainActivityViewModel.kt index b9c288824db..ee677b9f82c 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/main/MainActivityViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/main/MainActivityViewModel.kt @@ -15,7 +15,9 @@ import com.woocommerce.android.model.FeatureAnnouncement import com.woocommerce.android.model.Notification import com.woocommerce.android.notifications.NotificationChannelType import com.woocommerce.android.notifications.UnseenReviewsCountHandler +import com.woocommerce.android.notifications.WooNotificationType import com.woocommerce.android.notifications.local.LocalNotificationType +import com.woocommerce.android.notifications.local.LocalNotificationType.BLAZE_ABANDONED_CAMPAIGN_REMINDER import com.woocommerce.android.notifications.local.LocalNotificationType.BLAZE_NO_CAMPAIGN_REMINDER import com.woocommerce.android.notifications.push.NotificationMessageHandler import com.woocommerce.android.tools.SelectedSite @@ -109,7 +111,7 @@ class MainActivityViewModel @Inject constructor( ) } - fun handleIncomingNotification(localPushId: Int, notification: Notification?) { + fun onPushNotificationTapped(localPushId: Int, notification: Notification?) { notification?.let { // update current selectSite based on the current notification val currentSite = selectedSite.get() @@ -118,8 +120,8 @@ class MainActivityViewModel @Inject constructor( changeSiteAndRestart(it.remoteSiteId, RestartActivityForPushNotification(localPushId, notification)) } else { when (localPushId) { - it.getGroupPushId() -> onGroupMessageOpened(it.channelType, it.remoteSiteId) - else -> onSingleNotificationOpened(localPushId, it) + it.getGroupPushId() -> onGroupMessageOpened(it) + else -> onSinglePushNotificationOpened(localPushId, it) } } } ?: run { @@ -170,29 +172,50 @@ class MainActivityViewModel @Inject constructor( } } - private fun onGroupMessageOpened(notificationChannelType: NotificationChannelType, remoteSiteId: Long) { - notificationHandler.markNotificationsOfTypeTapped(notificationChannelType) - notificationHandler.removeNotificationsOfTypeFromSystemsBar(notificationChannelType, remoteSiteId) - when (notificationChannelType) { + private fun onGroupMessageOpened(notification: Notification) { + notificationHandler.markNotificationsOfTypeTapped(notification.channelType) + notificationHandler.removeNotificationsOfTypeFromSystemsBar(notification.channelType, notification.remoteSiteId) + when (notification.channelType) { NotificationChannelType.NEW_ORDER -> triggerEvent(ViewOrderList) NotificationChannelType.REVIEW -> triggerEvent(ViewReviewList) - else -> triggerEvent(ViewMyStoreStats) + NotificationChannelType.OTHER -> if (notification.isBlazeNotification) { + triggerEvent(ViewBlazeCampaignList) + } else { + triggerEvent(ViewMyStoreStats) + } } } - private fun onSingleNotificationOpened(localPushId: Int, notification: Notification) { + private fun onSinglePushNotificationOpened(localPushId: Int, notification: Notification) { notificationHandler.markNotificationTapped(notification.remoteNoteId) notificationHandler.removeNotificationByNotificationIdFromSystemsBar(localPushId) - if (notification.channelType == NotificationChannelType.REVIEW) { - analyticsTrackerWrapper.track(REVIEW_OPEN) - triggerEvent(ViewReviewDetail(notification.uniqueId)) - } else if (notification.channelType == NotificationChannelType.NEW_ORDER) { - if (siteStore.getSiteBySiteId(notification.remoteSiteId) != null) { - triggerEvent(ViewOrderDetail(notification.uniqueId, notification.remoteNoteId)) - } else { - // the site does not exist locally, open order list - triggerEvent(ViewOrderList) + when (notification.noteType) { + is WooNotificationType.NewOrder -> { + when { + siteStore.getSiteBySiteId(notification.remoteSiteId) != null -> triggerEvent( + ViewOrderDetail( + notification.uniqueId, + notification.remoteNoteId + ) + ) + + else -> triggerEvent(ViewOrderList) + } } + + is WooNotificationType.ProductReview -> { + analyticsTrackerWrapper.track(REVIEW_OPEN) + triggerEvent(ViewReviewDetail(notification.uniqueId)) + } + + is WooNotificationType.BlazeStatusUpdate -> triggerEvent( + ViewBlazeCampaignDetail( + campaignId = notification.uniqueId.toString(), + isOpenedFromPush = true + ) + ) + + is WooNotificationType.LocalReminder -> error("Local reminder notification should not be handled here") } } @@ -264,7 +287,8 @@ class MainActivityViewModel @Inject constructor( ) LocalNotificationType.fromString(notification.tag)?.let { when (it) { - BLAZE_NO_CAMPAIGN_REMINDER -> triggerEvent(LaunchBlazeCampaignCreation) + BLAZE_NO_CAMPAIGN_REMINDER, + BLAZE_ABANDONED_CAMPAIGN_REMINDER -> triggerEvent(LaunchBlazeCampaignCreation) } } } @@ -312,6 +336,7 @@ class MainActivityViewModel @Inject constructor( data class ViewUrlInWebView( val url: String, ) : Event() + object ShortcutOpenPayments : Event() object ShortcutOpenOrderCreation : Event() object LaunchBlazeCampaignCreation : Event() @@ -328,6 +353,8 @@ class MainActivityViewModel @Inject constructor( data class ShowFeatureAnnouncement(val announcement: FeatureAnnouncement) : Event() data class ViewReviewDetail(val uniqueId: Long) : Event() data class ViewOrderDetail(val uniqueId: Long, val remoteNoteId: Long) : Event() + data class ViewBlazeCampaignDetail(val campaignId: String, val isOpenedFromPush: Boolean) : Event() + object ViewBlazeCampaignList : Event() data class ShowPrivacyPreferenceUpdatedFailed(val analyticsEnabled: Boolean) : Event() object ShowPrivacySettings : Event() data class ShowPrivacySettingsWithError(val requestedAnalyticsValue: RequestedAnalyticsValue) : Event() diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/moremenu/MoreMenuViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/moremenu/MoreMenuViewModel.kt index c997117de2d..da9b6a78fa3 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/moremenu/MoreMenuViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/moremenu/MoreMenuViewModel.kt @@ -34,7 +34,6 @@ import com.woocommerce.android.ui.payments.taptopay.isAvailable import com.woocommerce.android.ui.plans.domain.SitePlan import com.woocommerce.android.ui.plans.repository.SitePlanRepository import com.woocommerce.android.ui.woopos.WooPosIsEnabled -import com.woocommerce.android.ui.woopos.WooPosIsFeatureFlagEnabled import com.woocommerce.android.util.WooLog import com.woocommerce.android.viewmodel.ResourceProvider import com.woocommerce.android.viewmodel.ScopedViewModel @@ -71,7 +70,6 @@ class MoreMenuViewModel @Inject constructor( private val isGoogleForWooEnabled: IsGoogleForWooEnabled, private val hasGoogleAdsCampaigns: HasGoogleAdsCampaigns, private val isWooPosEnabled: WooPosIsEnabled, - private val isWooPosFFEnabled: WooPosIsFeatureFlagEnabled, private val analyticsTrackerWrapper: AnalyticsTrackerWrapper ) : ScopedViewModel(savedState) { private var storeHasGoogleAdsCampaigns = false @@ -459,24 +457,17 @@ class MoreMenuViewModel @Inject constructor( .onStart { emit("") } private fun checkFeaturesAvailability(): Flow> { - val initialState = MoreMenuItemButton.Type.entries.associateWith { MoreMenuItemButton.State.Loading } - .toMutableMap() + val initialState = MoreMenuItemButton.Type.entries.associateWith { + MoreMenuItemButton.State.Loading + }.toMutableMap() - val flows = mutableListOf( + return listOf( doCheckAvailability(MoreMenuItemButton.Type.Blaze) { isBlazeEnabled() }, doCheckAvailability(MoreMenuItemButton.Type.GoogleForWoo) { isGoogleForWooEnabled() }, doCheckAvailability(MoreMenuItemButton.Type.Inbox) { moreMenuRepository.isInboxEnabled() }, doCheckAvailability(MoreMenuItemButton.Type.Settings) { moreMenuRepository.isUpgradesEnabled() }, - ) - - // While this in development better to not show loading state for WooPos at all - if (isWooPosFFEnabled()) { - flows += doCheckAvailability(MoreMenuItemButton.Type.WooPos) { isWooPosEnabled() } - } else { - initialState[MoreMenuItemButton.Type.WooPos] = MoreMenuItemButton.State.Hidden - } - - return flows.merge() + doCheckAvailability(MoreMenuItemButton.Type.WooPos) { isWooPosEnabled() } + ).merge() .map { update -> initialState[update.first] = update.second initialState diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/onboarding/aboutyourstore/AboutYourStoreScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/onboarding/aboutyourstore/AboutYourStoreScreen.kt index 343ecc914ec..ac36ca948c4 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/onboarding/aboutyourstore/AboutYourStoreScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/onboarding/aboutyourstore/AboutYourStoreScreen.kt @@ -16,7 +16,7 @@ import androidx.compose.ui.res.stringResource import com.woocommerce.android.R import com.woocommerce.android.ui.common.wpcomwebview.WPComWebViewAuthenticator import com.woocommerce.android.ui.compose.component.Toolbar -import com.woocommerce.android.ui.compose.component.WCWebView +import com.woocommerce.android.ui.compose.component.web.WCWebView import com.woocommerce.android.ui.onboarding.aboutyourstore.AboutYourStoreViewModel.ViewState.LoadingState import com.woocommerce.android.ui.onboarding.aboutyourstore.AboutYourStoreViewModel.ViewState.WebViewState import org.wordpress.android.fluxc.network.UserAgent diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/onboarding/launchstore/LaunchStoreScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/onboarding/launchstore/LaunchStoreScreen.kt index 9460797fd30..97f23d06a8d 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/onboarding/launchstore/LaunchStoreScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/onboarding/launchstore/LaunchStoreScreen.kt @@ -33,8 +33,8 @@ import com.woocommerce.android.ui.common.wpcomwebview.WPComWebViewAuthenticator import com.woocommerce.android.ui.compose.component.Toolbar import com.woocommerce.android.ui.compose.component.WCColoredButton import com.woocommerce.android.ui.compose.component.WCOutlinedButton -import com.woocommerce.android.ui.compose.component.WCWebView -import com.woocommerce.android.ui.compose.component.WebViewProgressIndicator.Circular +import com.woocommerce.android.ui.compose.component.web.WCWebView +import com.woocommerce.android.ui.compose.component.web.WebViewProgressIndicator.Circular import com.woocommerce.android.ui.compose.drawShadow import com.woocommerce.android.ui.onboarding.launchstore.LaunchStoreViewModel.LaunchStoreState import org.wordpress.android.fluxc.network.UserAgent diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/onboarding/payments/GetPaidScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/onboarding/payments/GetPaidScreen.kt index 70feaf1dbb9..3da449de4f2 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/onboarding/payments/GetPaidScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/onboarding/payments/GetPaidScreen.kt @@ -9,7 +9,7 @@ import androidx.compose.ui.res.stringResource import com.woocommerce.android.R.string import com.woocommerce.android.ui.common.wpcomwebview.WPComWebViewAuthenticator import com.woocommerce.android.ui.compose.component.Toolbar -import com.woocommerce.android.ui.compose.component.WCWebView +import com.woocommerce.android.ui.compose.component.web.WCWebView import org.wordpress.android.fluxc.network.UserAgent @Composable diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/OrderNavigationTarget.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/OrderNavigationTarget.kt index 88325c46345..ff7794a59ba 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/OrderNavigationTarget.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/OrderNavigationTarget.kt @@ -45,6 +45,7 @@ sealed class OrderNavigationTarget : Event() { val orderId: Long, val selectedProvider: String ) : OrderNavigationTarget() + object OpenTrackingBarcodeScanning : OrderNavigationTarget() data class PrintShippingLabel(val remoteOrderId: Long, val shippingLabelId: Long) : OrderNavigationTarget() data class ViewShippingLabelPaperSizes(val currentPaperSize: ShippingLabelPaperSize) : OrderNavigationTarget() object ViewCreateShippingLabelInfo : OrderNavigationTarget() diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/OrderNavigator.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/OrderNavigator.kt index 2fda095b354..e213cc736f9 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/OrderNavigator.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/OrderNavigator.kt @@ -11,6 +11,7 @@ import com.woocommerce.android.ui.orders.OrderNavigationTarget.AddOrderNote import com.woocommerce.android.ui.orders.OrderNavigationTarget.AddOrderShipmentTracking import com.woocommerce.android.ui.orders.OrderNavigationTarget.EditOrder import com.woocommerce.android.ui.orders.OrderNavigationTarget.IssueOrderRefund +import com.woocommerce.android.ui.orders.OrderNavigationTarget.OpenTrackingBarcodeScanning import com.woocommerce.android.ui.orders.OrderNavigationTarget.PreviewReceipt import com.woocommerce.android.ui.orders.OrderNavigationTarget.PrintShippingLabel import com.woocommerce.android.ui.orders.OrderNavigationTarget.RefundShippingLabel @@ -33,6 +34,7 @@ import com.woocommerce.android.ui.orders.details.OrderDetailFragmentDirections import com.woocommerce.android.ui.orders.shippinglabels.PrintShippingLabelFragmentDirections import com.woocommerce.android.ui.orders.tracking.AddOrderShipmentTrackingFragmentDirections import com.woocommerce.android.ui.payments.cardreader.onboarding.CardReaderFlowParam +import com.woocommerce.android.util.FeatureFlag import javax.inject.Inject import javax.inject.Singleton @@ -96,6 +98,13 @@ class OrderNavigator @Inject constructor() { ) fragment.findNavController().navigateSafely(action) } + + is OpenTrackingBarcodeScanning -> { + val action = AddOrderShipmentTrackingFragmentDirections + .actionAddOrderShipmentTrackingFragmentToBarcodeScanningFragment() + fragment.findNavController().navigateSafely(action) + } + is PrintShippingLabel -> { val action = OrderDetailFragmentDirections .actionOrderDetailFragmentToPrintShippingLabelFragment( @@ -157,7 +166,7 @@ class OrderNavigator @Inject constructor() { val action = OrderDetailFragmentDirections.actionOrderDetailFragmentToCardReaderFlow( CardReaderFlowParam.PaymentOrRefund.Payment(target.orderId, target.paymentTypeFlow) ) - fragment.findNavController().navigateSafely(action) + fragment.findNavController().navigateSafely(directions = action, skipThrottling = true) } is ViewPrintingInstructions -> { val action = OrderDetailFragmentDirections @@ -198,9 +207,15 @@ class OrderNavigator @Inject constructor() { } is ViewCustomFields -> { - val action = OrderDetailFragmentDirections.actionOrderDetailFragmentToCustomOrderFieldsFragment( - orderId = target.orderId - ) + val action = if (FeatureFlag.CUSTOM_FIELDS.isEnabled()) { + OrderDetailFragmentDirections.actionOrderDetailFragmentToCustomFieldsFragment( + parentItemId = target.orderId + ) + } else { + OrderDetailFragmentDirections.actionOrderDetailFragmentToCustomOrderFieldsFragment( + orderId = target.orderId + ) + } fragment.findNavController().navigateSafely(action) } is AIThankYouNote -> { diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/creation/CreateOrderItem.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/creation/CreateOrderItem.kt index 18624ded470..373b84ca18e 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/creation/CreateOrderItem.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/creation/CreateOrderItem.kt @@ -26,7 +26,8 @@ class CreateOrderItem @Inject constructor( productConfiguration: ProductConfiguration? = null, ): Order.Item { return withContext(coroutineDispatchers.io) { - val product = productDetailRepository.fetchProductOrLoadFromCache(remoteProductId) + val product = productDetailRepository.getProduct(remoteProductId) + ?: productDetailRepository.fetchAndGetProduct(remoteProductId) // Try to get the default configuration for not configurable bundles val configuration = if (product?.productType == ProductType.BUNDLE && productConfiguration == null) { getProductRules.getRules(remoteProductId)?.let { getProductConfiguration(it) } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/creation/OrderCreateEditViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/creation/OrderCreateEditViewModel.kt index 26d8caf3773..ca65c58c4cc 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/creation/OrderCreateEditViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/creation/OrderCreateEditViewModel.kt @@ -138,7 +138,7 @@ import com.woocommerce.android.ui.products.ParameterRepository import com.woocommerce.android.ui.products.ProductRestriction import com.woocommerce.android.ui.products.ProductStatus import com.woocommerce.android.ui.products.ProductType -import com.woocommerce.android.ui.products.list.ProductListRepository +import com.woocommerce.android.ui.products.inventory.FetchProductBySKU import com.woocommerce.android.ui.products.selector.ProductSelectorViewModel.SelectedItem import com.woocommerce.android.ui.products.selector.ProductSelectorViewModel.SelectedItem.Product import com.woocommerce.android.ui.products.selector.variationIds @@ -177,7 +177,6 @@ import kotlinx.coroutines.withContext import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize import org.wordpress.android.fluxc.network.rest.wpcom.wc.WooErrorType -import org.wordpress.android.fluxc.store.WCProductStore import org.wordpress.android.fluxc.store.WooCommerceStore.WooPlugin.WOO_GIFT_CARDS import org.wordpress.android.fluxc.utils.putIfNotNull import java.math.BigDecimal @@ -195,8 +194,6 @@ class OrderCreateEditViewModel @Inject constructor( private val orderCreationProductMapper: OrderCreationProductMapper, private val createOrderItem: CreateOrderItem, private val tracker: AnalyticsTrackerWrapper, - private val productRepository: ProductListRepository, - private val checkDigitRemoverFactory: CheckDigitRemoverFactory, private val barcodeScanningTracker: BarcodeScanningTracker, private val resourceProvider: ResourceProvider, private val productRestrictions: OrderCreationProductRestrictions, @@ -211,6 +208,7 @@ class OrderCreateEditViewModel @Inject constructor( private val currencySymbolFinder: CurrencySymbolFinder, private val totalsHelper: OrderCreateEditTotalsHelper, private val feedbackRepository: FeedbackRepository, + private val fetchProductBySKU: FetchProductBySKU, dateUtils: DateUtils, autoSyncOrder: AutoSyncOrder, autoSyncPriceModifier: AutoSyncPriceModifier, @@ -741,6 +739,8 @@ class OrderCreateEditViewModel @Inject constructor( source: ScanningSource? = null, addedVia: ProductAddedVia = ProductAddedVia.MANUALLY ) { + viewState = viewState.copy(isUpdatingOrderDraft = true) + val hasBundleConfiguration = selectedItems.any { item -> (item as? SelectedItem.ConfigurableProduct) ?.configuration?.configurationType == ConfigurationType.BUNDLE @@ -828,6 +828,9 @@ class OrderCreateEditViewModel @Inject constructor( } } } + + viewState = viewState.copy(isUpdatingOrderDraft = false) + _orderDraft.update { order -> order.updateItems(order.items + itemsToAdd) } } } @@ -892,7 +895,6 @@ class OrderCreateEditViewModel @Inject constructor( is CodeScannerStatus.Success -> { barcodeScanningTracker.trackSuccess(ScanningSource.ORDER_CREATION) - viewState = viewState.copy(isUpdatingOrderDraft = true) fetchProductBySKU( BarcodeOptions( sku = status.code, @@ -919,48 +921,50 @@ class OrderCreateEditViewModel @Inject constructor( } }.orEmpty() viewModelScope.launch { - productRepository.searchProductList( - searchQuery = barcodeOptions.sku, - skuSearchOptions = WCProductStore.SkuSearchOptions.ExactSearch, - )?.let { products -> - handleFetchProductBySKUSuccess(products, selectedItems, source, barcodeOptions) - } ?: run { + viewState = viewState.copy(isUpdatingOrderDraft = true) + val result = fetchProductBySKU(barcodeOptions.sku, barcodeOptions.barcodeFormat) + if (result.isSuccess) { + val product = result.getOrNull() + if (product != null) { + handleFetchProductBySKUSuccess( + product, + selectedItems, + source, + barcodeOptions + ) + } else { + handleFetchProductBySKUEmpty(barcodeOptions, source) + } + } else { handleFetchProductBySKUFailure( source, barcodeOptions, "Product search via SKU API call failed" ) } + viewState = viewState.copy(isUpdatingOrderDraft = false) } } private fun handleFetchProductBySKUSuccess( - products: List, + product: com.woocommerce.android.model.Product, selectedItems: List, source: ScanningSource, barcodeOptions: BarcodeOptions ) { viewState = viewState.copy(isUpdatingOrderDraft = false) - products.firstOrNull()?.let { product -> - addScannedProduct(product, selectedItems, source, barcodeOptions.barcodeFormat) - } ?: run { - handleFetchProductBySKUEmpty(barcodeOptions, source) - } + addScannedProduct(product, selectedItems, source, barcodeOptions.barcodeFormat) } private fun handleFetchProductBySKUEmpty( barcodeOptions: BarcodeOptions, source: ScanningSource ) { - if (shouldWeRetryProductSearchByRemovingTheCheckDigitFor(barcodeOptions)) { - fetchProductBySKURemovingCheckDigit(barcodeOptions) - } else { - handleFetchProductBySKUFailure( - source, - barcodeOptions, - "Empty data response (no product found for the SKU)" - ) - } + handleFetchProductBySKUFailure( + source, + barcodeOptions, + "Empty data response (no product found for the SKU)" + ) } private fun handleFetchProductBySKUFailure( @@ -978,30 +982,6 @@ class OrderCreateEditViewModel @Inject constructor( ) } - private fun fetchProductBySKURemovingCheckDigit(barcodeOptions: BarcodeOptions) { - viewState = viewState.copy(isUpdatingOrderDraft = true) - fetchProductBySKU( - barcodeOptions.copy( - sku = checkDigitRemoverFactory.getCheckDigitRemoverFor( - barcodeOptions.barcodeFormat - ).getSKUWithoutCheckDigit(barcodeOptions.sku), - shouldHandleCheckDigitOnFailure = false - ) - ) - } - - private fun shouldWeRetryProductSearchByRemovingTheCheckDigitFor(barcodeOptions: BarcodeOptions) = - (isBarcodeFormatUPC(barcodeOptions) || isBarcodeFormatEAN(barcodeOptions)) && - barcodeOptions.shouldHandleCheckDigitOnFailure - - private fun isBarcodeFormatUPC(barcodeOptions: BarcodeOptions) = - barcodeOptions.barcodeFormat == BarcodeFormat.FormatUPCA || - barcodeOptions.barcodeFormat == BarcodeFormat.FormatUPCE - - private fun isBarcodeFormatEAN(barcodeOptions: BarcodeOptions) = - barcodeOptions.barcodeFormat == BarcodeFormat.FormatEAN13 || - barcodeOptions.barcodeFormat == BarcodeFormat.FormatEAN8 - @Suppress("LongMethod", "ReturnCount") private fun addScannedProduct( product: ModelProduct, diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/creation/configuration/ProductConfigurationFragment.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/creation/configuration/ProductConfigurationFragment.kt index 6ae9de79de9..ffd982da11e 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/creation/configuration/ProductConfigurationFragment.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/creation/configuration/ProductConfigurationFragment.kt @@ -8,7 +8,6 @@ import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController -import com.google.gson.Gson import com.woocommerce.android.R import com.woocommerce.android.extensions.handleResult import com.woocommerce.android.extensions.navigateBackWithResult @@ -31,8 +30,6 @@ class ProductConfigurationFragment : BaseFragment() { override val activityAppBarStatus: AppBarStatus = AppBarStatus.Hidden - private val gson = Gson() - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { return ComposeView(requireContext()).apply { id = R.id.product_configuration_view @@ -80,12 +77,7 @@ class ProductConfigurationFragment : BaseFragment() { private fun handleResults() { handleResult(VariationPickerFragment.VARIATION_PICKER_RESULT) { - val value = mapOf( - VariableProductRule.VARIATION_ID to it.variationId, - VariableProductRule.VARIATION_ATTRIBUTES to it.attributes - ) - val valueString = gson.toJson(value) - viewModel.onUpdateChildrenConfiguration(it.itemId, VariableProductRule.KEY, valueString) + viewModel.onUpdateVariationConfiguration(it.itemId, it.variationId, it.attributes) } } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/creation/configuration/ProductConfigurationScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/creation/configuration/ProductConfigurationScreen.kt index ea347627e8c..b139c83a2d4 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/creation/configuration/ProductConfigurationScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/creation/configuration/ProductConfigurationScreen.kt @@ -66,11 +66,8 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import coil.request.ImageRequest -import com.google.gson.Gson -import com.google.gson.reflect.TypeToken import com.woocommerce.android.R import com.woocommerce.android.extensions.formatToString -import com.woocommerce.android.model.VariantOption import com.woocommerce.android.ui.compose.component.SelectionCheck import com.woocommerce.android.ui.compose.component.WCColoredButton import com.woocommerce.android.ui.compose.theme.WooThemeWithBackground @@ -157,8 +154,8 @@ fun ProductConfigurationScreen( ?.get(childMapEntry.key) ?.get(QuantityRule.KEY) as? QuantityRule - val attributes = childMapEntry.value[VariableProductRule.KEY] - .toAttributesFromConfigurationStringOrNull() + val attributes = productConfiguration.variationAttributesSelection[item.id]?.attributes + ?: emptyList() val quantity = childMapEntry.value[QuantityRule.KEY]?.toFloatOrNull() ?: 0f val isIncluded = childMapEntry.value[OptionalRule.KEY]?.toBoolean() ?: false @@ -188,8 +185,8 @@ fun ProductConfigurationScreen( ?.get(childMapEntry.key) ?.get(QuantityRule.KEY) as? QuantityRule - val attributes = childMapEntry.value[VariableProductRule.KEY] - .toAttributesFromConfigurationStringOrNull() + val attributes = productConfiguration.variationAttributesSelection[item.id]?.attributes + ?: emptyList() val quantity = childMapEntry.value[QuantityRule.KEY]?.toFloatOrNull() ?: 0f val isIncluded = quantity > 0f @@ -770,7 +767,7 @@ fun VariableQuantityProductItem( modifier: Modifier = Modifier, maxValue: Float? = null, minValue: Float? = null, - attributes: List? = null, + attributes: List, isSelectionEnabled: Boolean = true ) { val description = stringResource(id = R.string.order_configuration_product_selection, title) @@ -856,7 +853,7 @@ fun OptionalVariableQuantityProductItem( modifier: Modifier = Modifier, maxValue: Float? = null, minValue: Float? = null, - attributes: List? = null, + attributes: List, isSelectionEnabled: Boolean = true ) { val description = stringResource(id = R.string.order_configuration_product_selection, title) @@ -929,7 +926,7 @@ fun OptionalVariableQuantityProductItem( @Composable fun VariableSelection( - attributes: List?, + attributes: List?, onSelectAttributes: () -> Unit, modifier: Modifier = Modifier ) { @@ -946,7 +943,9 @@ fun VariableSelection( withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { append(attribute.name) } - append(" ${attribute.option}") + val selectedOption = attribute.selectedOption + ?: stringResource(id = R.string.product_any_attribute_hint) + append(" $selectedOption") } Text(text = annotatedString, modifier = Modifier.padding(top = 8.dp)) } @@ -988,23 +987,8 @@ fun VariableQuantityProductItemPreview() { info = "Attribute 1 • Attribute 2", quantity = 1f, onQuantityChanged = {}, - onSelectAttributes = {} + onSelectAttributes = {}, + attributes = emptyList() ) } } - -@Suppress("UNCHECKED_CAST") -private fun String?.toAttributesFromConfigurationStringOrNull(): List? { - return this?.runCatching { - val gson = Gson() - val mapType = object : TypeToken>() {}.type - val map = gson.fromJson>(this, mapType) - (map[VariableProductRule.VARIATION_ATTRIBUTES] as? List>)?.mapNotNull { attribute -> - VariantOption( - id = attribute["id"] as? Long, - name = attribute["name"]?.toString(), - option = attribute["option"]?.toString() - ).takeIf { variantOption -> variantOption != VariantOption.empty } - } - }?.getOrNull() -} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/creation/configuration/ProductConfigurationViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/creation/configuration/ProductConfigurationViewModel.kt index e61353baceb..b33f40833e7 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/creation/configuration/ProductConfigurationViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/creation/configuration/ProductConfigurationViewModel.kt @@ -3,6 +3,7 @@ package com.woocommerce.android.ui.orders.creation.configuration import android.os.Parcelable import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope +import com.google.gson.Gson import com.woocommerce.android.R import com.woocommerce.android.analytics.AnalyticsEvent import com.woocommerce.android.analytics.AnalyticsTracker.Companion.KEY_CHANGED_FIELD @@ -11,7 +12,11 @@ import com.woocommerce.android.analytics.AnalyticsTracker.Companion.VALUE_CHANGE import com.woocommerce.android.analytics.AnalyticsTracker.Companion.VALUE_CHANGED_FIELD_VARIATION import com.woocommerce.android.analytics.AnalyticsTracker.Companion.VALUE_OTHER import com.woocommerce.android.analytics.AnalyticsTrackerWrapper +import com.woocommerce.android.model.VariantOption import com.woocommerce.android.ui.orders.creation.GetProductRules +import com.woocommerce.android.ui.orders.creation.configuration.VariableProductRule.Companion.VARIATION_ATTRIBUTES +import com.woocommerce.android.ui.orders.creation.configuration.VariableProductRule.Companion.VARIATION_ID +import com.woocommerce.android.ui.products.variations.picker.VariationPickerViewModel.OptionalVariantAttribute import com.woocommerce.android.util.StringUtils import com.woocommerce.android.viewmodel.MultiLiveEvent import com.woocommerce.android.viewmodel.ResourceProvider @@ -23,6 +28,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize import javax.inject.Inject @@ -49,6 +55,8 @@ class ProductConfigurationViewModel @Inject constructor( private val productsInformation = getChildrenProductInfo(productId).toStateFlow(null) + private val gson by lazy { Gson() } + val viewState = combine( flow = rules.drop(1), flow2 = configuration.drop(1), @@ -92,6 +100,36 @@ class ProductConfigurationViewModel @Inject constructor( } } + /** + * The Bundles API demand that all variation attributes have a defined option, + * not effectively supporting Any as an option, since the Any selection is represented as null. + * + * However, the order creation will follow the attributes configuration set in the [VariantOption.id], + * which will eventually map the attributes to the defined options of the Variation, not the provided ones + * during the Bundle setup. + * + * The [OptionalVariantAttribute.defaultOption] serves exactly this purpose, it will select any option when + * Any is selected, or keep the already selected otherwise. Once the Order is created, Woo Core will ignore this + * information and just use the attributes defined for that Variation. + */ + fun onUpdateVariationConfiguration(itemId: Long, variationId: Long, attributes: List) { + configuration.update { currentConfig -> + val newConfig = currentConfig?.updateVariationAttributesConfiguration(itemId, variationId, attributes) + + attributes.takeIf { it.isNotEmpty() } + ?.map { VariantOption(it.id, it.name, it.defaultOption) } + ?.filter { it.option != null } + .let { mapOf(VARIATION_ID to variationId, VARIATION_ATTRIBUTES to it) } + .let { gson.toJson(it) } + .let { newConfig?.updateChildrenConfiguration(itemId, VariableProductRule.KEY, it) } + } + + tracker.track( + AnalyticsEvent.ORDER_FORM_BUNDLE_PRODUCT_CONFIGURATION_CHANGED, + mapOf(KEY_CHANGED_FIELD to VALUE_CHANGED_FIELD_VARIATION) + ) + } + fun onCancel() { triggerEvent(MultiLiveEvent.Event.Exit) } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/creation/configuration/ProductRules.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/creation/configuration/ProductRules.kt index 52c7dacd1aa..cf385b37dd9 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/creation/configuration/ProductRules.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/creation/configuration/ProductRules.kt @@ -7,6 +7,7 @@ import com.woocommerce.android.extensions.formatToString import com.woocommerce.android.extensions.sumByFloat import com.woocommerce.android.model.VariantOption import com.woocommerce.android.ui.products.ProductType +import com.woocommerce.android.ui.products.variations.picker.VariationPickerViewModel.OptionalVariantAttribute import com.woocommerce.android.util.StringUtils import com.woocommerce.android.viewmodel.ResourceProvider import kotlinx.parcelize.Parcelize @@ -147,7 +148,8 @@ class ProductConfiguration( val rules: ProductRules, val configurationType: ConfigurationType, val configuration: Map, - val childrenConfiguration: Map>? = null + val childrenConfiguration: Map>? = null, + val variationAttributesSelection: Map = emptyMap() ) : Parcelable { companion object { const val PARENT_KEY = -1L @@ -207,15 +209,15 @@ class ProductConfiguration( val isIncluded = isOptional && optionalValue || isOptional.not() && itemQuantity > 0f val isVariable = entry.value.containsKey(VariableProductRule.KEY) - val attributesAreNullOrEmpty = entry.value[VariableProductRule.KEY].isNullOrEmpty() + val attributesAreNull = entry.value[VariableProductRule.KEY] == null - if (isIncluded && isVariable && attributesAreNullOrEmpty) { + if (isIncluded && isVariable && attributesAreNull) { issues[entry.key] = resourceProvider.getString(R.string.configuration_variable_selection) } } } - fun updateChildrenConfiguration(itemId: Long, ruleKey: String, value: String): ProductConfiguration { + fun updateChildrenConfiguration(itemId: Long, ruleKey: String, value: String?): ProductConfiguration { val updatedChildConfiguration = childrenConfiguration?.get(itemId)?.let { childConfiguration -> val mutableConfiguration = childConfiguration.toMutableMap() mutableConfiguration[ruleKey] = value @@ -231,11 +233,57 @@ class ProductConfiguration( rules, configurationType, configuration, - updatedChildrenConfiguration + updatedChildrenConfiguration, + variationAttributesSelection ) } + + fun updateVariationAttributesConfiguration( + itemId: Long, + variationId: Long, + attributes: List + ): ProductConfiguration { + val variantAttributes = attributes.map { + VariantAttribute( + id = it.id, + name = it.name, + selectedOption = it.option, + selectableOptions = it.selectableOptions + ) + } + + val updatedVariableProductSelection = variationAttributesSelection[itemId] + ?.copy(variationId = variationId, attributes = variantAttributes) + ?: VariableProductSelection(variationId, variantAttributes) + + return variationAttributesSelection.toMutableMap() + .apply { put(itemId, updatedVariableProductSelection) } + .let { updatedVariableProductSelectionMap -> + ProductConfiguration( + rules, + configurationType, + configuration, + childrenConfiguration, + updatedVariableProductSelectionMap + ) + } + } } +@Parcelize +data class VariableProductSelection( + val variationId: Long, + val attributes: List +) : Parcelable + +@Parcelize +data class VariantAttribute( + val id: Long?, + val name: String?, + val selectedOption: String?, + val selectableOptions: List +) : Parcelable + enum class ConfigurationType { BUNDLE, UNKNOWN } fun ProductType.getConfigurationType(): ConfigurationType { diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/list/OrderListFragment.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/list/OrderListFragment.kt index 4d3a75e53ec..463ecd55b49 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/list/OrderListFragment.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/list/OrderListFragment.kt @@ -709,7 +709,9 @@ class OrderListFragment : } private fun showOrderFilters() { - findNavController().navigateSafely(R.id.action_orderListFragment_to_orderFilterListFragment) + findNavController().navigateSafely( + OrderListFragmentDirections.actionOrderListFragmentToOrderFilterListFragment() + ) } private fun navigateToTryTestOrderScreen() { diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/list/OrderListViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/list/OrderListViewModel.kt index eafebf73832..15690ff1ea3 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/list/OrderListViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/list/OrderListViewModel.kt @@ -270,7 +270,7 @@ class OrderListViewModel @Inject constructor( ) val listId = listDescriptor.uniqueIdentifier.value launch { - if (shouldUpdateOrdersList(listId)) { + if (shouldUpdateOrdersList(listDescriptor)) { fetchOrdersAndOrderDependencies() } else { // List is displayed from cache diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/list/ShouldUpdateOrdersList.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/list/ShouldUpdateOrdersList.kt index e021e79f674..c51912fc1dd 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/list/ShouldUpdateOrdersList.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/list/ShouldUpdateOrdersList.kt @@ -2,12 +2,21 @@ package com.woocommerce.android.ui.orders.list import com.woocommerce.android.background.LastUpdateDataStore import kotlinx.coroutines.flow.first +import org.wordpress.android.fluxc.model.list.ListDescriptor +import org.wordpress.android.fluxc.model.list.ListState +import org.wordpress.android.fluxc.store.ListStore import javax.inject.Inject -class ShouldUpdateOrdersList @Inject constructor(private val lastUpdateDataStore: LastUpdateDataStore) { - suspend operator fun invoke(listId: Int): Boolean { - return lastUpdateDataStore.getLastUpdateKeyByOrdersListId(listId).let { key -> +class ShouldUpdateOrdersList @Inject constructor( + private val lastUpdateDataStore: LastUpdateDataStore, + private val listStore: ListStore +) { + suspend operator fun invoke(listDescriptor: ListDescriptor): Boolean { + val listId = listDescriptor.uniqueIdentifier.value + val shouldUpdateByState = listStore.getListState(listDescriptor) == ListState.NEEDS_REFRESH + val shouldUpdateByCache = lastUpdateDataStore.getLastUpdateKeyByOrdersListId(listId).let { key -> lastUpdateDataStore.shouldUpdateData(key).first() } + return shouldUpdateByState || shouldUpdateByCache } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/shippinglabels/creation/EditShippingLabelPackagesFragment.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/shippinglabels/creation/EditShippingLabelPackagesFragment.kt index 9132fb72ed0..0dd06f7562b 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/shippinglabels/creation/EditShippingLabelPackagesFragment.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/shippinglabels/creation/EditShippingLabelPackagesFragment.kt @@ -18,6 +18,7 @@ import com.woocommerce.android.extensions.navigateSafely import com.woocommerce.android.extensions.takeIfNotEqualTo import com.woocommerce.android.ui.base.BaseFragment import com.woocommerce.android.ui.base.UIMessageResolver +import com.woocommerce.android.ui.main.AppBarStatus import com.woocommerce.android.ui.main.MainActivity.Companion.BackPressListener import com.woocommerce.android.ui.orders.shippinglabels.creation.EditShippingLabelPackagesViewModel.OpenHazmatCategorySelector import com.woocommerce.android.ui.orders.shippinglabels.creation.EditShippingLabelPackagesViewModel.OpenPackageCreatorEvent @@ -63,9 +64,10 @@ class EditShippingLabelPackagesFragment : ) } - private val skeletonView: SkeletonView = SkeletonView() + override val activityAppBarStatus: AppBarStatus + get() = AppBarStatus.Hidden - override fun getFragmentTitle() = getString(R.string.orderdetail_shipping_label_item_package_info) + private val skeletonView: SkeletonView = SkeletonView() fun onMenuItemSelected(item: MenuItem): Boolean { return when (item.itemId) { diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/shippinglabels/creation/EditShippingLabelPackagesViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/shippinglabels/creation/EditShippingLabelPackagesViewModel.kt index 6487361c972..09674406f31 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/shippinglabels/creation/EditShippingLabelPackagesViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/shippinglabels/creation/EditShippingLabelPackagesViewModel.kt @@ -102,7 +102,7 @@ class EditShippingLabelPackagesViewModel @Inject constructor( loadProductsWeightsIfNeeded(order) val items = order.getShippableItems().map { it.toShippingItem() } - val totalWeight = items.sumByFloat { it.weight * it.quantity } + (lastUsedPackage?.boxWeight ?: 0f) + val totalWeight = getPackageTotalWeight(items, lastUsedPackage?.boxWeight ?: 0f) return listOf( ShippingLabelPackage( position = 1, @@ -113,10 +113,14 @@ class EditShippingLabelPackagesViewModel @Inject constructor( ) } + private fun getPackageTotalWeight(items: List, packageWeight: Float): Float { + return items.sumByFloat { it.weight * it.quantity } + packageWeight + } + private suspend fun loadProductsWeightsIfNeeded(order: Order) { suspend fun fetchProductIfNeeded(productId: Long): Boolean { if (productDetailRepository.getProduct(productId) == null) { - return productDetailRepository.fetchProductOrLoadFromCache(productId) != null || + return productDetailRepository.fetchAndGetProduct(productId) != null || productDetailRepository.lastFetchProductErrorType == ProductErrorType.INVALID_PRODUCT_ID } return true @@ -179,7 +183,7 @@ class EditShippingLabelPackagesViewModel @Inject constructor( val packages = viewState.packagesUiModels.toMutableList() val updatedPackage = with(packages[position].data) { val weight = if (!viewState.packagesWithEditedWeight.contains(packageId)) { - items.sumByFloat { it.weight } + selectedPackage.boxWeight + getPackageTotalWeight(items, selectedPackage.boxWeight) } else { weight } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/tracking/AddOrderShipmentTrackingFragment.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/tracking/AddOrderShipmentTrackingFragment.kt index aa63c44231f..9d1b18c1018 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/tracking/AddOrderShipmentTrackingFragment.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/tracking/AddOrderShipmentTrackingFragment.kt @@ -7,7 +7,8 @@ import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.appcompat.content.res.AppCompatResources -import androidx.core.widget.doOnTextChanged +import androidx.core.view.isVisible +import androidx.core.widget.doAfterTextChanged import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController import com.woocommerce.android.AppPrefs @@ -18,6 +19,7 @@ import com.woocommerce.android.extensions.handleResult import com.woocommerce.android.extensions.navigateBackWithResult import com.woocommerce.android.extensions.takeIfNotEqualTo import com.woocommerce.android.tools.NetworkStatus +import com.woocommerce.android.ui.barcodescanner.BarcodeScanningFragment.Companion.KEY_BARCODE_SCANNING_SCAN_STATUS import com.woocommerce.android.ui.base.BaseFragment import com.woocommerce.android.ui.base.UIMessageResolver import com.woocommerce.android.ui.dialog.WooDialog @@ -25,7 +27,10 @@ import com.woocommerce.android.ui.main.AppBarStatus import com.woocommerce.android.ui.main.MainActivity.Companion.BackPressListener import com.woocommerce.android.ui.orders.OrderNavigationTarget import com.woocommerce.android.ui.orders.OrderNavigator +import com.woocommerce.android.ui.orders.creation.CodeScannerStatus import com.woocommerce.android.ui.orders.tracking.AddOrderShipmentTrackingViewModel.SaveTrackingPrefsEvent +import com.woocommerce.android.ui.orders.tracking.AddOrderShipmentTrackingViewModel.SetScannedTrackingNumberEvent +import com.woocommerce.android.ui.orders.tracking.AddOrderShipmentTrackingViewModel.ShowTrackingNumberScanFailed import com.woocommerce.android.util.DateUtils import com.woocommerce.android.viewmodel.MultiLiveEvent.Event.Exit import com.woocommerce.android.viewmodel.MultiLiveEvent.Event.ExitWithResult @@ -155,6 +160,16 @@ class AddOrderShipmentTrackingFragment : AppPrefs.setSelectedShipmentTrackingProviderName(event.carrier.name) AppPrefs.setIsSelectedShipmentTrackingProviderNameCustom(event.carrier.isCustom) } + + is SetScannedTrackingNumberEvent -> { + binding.trackingNumber.setText(event.trackingNumber) + viewModel.onTrackingNumberEntered(event.trackingNumber) + } + + is ShowTrackingNumberScanFailed -> { + uiMessageResolver.showSnack(event.errorMessage) + } + else -> event.isHandled = false } } @@ -162,6 +177,10 @@ class AddOrderShipmentTrackingFragment : handleResult(AddOrderTrackingProviderListFragment.SHIPMENT_TRACKING_PROVIDER_RESULT) { viewModel.onCarrierSelected(it) } + + handleResult(KEY_BARCODE_SCANNING_SCAN_STATUS) { status -> + viewModel.handleBarcodeScannedStatus(status) + } } private fun initUi(binding: FragmentAddShipmentTrackingBinding) { @@ -169,6 +188,12 @@ class AddOrderShipmentTrackingFragment : viewModel.onCarrierClicked() } + // Let's not hide the scan button with the error icon + binding.trackingNumberLayout.errorIconDrawable = null + binding.trackingNumberLayout.setEndIconOnClickListener { + viewModel.onScanTrackingNumberClicked() + } + binding.date.setOnClickListener { val calendar = FluxCDateUtils.getCalendarInstance(viewModel.currentSelectedDate) dateShippedPickerDialog = DatePickerDialog( @@ -181,13 +206,15 @@ class AddOrderShipmentTrackingFragment : dateShippedPickerDialog?.show() } - binding.customProviderName.doOnTextChanged { text, _, _, _ -> + binding.customProviderName.doAfterTextChanged { text -> + if (!binding.customProviderNameLayout.isVisible) return@doAfterTextChanged viewModel.onCustomCarrierNameEntered(text.toString()) } - binding.trackingNumber.doOnTextChanged { text, _, _, _ -> + binding.trackingNumber.doAfterTextChanged { text -> viewModel.onTrackingNumberEntered(text.toString()) } - binding.customProviderUrl.doOnTextChanged { text, _, _, _ -> + binding.customProviderUrl.doAfterTextChanged { text -> + if (!binding.customProviderUrlLayout.isVisible) return@doAfterTextChanged viewModel.onTrackingLinkEntered(text.toString()) } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/tracking/AddOrderShipmentTrackingViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/tracking/AddOrderShipmentTrackingViewModel.kt index d032a12bb56..a9006ade031 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/tracking/AddOrderShipmentTrackingViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/tracking/AddOrderShipmentTrackingViewModel.kt @@ -10,10 +10,13 @@ import com.woocommerce.android.analytics.AnalyticsEvent.ORDER_SHIPMENT_TRACKING_ import com.woocommerce.android.analytics.AnalyticsTracker import com.woocommerce.android.model.OrderShipmentTracking import com.woocommerce.android.tools.NetworkStatus +import com.woocommerce.android.ui.orders.OrderNavigationTarget.OpenTrackingBarcodeScanning import com.woocommerce.android.ui.orders.OrderNavigationTarget.ViewShipmentTrackingProviders +import com.woocommerce.android.ui.orders.creation.CodeScannerStatus import com.woocommerce.android.ui.orders.details.OrderDetailRepository import com.woocommerce.android.viewmodel.LiveDataDelegate import com.woocommerce.android.viewmodel.MultiLiveEvent +import com.woocommerce.android.viewmodel.MultiLiveEvent.Event import com.woocommerce.android.viewmodel.MultiLiveEvent.Event.Exit import com.woocommerce.android.viewmodel.MultiLiveEvent.Event.ExitWithResult import com.woocommerce.android.viewmodel.MultiLiveEvent.Event.ShowDialog @@ -76,6 +79,18 @@ class AddOrderShipmentTrackingViewModel @Inject constructor( ) } + fun handleBarcodeScannedStatus(status: CodeScannerStatus) { + when (status) { + is CodeScannerStatus.Failure, CodeScannerStatus.NotFound -> { + triggerEvent(ShowTrackingNumberScanFailed(R.string.order_shipment_tracking_barcode_scanning_failed)) + } + + is CodeScannerStatus.Success -> { + triggerEvent(SetScannedTrackingNumberEvent(status.code)) + } + } + } + fun onTrackingLinkEntered(trackingLink: String) { addOrderShipmentTrackingViewState = addOrderShipmentTrackingViewState.copy(trackingLink = trackingLink) } @@ -93,6 +108,11 @@ class AddOrderShipmentTrackingViewModel @Inject constructor( ) } + fun onScanTrackingNumberClicked() { + addOrderShipmentTrackingViewState = addOrderShipmentTrackingViewState.copy(trackingNumberError = null) + triggerEvent(OpenTrackingBarcodeScanning) + } + fun onAddButtonTapped() { if (addOrderShipmentTrackingViewState.carrier.name.isEmpty()) { addOrderShipmentTrackingViewState = if (!addOrderShipmentTrackingViewState.carrier.isCustom) { @@ -192,4 +212,6 @@ class AddOrderShipmentTrackingViewModel @Inject constructor( ) : Parcelable data class SaveTrackingPrefsEvent(val carrier: Carrier) : MultiLiveEvent.Event() + data class SetScannedTrackingNumberEvent(val trackingNumber: String) : MultiLiveEvent.Event() + data class ShowTrackingNumberScanFailed(val errorMessage: Int) : Event() } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/tracking/AddOrderTrackingProviderListFragment.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/tracking/AddOrderTrackingProviderListFragment.kt index 29e6dc2c895..ac095759a89 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/tracking/AddOrderTrackingProviderListFragment.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/tracking/AddOrderTrackingProviderListFragment.kt @@ -16,6 +16,7 @@ import com.woocommerce.android.extensions.takeIfNotEqualTo import com.woocommerce.android.model.OrderShipmentProvider import com.woocommerce.android.ui.base.BaseFragment import com.woocommerce.android.ui.base.UIMessageResolver +import com.woocommerce.android.ui.main.AppBarStatus import com.woocommerce.android.ui.orders.tracking.AddOrderTrackingProviderListAdapter.OnProviderClickListener import com.woocommerce.android.util.StringUtils import com.woocommerce.android.viewmodel.MultiLiveEvent.Event.ExitWithResult @@ -34,6 +35,9 @@ class AddOrderTrackingProviderListFragment : const val SHIPMENT_TRACKING_PROVIDER_RESULT = "tracking-provider-result" } + override val activityAppBarStatus: AppBarStatus + get() = AppBarStatus.Hidden + @Inject lateinit var uiMessageResolver: UIMessageResolver private val viewModel: AddOrderTrackingProviderListViewModel by viewModels() diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/payments/cardreader/connect/CardReaderConnectDialogFragment.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/payments/cardreader/connect/CardReaderConnectDialogFragment.kt index a1b5d9de009..53ffecb521f 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/payments/cardreader/connect/CardReaderConnectDialogFragment.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/payments/cardreader/connect/CardReaderConnectDialogFragment.kt @@ -3,6 +3,7 @@ package com.woocommerce.android.ui.payments.cardreader.connect import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.app.Activity.RESULT_OK +import android.app.Dialog import android.bluetooth.BluetoothAdapter import android.content.ActivityNotFoundException import android.content.Intent @@ -12,6 +13,8 @@ import android.provider.Settings import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.activity.ComponentDialog +import androidx.activity.addCallback import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts.RequestPermission import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult @@ -87,6 +90,14 @@ class CardReaderConnectDialogFragment : PaymentsBaseDialogFragment(R.layout.card return super.onCreateView(inflater, container, savedInstanceState) } + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val dialog = ComponentDialog(requireContext(), theme) + dialog.onBackPressedDispatcher.addCallback(dialog) { + viewModel.onBackPressed() + } + return dialog + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { val binding = CardReaderConnectDialogBinding.bind(view) initMultipleReadersFoundRecyclerView(binding) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/payments/cardreader/connect/CardReaderConnectViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/payments/cardreader/connect/CardReaderConnectViewModel.kt index aea6caaf98d..1abbf6ba085 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/payments/cardreader/connect/CardReaderConnectViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/payments/cardreader/connect/CardReaderConnectViewModel.kt @@ -522,6 +522,10 @@ class CardReaderConnectViewModel @Inject constructor( exitFlow(connected = true) } + fun onBackPressed() { + onCancelClicked() + } + private fun exitFlow(connected: Boolean) { if (!connected) { when (val param = arguments.cardReaderFlowParam) { diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/payments/cardreader/connect/adapter/MultipleCardReadersFoundViewHolder.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/payments/cardreader/connect/adapter/MultipleCardReadersFoundViewHolder.kt index 6856f9b6695..76d5763bbe2 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/payments/cardreader/connect/adapter/MultipleCardReadersFoundViewHolder.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/payments/cardreader/connect/adapter/MultipleCardReadersFoundViewHolder.kt @@ -41,7 +41,10 @@ sealed class MultipleCardReadersFoundViewHolder( var binding: CardReaderConnectScanningItemBinding = CardReaderConnectScanningItemBinding.bind(itemView) init { - WooAnimUtils.rotate(binding.cardReaderConnectProgressIndicator) + WooAnimUtils.rotate( + binding.cardReaderConnectProgressIndicator, + rotationDirection = WooAnimUtils.RotationDirection.ANTICLOCKWISE + ) } override fun onBind(uiState: ListItemViewState) { diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/payments/methodselection/SelectPaymentMethodEvent.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/payments/methodselection/SelectPaymentMethodEvent.kt index ff7ae546a86..950dadcece2 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/payments/methodselection/SelectPaymentMethodEvent.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/payments/methodselection/SelectPaymentMethodEvent.kt @@ -23,6 +23,11 @@ data class NavigateToCardReaderPaymentFlow( val cardReaderType: CardReaderType ) : MultiLiveEvent.Event() +data class SkipScreenInPosAndNavigateToCardReaderPaymentFlow( + val cardReaderFlowParam: CardReaderFlowParam.PaymentOrRefund.Payment, + val cardReaderType: CardReaderType +) : MultiLiveEvent.Event() + data class NavigateToCardReaderRefundFlow( val cardReaderFlowParam: CardReaderFlowParam.PaymentOrRefund.Refund, val cardReaderType: CardReaderType diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/payments/methodselection/SelectPaymentMethodFragment.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/payments/methodselection/SelectPaymentMethodFragment.kt index fa839aa9ba6..7731e34a0ef 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/payments/methodselection/SelectPaymentMethodFragment.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/payments/methodselection/SelectPaymentMethodFragment.kt @@ -35,7 +35,7 @@ import com.woocommerce.android.ui.payments.methodselection.SelectPaymentMethodVi import com.woocommerce.android.ui.payments.scantopay.ScanToPayDialogFragment import com.woocommerce.android.ui.payments.taptopay.summary.TapToPaySummaryFragment import com.woocommerce.android.ui.woopos.cardreader.WooPosCardReaderActivity -import com.woocommerce.android.ui.woopos.cardreader.WooPosCardReaderPaymentResult +import com.woocommerce.android.ui.woopos.cardreader.WooPosCardReaderPaymentStatus import com.woocommerce.android.util.ChromeCustomTabUtils import com.woocommerce.android.util.UiHelpers import com.woocommerce.android.viewmodel.MultiLiveEvent.Event.ShowDialog @@ -63,12 +63,8 @@ class SelectPaymentMethodFragment : BaseFragment(R.layout.fragment_select_paymen savedInstanceState: Bundle? ): View { _binding = FragmentSelectPaymentMethodBinding.inflate(inflater, container, false) - return if (viewModel.displayUi) { - setupToolbar() - binding.root - } else { - View(requireContext()) - } + setupToolbar() + return binding.root } private fun setupToolbar() { @@ -213,6 +209,18 @@ class SelectPaymentMethodFragment : BaseFragment(R.layout.fragment_select_paymen findNavController().navigate(action) } + is SkipScreenInPosAndNavigateToCardReaderPaymentFlow -> { + if (findNavController().currentDestination?.id == R.id.selectPaymentMethodFragment) { + findNavController().navigate( + SelectPaymentMethodFragmentDirections + .actionSelectPaymentMethodFragmentToCardReaderPaymentFlow( + event.cardReaderFlowParam, + event.cardReaderType + ) + ) + } + } + is NavigateToCardReaderHubFlow -> { val action = SelectPaymentMethodFragmentDirections.actionSelectPaymentMethodFragmentToCardReaderHubFlow( @@ -299,8 +307,8 @@ class SelectPaymentMethodFragment : BaseFragment(R.layout.fragment_select_paymen private fun ReturnResultToWooPos.asWooPosCardReaderPaymentResult() = when (this) { - is ReturnResultToWooPos.Success -> WooPosCardReaderPaymentResult.Success - else -> WooPosCardReaderPaymentResult.Failure + is ReturnResultToWooPos.Success -> WooPosCardReaderPaymentStatus.Success + else -> WooPosCardReaderPaymentStatus.Failure } private fun setupResultHandlers() { diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/payments/methodselection/SelectPaymentMethodViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/payments/methodselection/SelectPaymentMethodViewModel.kt index 7a5fa808ecb..23d938de246 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/payments/methodselection/SelectPaymentMethodViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/payments/methodselection/SelectPaymentMethodViewModel.kt @@ -91,9 +91,6 @@ class SelectPaymentMethodViewModel @Inject constructor( private val cardReaderPaymentFlowParam get() = navArgs.cardReaderFlowParam as Payment - val displayUi: Boolean - get() = !isWooPOSPaymentFlow() - init { checkStatus() } @@ -123,7 +120,7 @@ class SelectPaymentMethodViewModel @Inject constructor( when (param.paymentType) { SIMPLE, ORDER, ORDER_CREATION, TRY_TAP_TO_PAY -> showPaymentState() - WOO_POS -> onBtReaderClicked() + WOO_POS -> skipScreenDuringPosFlow() } } Unit @@ -309,6 +306,10 @@ class SelectPaymentMethodViewModel @Inject constructor( } } + private fun skipScreenDuringPosFlow() { + triggerEvent(SkipScreenInPosAndNavigateToCardReaderPaymentFlow(cardReaderPaymentFlowParam, EXTERNAL)) + } + fun onTapToPayClicked() { launch { trackPaymentMethodSelection(VALUE_SIMPLE_PAYMENTS_COLLECT_CARD, VALUE_CARD_READER_TYPE_BUILT_IN) @@ -512,10 +513,6 @@ class SelectPaymentMethodViewModel @Inject constructor( wooCommerceStore.getSiteSettings(selectedSite.get())?.currencyCode ?: "" } - private fun isWooPOSPaymentFlow() = with(navArgs.cardReaderFlowParam) { - this is Payment && paymentType == WOO_POS - } - companion object { private const val DELAY_MS = 1L const val UTM_CAMPAIGN = "feature_announcement_card" diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/prefs/domain/DomainPurchaseScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/prefs/domain/DomainPurchaseScreen.kt index d1d4e7971da..fdbd100018c 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/prefs/domain/DomainPurchaseScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/prefs/domain/DomainPurchaseScreen.kt @@ -11,7 +11,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import com.woocommerce.android.ui.common.wpcomwebview.WPComWebViewAuthenticator import com.woocommerce.android.ui.compose.component.ProgressIndicator -import com.woocommerce.android.ui.compose.component.WCWebView +import com.woocommerce.android.ui.compose.component.web.WCWebView import com.woocommerce.android.ui.prefs.domain.DomainPurchaseViewModel.ViewState.CheckoutState import com.woocommerce.android.ui.prefs.domain.DomainPurchaseViewModel.ViewState.LoadingState import com.woocommerce.android.util.WooLog diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/GetComponentOptions.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/GetComponentOptions.kt index dd7de165e1a..18b6248ea5d 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/GetComponentOptions.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/GetComponentOptions.kt @@ -32,7 +32,7 @@ class GetComponentOptions @Inject constructor( } private suspend fun getDefaultValue(remoteProductId: Long?): String? { - return remoteProductId?.let { repository.fetchProductOrLoadFromCache(it)?.name } + return remoteProductId?.let { repository.fetchAndGetProduct(it)?.name } } private suspend fun getCategoriesOptions(categoriesIds: List): List { diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductHelper.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductHelper.kt index d639a19e612..e11a2a84264 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductHelper.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductHelper.kt @@ -106,7 +106,8 @@ object ProductHelper { bundleMinSize = null, bundleMaxSize = null, groupOfQuantity = null, - combineVariationQuantities = null + combineVariationQuantities = null, + password = null, ) } 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 46961eab06c..5a000ee0ee6 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 @@ -176,7 +176,6 @@ class ProductNavigator @Inject constructor() { val visibility = target.visibility.toString() val action = ProductSettingsFragmentDirections .actionProductSettingsFragmentToProductVisibilityFragment( - target.isApplicationPasswordsLogin, visibility, target.password ) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductType.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductType.kt index 20cc02ecd54..ba230334aa0 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductType.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductType.kt @@ -20,6 +20,9 @@ enum class ProductType(@StringRes val stringResource: Int = 0, val value: String fun isVariableProduct() = this == VARIABLE || this == VARIABLE_SUBSCRIPTION companion object { + val FILTERABLE_VALUES = + setOf(SIMPLE, GROUPED, EXTERNAL, VARIABLE, SUBSCRIPTION, VARIABLE_SUBSCRIPTION, BUNDLE, COMPOSITE) + fun fromString(type: String): ProductType { return when (type.lowercase(Locale.US)) { "grouped" -> GROUPED diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/DetermineProductPasswordApi.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/DetermineProductPasswordApi.kt new file mode 100644 index 00000000000..5eb0dbec7f4 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/DetermineProductPasswordApi.kt @@ -0,0 +1,42 @@ +package com.woocommerce.android.ui.products.details + +import com.woocommerce.android.extensions.semverCompareTo +import com.woocommerce.android.tools.SelectedSite +import com.woocommerce.android.tools.SiteConnectionType +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.wordpress.android.fluxc.store.WooCommerceStore +import javax.inject.Inject + +class DetermineProductPasswordApi @Inject constructor( + private val selectedSite: SelectedSite, + private val wooCommerceStore: WooCommerceStore +) { + suspend operator fun invoke(): ProductPasswordApi { + val wcVersion = withContext(Dispatchers.IO) { + wooCommerceStore.getSitePlugin( + selectedSite.get(), + WooCommerceStore.WooPlugin.WOO_CORE + )?.version + } + + return when { + wcVersion == null || + wcVersion.semverCompareTo("8.1.0") < 0 -> { + if (selectedSite.connectionType != SiteConnectionType.ApplicationPasswords) { + ProductPasswordApi.WPCOM + } else { + ProductPasswordApi.UNSUPPORTED + } + } + + else -> ProductPasswordApi.CORE + } + } +} + +enum class ProductPasswordApi { + WPCOM, + CORE, + UNSUPPORTED +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailRepository.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailRepository.kt index cde8d7de5be..8605bebf736 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailRepository.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailRepository.kt @@ -79,7 +79,7 @@ class ProductDetailRepository @Inject constructor( dispatcher.unregister(this) } - suspend fun fetchProductOrLoadFromCache(remoteProductId: Long): Product? { + suspend fun fetchAndGetProduct(remoteProductId: Long): Product? { val payload = WCProductStore.FetchSingleProductPayload(selectedSite.get(), remoteProductId) val result = productStore.fetchSingleProduct(payload) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailViewModel.kt index 66bfb99ec46..e626661bc78 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailViewModel.kt @@ -115,6 +115,7 @@ import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.withIndex import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex @@ -156,6 +157,7 @@ class ProductDetailViewModel @Inject constructor( private val isBlazeEnabled: IsBlazeEnabled, private val isProductCurrentlyPromoted: IsProductCurrentlyPromoted, private val isWindowClassLargeThanCompact: IsWindowClassLargeThanCompact, + private val determineProductPasswordApi: DetermineProductPasswordApi, ) : ScopedViewModel(savedState) { companion object { private const val KEY_PRODUCT_PARAMETERS = "key_product_parameters" @@ -183,7 +185,7 @@ class ProductDetailViewModel @Inject constructor( savedState = savedState, initialValue = ProductDetailViewState(areImagesAvailable = !selectedSite.get().isPrivate) ) { old, new -> - if (old?.productDraft != new.productDraft || old?.draftPassword != new.draftPassword) { + if (old?.productDraft != new.productDraft) { new.productDraft?.let { updateCards(it) draftChanges.value = it @@ -295,9 +297,7 @@ class ProductDetailViewModel @Inject constructor( private val _hasChanges = storedProduct .combine(draftChanges) { storedProduct, productDraft -> - storedProduct?.let { product -> - productDraft?.isSameProduct(product) == false || viewState.isPasswordChanged - } ?: false + storedProduct?.let { product -> productDraft?.isSameProduct(product) == false } ?: false }.stateIn(viewModelScope, SharingStarted.Eagerly, false) val hasChanges = _hasChanges.asLiveData() @@ -678,7 +678,7 @@ class ProductDetailViewModel @Inject constructor( when (variationRepository.bulkCreateVariations(remoteProductId, variationCandidates)) { RequestResult.SUCCESS -> { tracker.track(AnalyticsEvent.PRODUCT_VARIATION_GENERATION_SUCCESS) - productRepository.fetchProductOrLoadFromCache(remoteProductId) + productRepository.fetchAndGetProduct(remoteProductId) ?.also { updateProductState(productToUpdateFrom = it) } triggerEvent(ProductExitEvent.ExitAttributesAdded) } @@ -701,7 +701,7 @@ class ProductDetailViewModel @Inject constructor( ) variationRepository.createEmptyVariation(draft) ?.let { - productRepository.fetchProductOrLoadFromCache(draft.remoteId) + productRepository.fetchAndGetProduct(draft.remoteId) ?.also { updateProductState(productToUpdateFrom = it) } } }.also { @@ -716,7 +716,7 @@ class ProductDetailViewModel @Inject constructor( return if (storedProduct.value?.hasSettingsChanges(viewState.productDraft) == true) { true } else { - viewState.isPasswordChanged + storedProduct.value?.password != viewState.draftPassword } } @@ -1172,7 +1172,7 @@ class ProductDetailViewModel @Inject constructor( */ fun onSettingsVisibilityButtonClicked() { val visibility = getProductVisibility() - val password = viewState.draftPassword ?: viewState.storedPassword + val password = viewState.draftPassword ?: storedProduct.value?.password triggerEvent( ProductNavigationTarget.ViewProductVisibility( selectedSite.connectionType == SiteConnectionType.ApplicationPasswords, @@ -1502,7 +1502,9 @@ class ProductDetailViewModel @Inject constructor( * the product's visibility and/or password */ fun updateProductVisibility(visibility: ProductVisibility, password: String?) { - viewState = viewState.copy(draftPassword = password) + viewState = viewState.copy( + productDraft = viewState.productDraft?.copy(password = password) + ) when (visibility) { PUBLIC -> { @@ -1527,7 +1529,7 @@ class ProductDetailViewModel @Inject constructor( */ fun getProductVisibility(): ProductVisibility { val status = viewState.productDraft?.status ?: storedProduct.value?.status - val password = viewState.draftPassword ?: viewState.storedPassword + val password = viewState.draftPassword ?: storedProduct.value?.password return when { password?.isNotEmpty() == true -> { ProductVisibility.PASSWORD_PROTECTED @@ -1547,23 +1549,22 @@ class ProductDetailViewModel @Inject constructor( * Sends a request to fetch the product's password */ private suspend fun fetchProductPassword(remoteProductId: Long) { - val password = productRepository.fetchProductPassword(remoteProductId) - - viewState = if (viewState.draftPassword == null) { - viewState.copy( - storedPassword = password, - draftPassword = password - ) - } else { - viewState.copy( - storedPassword = password - ) + val productPasswordApi = determineProductPasswordApi() + val password = when (productPasswordApi) { + ProductPasswordApi.WPCOM -> productRepository.fetchProductPassword(remoteProductId) + ProductPasswordApi.CORE -> storedProduct.value?.password + ProductPasswordApi.UNSUPPORTED -> return } + + storedProduct.update { it?.copy(password = password) } + viewState = viewState.copy( + productDraft = viewState.productDraft?.copy(password = viewState.draftPassword ?: password) + ) } private suspend fun fetchProduct(remoteProductId: Long) { if (checkConnection()) { - val fetchedProduct = productRepository.fetchProductOrLoadFromCache(remoteProductId) + val fetchedProduct = productRepository.fetchAndGetProduct(remoteProductId) if (fetchedProduct != null) { updateProductState(fetchedProduct) } else { @@ -1664,7 +1665,7 @@ class ProductDetailViewModel @Inject constructor( fun renameAttributeInDraft(attributeId: Long, oldAttributeName: String, newAttributeName: String): Boolean { // first make sure an attribute with the new name doesn't already exist in the draft productDraftAttributes.forEach { - if (it.name.equals(newAttributeName, ignoreCase = true)) { + if (it.name.equals(newAttributeName)) { triggerEvent(ShowSnackbar(R.string.product_attribute_name_already_exists)) return false } @@ -1948,13 +1949,15 @@ class ProductDetailViewModel @Inject constructor( viewState = viewState.copy(isProgressDialogShown = false) return } - val result = productRepository.updateProduct(product) + val result = productRepository.updateProduct(product.copy(password = viewState.draftPassword)) if (result.first) { val successMsg = pickProductUpdateSuccessText(isPublish) - if (viewState.isPasswordChanged) { - val password = viewState.draftPassword + val isPasswordChanged = storedProduct.value?.password != viewState.draftPassword + if (isPasswordChanged && determineProductPasswordApi() == ProductPasswordApi.WPCOM) { + // Update the product password using WordPress.com API + val password = viewState.productDraft?.password if (productRepository.updateProductPassword(product.remoteId, password)) { - viewState = viewState.copy(storedPassword = password) + storedProduct.update { it?.copy(password = password) } triggerEvent(ShowSnackbar(successMsg)) } else { triggerEvent(ShowSnackbar(R.string.product_detail_update_product_password_error)) @@ -2685,16 +2688,14 @@ class ProductDetailViewModel @Inject constructor( val auxiliaryState: AuxiliaryState = AuxiliaryState.None, val uploadingImageUris: List? = null, val isProgressDialogShown: Boolean? = null, - val storedPassword: String? = null, - val draftPassword: String? = null, val showBottomSheetButton: Boolean? = null, val isConfirmingTrash: Boolean = false, val isUploadingDownloadableFile: Boolean? = null, val isVariationListEmpty: Boolean? = null, val areImagesAvailable: Boolean ) : Parcelable { - val isPasswordChanged: Boolean - get() = storedPassword != draftPassword + val draftPassword + get() = productDraft?.password @Parcelize sealed class AuxiliaryState : Parcelable { diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/filter/ProductFilterListAdapter.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/filter/ProductFilterListAdapter.kt index 179b87d7bb7..d7857a9f882 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/filter/ProductFilterListAdapter.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/filter/ProductFilterListAdapter.kt @@ -4,13 +4,14 @@ import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView +import com.woocommerce.android.R import com.woocommerce.android.databinding.FilterListItemBinding import com.woocommerce.android.ui.products.filter.ProductFilterListAdapter.ProductFilterViewHolder import com.woocommerce.android.ui.products.filter.ProductFilterListViewModel.FilterListItemUiModel -import com.woocommerce.android.ui.products.filter.ProductFilterListViewModel.FilterListOptionItemUiModel class ProductFilterListAdapter( - private val clickListener: OnProductFilterClickListener + private val clickListener: OnProductFilterClickListener, + private val resourceProvider: (resourceId: Int) -> String ) : RecyclerView.Adapter() { var filterList = listOf() set(value) { @@ -39,7 +40,10 @@ class ProductFilterListAdapter( } override fun onBindViewHolder(holder: ProductFilterViewHolder, position: Int) { - holder.bind(filterList[position]) + holder.bind( + filterItem = filterList[position], + defaultFilterOption = resourceProvider(R.string.product_filter_default) + ) holder.itemView.setOnClickListener { clickListener.onProductFilterClick(position) } @@ -51,12 +55,9 @@ class ProductFilterListAdapter( class ProductFilterViewHolder(val viewBinding: FilterListItemBinding) : RecyclerView.ViewHolder(viewBinding.root) { - fun bind(filter: FilterListItemUiModel) { - viewBinding.filterItemName.text = filter.filterItemName - viewBinding.filterItemSelection.text = - filter.filterOptionListItems - .filterIsInstance() - .first { it.isSelected }.filterOptionItemName + fun bind(filterItem: FilterListItemUiModel, defaultFilterOption: String) { + viewBinding.filterItemName.text = filterItem.filterItemName + viewBinding.filterItemSelection.text = filterItem.firstSelectedOption ?: defaultFilterOption } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/filter/ProductFilterListFragment.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/filter/ProductFilterListFragment.kt index c360cdc8038..843d99d35a7 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/filter/ProductFilterListFragment.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/filter/ProductFilterListFragment.kt @@ -53,7 +53,10 @@ class ProductFilterListFragment : requireActivity().addMenuProvider(this, viewLifecycleOwner) setupObservers(viewModel) - productFilterListAdapter = ProductFilterListAdapter(this) + productFilterListAdapter = ProductFilterListAdapter( + clickListener = this, + resourceProvider = { requireContext().getString(it) } + ) with(binding.filterList) { addItemDecoration( DividerItemDecoration( diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/filter/ProductFilterListViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/filter/ProductFilterListViewModel.kt index 799bc8f3c2a..a1eda79ed18 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/filter/ProductFilterListViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/filter/ProductFilterListViewModel.kt @@ -22,6 +22,7 @@ import com.woocommerce.android.ui.products.ProductStatus import com.woocommerce.android.ui.products.ProductStockStatus import com.woocommerce.android.ui.products.ProductType import com.woocommerce.android.ui.products.categories.ProductCategoriesRepository +import com.woocommerce.android.ui.products.filter.ProductFilterListViewModel.FilterListOptionItemUiModel.DefaultFilterListOptionItemUiModel import com.woocommerce.android.viewmodel.LiveDataDelegate import com.woocommerce.android.viewmodel.MultiLiveEvent import com.woocommerce.android.viewmodel.ResourceProvider @@ -184,7 +185,7 @@ class ProductFilterListViewModel @Inject constructor( } private fun getTypeFilterWithExploreOptions(): MutableList { - return ProductType.values().filterNot { it == ProductType.OTHER }.map { + return ProductType.FILTERABLE_VALUES.map { when { it == ProductType.BUNDLE && isPluginInstalled(it) == false -> { FilterListOptionItemUiModel.ExploreOptionItemUiModel( @@ -219,7 +220,7 @@ class ProductFilterListViewModel @Inject constructor( } else -> { - FilterListOptionItemUiModel.DefaultFilterListOptionItemUiModel( + DefaultFilterListOptionItemUiModel( resourceProvider.getString(it.stringResource), filterOptionItemValue = it.value, isSelected = productFilterOptions[TYPE] == it.value @@ -517,7 +518,13 @@ class ProductFilterListViewModel @Inject constructor( val filterItemKey: ProductFilterOption, val filterItemName: String, var filterOptionListItems: List - ) : Parcelable + ) : Parcelable { + val firstSelectedOption: String? + get() = filterOptionListItems + .filterIsInstance() + .firstOrNull { it.isSelected } + ?.filterOptionItemName + } @Parcelize sealed class FilterListOptionItemUiModel : Parcelable { diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/settings/ProductVisibilityFragment.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/settings/ProductVisibilityFragment.kt index c654b64bbdd..306eb09f9d3 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/settings/ProductVisibilityFragment.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/settings/ProductVisibilityFragment.kt @@ -6,20 +6,27 @@ import android.view.View import android.view.View.OnClickListener import android.widget.CheckedTextView import androidx.annotation.IdRes +import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.navArgs import com.woocommerce.android.R import com.woocommerce.android.analytics.AnalyticsTracker import com.woocommerce.android.databinding.FragmentProductVisibilityBinding +import com.woocommerce.android.ui.products.details.DetermineProductPasswordApi +import com.woocommerce.android.ui.products.details.ProductPasswordApi import com.woocommerce.android.ui.products.settings.ProductVisibility.PASSWORD_PROTECTED import com.woocommerce.android.ui.products.settings.ProductVisibility.PRIVATE import com.woocommerce.android.ui.products.settings.ProductVisibility.PUBLIC import com.woocommerce.android.util.setupTabletSecondPaneToolbar +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize import org.wordpress.android.util.ActivityUtils +import javax.inject.Inject /** * Settings screen which enables choosing a product's visibility */ +@AndroidEntryPoint class ProductVisibilityFragment : BaseProductSettingsFragment(R.layout.fragment_product_visibility), OnClickListener { companion object { const val ARG_VISIBILITY = "visibility" @@ -27,6 +34,9 @@ class ProductVisibilityFragment : BaseProductSettingsFragment(R.layout.fragment_ const val PRODUCT_VISIBILITY_RESULT = "product-visibility" } + @Inject + lateinit var determineProductPasswordApi: DetermineProductPasswordApi + private var _binding: FragmentProductVisibilityBinding? = null private val binding get() = _binding!! @@ -48,7 +58,6 @@ class ProductVisibilityFragment : BaseProductSettingsFragment(R.layout.fragment_ val password = savedInstanceState?.getString(ARG_PASSWORD) ?: navArgs.password setupPasswordProtectedSetting( - isApplicationPasswordsLogin = navArgs.isApplicationPasswordsLogin, selectedVisibility = selectedVisibility, password = password ) @@ -65,27 +74,27 @@ class ProductVisibilityFragment : BaseProductSettingsFragment(R.layout.fragment_ } private fun setupPasswordProtectedSetting( - isApplicationPasswordsLogin: Boolean, selectedVisibility: String?, password: String? - ) { - if (isApplicationPasswordsLogin) { - // Hide "Password protected" visibility setting on login with application passwords, - // because this feature specifically uses WP.com API. + ) = viewLifecycleOwner.lifecycleScope.launch { + val productPasswordApi = determineProductPasswordApi() + if (productPasswordApi == ProductPasswordApi.UNSUPPORTED) { + // Hide "Password protected" visibility setting when not supported. binding.btnPasswordProtected.visibility = View.GONE - } else { - if (selectedVisibility == PASSWORD_PROTECTED.toString()) { - password?.let { - binding.editPassword.text = it - showPassword(it.isNotBlank()) - } + return@launch + } + + if (selectedVisibility == PASSWORD_PROTECTED.toString()) { + password?.let { + binding.editPassword.text = it + showPassword(it.isNotBlank()) } - binding.btnPasswordProtected.setOnClickListener(this) - binding.btnPasswordProtected.visibility = View.VISIBLE - binding.editPassword.setOnTextChangedListener { - if (it.toString().isNotBlank()) { - binding.editPassword.clearError() - } + } + binding.btnPasswordProtected.setOnClickListener(this@ProductVisibilityFragment) + binding.btnPasswordProtected.visibility = View.VISIBLE + binding.editPassword.setOnTextChangedListener { + if (it.toString().isNotBlank()) { + binding.editPassword.clearError() } } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/variations/VariationListViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/variations/VariationListViewModel.kt index af066c7f458..5149cbcb40f 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/variations/VariationListViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/variations/VariationListViewModel.kt @@ -170,7 +170,7 @@ class VariationListViewModel @Inject constructor( ?.let { remove(it) } }?.toList().let { _variationList.value = it } - productRepository.fetchProductOrLoadFromCache(productID) + productRepository.fetchAndGetProduct(productID) ?.let { viewState = viewState.copy(parentProduct = it) } } @@ -212,7 +212,7 @@ class VariationListViewModel @Inject constructor( private suspend fun syncProductToVariations(productID: Long) { loadVariations(productID, withSkeletonView = false) - productRepository.fetchProductOrLoadFromCache(productID) + productRepository.fetchAndGetProduct(productID) ?.let { viewState = viewState.copy(parentProduct = it) } } @@ -339,7 +339,10 @@ class VariationListViewModel @Inject constructor( RequestResult.SUCCESS -> { tracker.track(AnalyticsEvent.PRODUCT_VARIATION_GENERATION_SUCCESS) refreshVariations(remoteProductId) - viewState = viewState.copy(progressDialogState = Hidden) + + viewState = productRepository.fetchAndGetProduct(remoteProductId) + ?.let { viewState.copy(parentProduct = it, progressDialogState = Hidden) } + ?: viewState.copy(progressDialogState = Hidden) } else -> { tracker.track(AnalyticsEvent.PRODUCT_VARIATION_GENERATION_FAILURE) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/variations/picker/VariationPickerViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/variations/picker/VariationPickerViewModel.kt index 9082994197f..e1398217c58 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/variations/picker/VariationPickerViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/variations/picker/VariationPickerViewModel.kt @@ -4,16 +4,22 @@ 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.model.ProductAttribute +import com.woocommerce.android.model.ProductVariation import com.woocommerce.android.model.VariantOption import com.woocommerce.android.ui.products.variations.selector.VariationListHandler +import com.woocommerce.android.ui.products.variations.selector.VariationSelectorRepository import com.woocommerce.android.viewmodel.MultiLiveEvent import com.woocommerce.android.viewmodel.ScopedViewModel import com.woocommerce.android.viewmodel.navArgs import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.withIndex import kotlinx.coroutines.launch @@ -24,17 +30,31 @@ import javax.inject.Inject @HiltViewModel class VariationPickerViewModel @Inject constructor( savedState: SavedStateHandle, - private val variationListHandler: VariationListHandler + private val variationListHandler: VariationListHandler, + private val variationRepository: VariationSelectorRepository ) : ScopedViewModel(savedState) { companion object { private const val STATE_UPDATE_DELAY = 100L } private val navArgs: VariationPickerFragmentArgs by savedState.navArgs() + private val allowedVariations = navArgs.allowedVatiations?.toSet() ?: emptySet() private val loadingState = MutableStateFlow(LoadingState.IDLE) + private val loadingWithDebounce + get() = loadingState.withIndex().debounce { + if (it.index != 0 && it.value == LoadingState.IDLE) { + // When resetting to IDLE, wait a bit to make sure the list has been fetched from DB + STATE_UPDATE_DELAY + } else { + 0L + } + }.map { it.value } - private val allowedVariations = navArgs.allowedVatiations?.toSet() + private val parentProductFlow: Flow = flow { + val fetchedProduct = variationRepository.getProduct(navArgs.productId) + emit(fetchedProduct) + } init { viewModelScope.launch { @@ -46,30 +66,14 @@ class VariationPickerViewModel @Inject constructor( val viewSate = combine( variationListHandler.getVariationsFlow(navArgs.productId), - loadingState.withIndex() - .debounce { - if (it.index != 0 && it.value == LoadingState.IDLE) { - // When resetting to IDLE, wait a bit to make sure the list has been fetched from DB - STATE_UPDATE_DELAY - } else { - 0L - } - } - .map { it.value } - ) { variations, loadingState -> + parentProductFlow, + loadingWithDebounce + ) { variations, parentProduct, loadingState -> ViewState( loadingState = loadingState, - variations = variations.filter { variation -> - allowedVariations?.let { it.isEmpty() || variation.remoteVariationId in it } ?: true - } - .map { variation -> - VariationListItem( - id = variation.remoteVariationId, - title = variation.getName(), - imageUrl = variation.image?.source, - attributes = variation.attributes.toList() - ) - } + variations = variations + .filter { allowedVariations.isEmpty() || it.remoteVariationId in allowedVariations } + .map { it.toVariationListItem(parentProduct) } ) }.asLiveData() @@ -98,12 +102,42 @@ class VariationPickerViewModel @Inject constructor( ) } + private fun ProductVariation.toVariationListItem(parentProduct: Product?) = + VariationListItem( + id = remoteVariationId, + title = getName(parentProduct), + imageUrl = image?.source, + selectedAttributes = attributes.toList(), + selectableAttributes = parentProduct?.attributes.orEmpty() + ) + data class VariationListItem( val id: Long, val title: String, val imageUrl: String? = null, - val attributes: List - ) + private val selectedAttributes: List, + private val selectableAttributes: List + ) { + val attributes: List + get() { + val attributeSelection = mutableListOf() + + selectableAttributes.forEach { selectable -> + selectedAttributes.find { selectable.name == it.name } + ?.let { attributeSelection.add(OptionalVariantAttribute(it, selectable.terms)) } + ?: attributeSelection.add( + OptionalVariantAttribute( + id = selectable.id, + name = selectable.name, + option = null, + selectableOptions = selectable.terms + ) + ) + } + + return attributeSelection + } + } data class ViewState( val loadingState: LoadingState = LoadingState.IDLE, @@ -119,6 +153,24 @@ class VariationPickerViewModel @Inject constructor( val itemId: Long, val productId: Long, val variationId: Long, - val attributes: List + val attributes: List ) : Parcelable + + @Parcelize + data class OptionalVariantAttribute( + val id: Long?, + val name: String?, + val option: String?, + val selectableOptions: List = emptyList() + ) : Parcelable { + constructor(option: VariantOption, selectableOptions: List) : this( + id = option.id, + name = option.name, + option = option.option, + selectableOptions = selectableOptions + ) + + val defaultOption + get() = option ?: selectableOptions.firstOrNull() + } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/reviews/ReviewDetailViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/reviews/ReviewDetailViewModel.kt index c6b16a49ac8..ae224803ad8 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/reviews/ReviewDetailViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/reviews/ReviewDetailViewModel.kt @@ -59,7 +59,7 @@ class ReviewDetailViewModel @Inject constructor( viewState.productReview?.let { review -> reviewModerationHandler.postModerationRequest(review, newStatus) // Close the detail view - triggerEvent(Exit) + closeDetailScreen() } } else { // Network is not connected @@ -120,6 +120,7 @@ class ReviewDetailViewModel @Inject constructor( ) } } + RequestResult.ERROR -> triggerEvent(ShowSnackbar(R.string.wc_load_review_error)) RequestResult.API_ERROR -> Unit // Do nothing RequestResult.RETRY -> Unit // Do nothing @@ -132,12 +133,16 @@ class ReviewDetailViewModel @Inject constructor( } fun onBackPressed(): Boolean { + closeDetailScreen() + return false + } + + private fun closeDetailScreen() { if (launchedFromNotification) { triggerEvent(NavigateBackFromNotification) } else { triggerEvent(Exit) } - return false } fun onReviewReplied(reviewReply: String) { diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/sitepicker/SitePickerViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/sitepicker/SitePickerViewModel.kt index 87dc46afa6b..6c60dc24bdb 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/sitepicker/SitePickerViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/sitepicker/SitePickerViewModel.kt @@ -2,6 +2,7 @@ package com.woocommerce.android.ui.sitepicker import android.os.Parcelable import androidx.annotation.StringRes +import androidx.annotation.VisibleForTesting import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.SavedStateHandle @@ -78,7 +79,9 @@ class SitePickerViewModel @Inject constructor( */ @Suppress("OPT_IN_USAGE") val sitePickerViewStateData = LiveDataDelegate(savedState, SitePickerViewState()) - private var sitePickerViewState by sitePickerViewStateData + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + var sitePickerViewState by sitePickerViewStateData private val _sites = MutableLiveData>() val sites: LiveData> = _sites @@ -140,7 +143,10 @@ class SitePickerViewModel @Inject constructor( } private suspend fun fetchSitesFromApi(showSkeleton: Boolean) { - sitePickerViewState = sitePickerViewState.copy(isSkeletonViewVisible = showSkeleton) + sitePickerViewState = sitePickerViewState.copy( + isSkeletonViewVisible = showSkeleton, + isPrimaryBtnVisible = false + ) val startTime = System.currentTimeMillis() val result = repository.fetchWooCommerceSites() diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/WooPosIsEnabled.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/WooPosIsEnabled.kt index 4f5bb0ee216..3dcc927374a 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/WooPosIsEnabled.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/WooPosIsEnabled.kt @@ -6,6 +6,8 @@ import com.woocommerce.android.ui.payments.cardreader.onboarding.CardReaderOnboa import com.woocommerce.android.ui.payments.cardreader.onboarding.CardReaderOnboardingState import com.woocommerce.android.ui.payments.cardreader.onboarding.PluginType import com.woocommerce.android.util.GetWooCorePluginCachedVersion +import com.woocommerce.android.util.IsRemoteFeatureFlagEnabled +import com.woocommerce.android.util.RemoteFeatureFlag.WOO_POS import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.fluxc.model.payments.inperson.WCPaymentAccountResult import org.wordpress.android.fluxc.store.WCInPersonPaymentsStore @@ -20,9 +22,9 @@ class WooPosIsEnabled @Inject constructor( private val selectedSite: SelectedSite, private val ippStore: WCInPersonPaymentsStore, private val isScreenSizeAllowed: WooPosIsScreenSizeAllowed, - private val isFeatureFlagEnabled: WooPosIsFeatureFlagEnabled, private val getWooCoreVersion: GetWooCorePluginCachedVersion, private val cardReaderOnboardingChecker: CardReaderOnboardingChecker, + private val isRemoteFeatureFlagEnabled: IsRemoteFeatureFlagEnabled, ) { private var paymentAccountCache: HashMap = hashMapOf() @@ -30,7 +32,7 @@ class WooPosIsEnabled @Inject constructor( suspend operator fun invoke(): Boolean { val selectedSite = selectedSite.getOrNull() ?: return false - if (!isFeatureFlagEnabled()) return false + if (!isRemoteFeatureFlagEnabled(WOO_POS)) return false if (!isScreenSizeAllowed()) return false if (!isWooCoreSupportsOrderAutoDraftsAndExtraPaymentsProps()) return false diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/WooPosIsFeatureFlagEnabled.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/WooPosIsFeatureFlagEnabled.kt deleted file mode 100644 index 4ce7170ec5e..00000000000 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/WooPosIsFeatureFlagEnabled.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.woocommerce.android.ui.woopos - -import com.woocommerce.android.util.FeatureFlag -import javax.inject.Inject - -class WooPosIsFeatureFlagEnabled @Inject constructor() { - operator fun invoke(): Boolean { - return FeatureFlag.WOO_POS.isEnabled() - } -} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/cardreader/WooPosCardReaderActivity.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/cardreader/WooPosCardReaderActivity.kt index b4d9d5cb734..35cdbbd4551 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/cardreader/WooPosCardReaderActivity.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/cardreader/WooPosCardReaderActivity.kt @@ -37,7 +37,7 @@ class WooPosCardReaderActivity : AppCompatActivity(R.layout.activity_woo_pos_car ) { requestKey, bundle -> when (requestKey) { WOO_POS_CARD_PAYMENT_REQUEST_KEY -> { - val result = bundle.parcelable( + val result = bundle.parcelable( WOO_POS_CARD_PAYMENT_RESULT_KEY ) setResult( @@ -47,11 +47,7 @@ class WooPosCardReaderActivity : AppCompatActivity(R.layout.activity_woo_pos_car finish() } - else -> { - val errorMessage = "Unknown request key: $requestKey" - WooLog.e(WooLog.T.POS, "Error in WooPosCardReaderActivity - $errorMessage") - error(errorMessage) - } + else -> logResultListenerError(requestKey) } } @@ -64,11 +60,7 @@ class WooPosCardReaderActivity : AppCompatActivity(R.layout.activity_woo_pos_car finish() } - else -> { - val errorMessage = "Unknown request key: $requestKey" - WooLog.e(WooLog.T.POS, "Error in WooPosCardReaderActivity - $errorMessage") - error(errorMessage) - } + else -> logResultListenerError(requestKey) } } } @@ -101,6 +93,12 @@ class WooPosCardReaderActivity : AppCompatActivity(R.layout.activity_woo_pos_car } } + private fun logResultListenerError(requestKey: String) { + val errorMessage = "Unknown request key: $requestKey" + WooLog.e(WooLog.T.POS, "Error in WooPosCardReaderActivity - $errorMessage") + error(errorMessage) + } + companion object { const val WOO_POS_CARD_PAYMENT_REQUEST_KEY = "woo_pos_card_payment_request" const val WOO_POS_CARD_CONNECTION_REQUEST_KEY = "woo_pos_card_connection_request" diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/cardreader/WooPosCardReaderFacade.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/cardreader/WooPosCardReaderFacade.kt index b6dd065cd23..cd70d76a586 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/cardreader/WooPosCardReaderFacade.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/cardreader/WooPosCardReaderFacade.kt @@ -10,37 +10,39 @@ import com.woocommerce.android.cardreader.CardReaderManager import com.woocommerce.android.cardreader.connection.CardReaderStatus import com.woocommerce.android.ui.woopos.cardreader.WooPosCardReaderActivity.Companion.WOO_POS_CARD_PAYMENT_RESULT_KEY import com.woocommerce.android.util.parcelable -import dagger.hilt.android.scopes.ActivityRetainedScoped import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.flow.MutableStateFlow import javax.inject.Inject -import kotlin.coroutines.Continuation -import kotlin.coroutines.resume +import javax.inject.Singleton -@ActivityRetainedScoped +@Singleton class WooPosCardReaderFacade @Inject constructor( private val cardReaderManager: CardReaderManager ) : DefaultLifecycleObserver { - private var paymentContinuation: Continuation? = null private var paymentResultLauncher: ActivityResultLauncher? = null private var activity: AppCompatActivity? = null val readerStatus: Flow = cardReaderManager.readerStatus + private val _paymentStatus = MutableStateFlow( + WooPosCardReaderPaymentStatus.Unknown + ) + val paymentStatus: Flow = _paymentStatus + override fun onCreate(owner: LifecycleOwner) { activity = owner as AppCompatActivity paymentResultLauncher = activity!!.registerForActivityResult( ActivityResultContracts.StartActivityForResult() ) { result -> val paymentResult = if (result.data != null && result.resultCode == AppCompatActivity.RESULT_OK) { - result.data!!.parcelable( + result.data!!.parcelable( WOO_POS_CARD_PAYMENT_RESULT_KEY ) } else { - WooPosCardReaderPaymentResult.Failure + WooPosCardReaderPaymentStatus.Failure } - - paymentContinuation!!.resume(paymentResult!!) + _paymentStatus.value = paymentResult!! + _paymentStatus.value = WooPosCardReaderPaymentStatus.Unknown } } @@ -56,14 +58,12 @@ class WooPosCardReaderFacade @Inject constructor( activity!!.startActivity(intent) } - suspend fun collectPayment(orderId: Long): WooPosCardReaderPaymentResult { - return suspendCancellableCoroutine { continuation -> - paymentContinuation = continuation - val intent = WooPosCardReaderActivity.buildIntentForPayment(activity!!, orderId).apply { - addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) - } - paymentResultLauncher!!.launch(intent) + fun collectPayment(orderId: Long) { + _paymentStatus.value = WooPosCardReaderPaymentStatus.Unknown + val intent = WooPosCardReaderActivity.buildIntentForPayment(activity!!, orderId).apply { + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) } + paymentResultLauncher!!.launch(intent) } suspend fun disconnectFromReader() { diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/cardreader/WooPosCardReaderPaymentResult.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/cardreader/WooPosCardReaderPaymentResult.kt deleted file mode 100644 index 1266ad8766a..00000000000 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/cardreader/WooPosCardReaderPaymentResult.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.woocommerce.android.ui.woopos.cardreader - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Parcelize -sealed class WooPosCardReaderPaymentResult : Parcelable { - data object Success : WooPosCardReaderPaymentResult() - data object Failure : WooPosCardReaderPaymentResult() -} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/cardreader/WooPosCardReaderPaymentStatus.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/cardreader/WooPosCardReaderPaymentStatus.kt new file mode 100644 index 00000000000..a20fe929155 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/cardreader/WooPosCardReaderPaymentStatus.kt @@ -0,0 +1,11 @@ +package com.woocommerce.android.ui.woopos.cardreader + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +sealed class WooPosCardReaderPaymentStatus : Parcelable { + data object Success : WooPosCardReaderPaymentStatus() + data object Failure : WooPosCardReaderPaymentStatus() + data object Unknown : WooPosCardReaderPaymentStatus() +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/composeui/WooPosCard.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/composeui/WooPosCard.kt new file mode 100644 index 00000000000..9ac6b5d392b --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/composeui/WooPosCard.kt @@ -0,0 +1,195 @@ +package com.woocommerce.android.ui.woopos.common.composeui + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CornerBasedShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ElevationOverlay +import androidx.compose.material.LocalAbsoluteElevation +import androidx.compose.material.LocalContentColor +import androidx.compose.material.LocalElevationOverlay +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.isTraversalGroup +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * We implemented our custom card as the default Material card + * uses source of light being at the top left corner, which is not + * the case in our design. + */ +@Composable +fun WooPosCard( + modifier: Modifier = Modifier, + shape: Shape = MaterialTheme.shapes.medium, + backgroundColor: Color = MaterialTheme.colors.surface, + contentColor: Color = contentColorFor(backgroundColor), + border: BorderStroke? = null, + elevation: Dp = 1.dp, + content: @Composable () -> Unit +) { + val absoluteElevation = LocalAbsoluteElevation.current + elevation + CompositionLocalProvider( + LocalContentColor provides contentColor, + LocalAbsoluteElevation provides absoluteElevation + ) { + Box( + modifier = modifier + .surface( + shape = shape, + backgroundColor = surfaceColorAtElevation( + color = backgroundColor, + elevationOverlay = LocalElevationOverlay.current, + absoluteElevation = absoluteElevation + ), + border = border, + elevation = elevation + ) + .semantics(mergeDescendants = false) { + isTraversalGroup = true + } + .pointerInput(Unit) {}, + propagateMinConstraints = true + ) { + content() + } + } +} + +@Composable +private fun Modifier.surface( + shape: Shape, + backgroundColor: Color, + border: BorderStroke?, + elevation: Dp +): Modifier { + return this + .drawShadow( + color = Color.Black, + borderRadius = shape.toCornerRadius(LocalDensity.current), + shadowRadius = elevation, + alpha = 0.24f, + offsetX = 0.dp, + offsetY = elevation * 0.5f + ) + .then(if (border != null) Modifier.border(border, shape) else Modifier) + .background(color = backgroundColor, shape = shape) + .clip(shape) +} + +@Composable +private fun surfaceColorAtElevation( + color: Color, + elevationOverlay: ElevationOverlay?, + absoluteElevation: Dp +): Color { + return if (color == MaterialTheme.colors.surface && elevationOverlay != null) { + elevationOverlay.apply(color, absoluteElevation) + } else { + color + } +} + +@Composable +fun Shape.toCornerRadius(density: Density): Dp { + return if (this is CornerBasedShape) { + with(density) { + topStart.toPx(Size.Unspecified, this).toDp() + } + } else { + 0.dp + } +} + +@Suppress("LongParameterList") +private fun Modifier.drawShadow( + color: Color, + alpha: Float, + borderRadius: Dp, + shadowRadius: Dp, + offsetY: Dp, + offsetX: Dp, +) = this.drawBehind { + val shadowColor = color.copy(alpha = alpha).toArgb() + val paint = Paint() + val frameworkPaint = paint.asFrameworkPaint() + frameworkPaint.color = Color.Transparent.toArgb() + frameworkPaint.setShadowLayer( + shadowRadius.toPx(), + offsetX.toPx(), + offsetY.toPx(), + shadowColor + ) + drawIntoCanvas { canvas -> + canvas.drawRoundRect( + 0f, + 0f, + size.width, + size.height, + borderRadius.toPx(), + borderRadius.toPx(), + paint + ) + } +} + +@WooPosPreview +@Composable +fun WooPosCardPreview8() { + Preview(elevation = 8.dp) +} + +@WooPosPreview +@Composable +fun WooPosCardPreview2() { + Preview(elevation = 2.dp) +} + +@WooPosPreview +@Composable +fun WooPosCardPreview4() { + Preview(elevation = 4.dp) +} + +@Composable +private fun Preview(elevation: Dp) { + WooPosTheme { + WooPosCard( + modifier = Modifier.padding(16.dp), + shape = RoundedCornerShape(8.dp), + backgroundColor = MaterialTheme.colors.surface, + elevation = elevation, + ) { + Text( + modifier = Modifier + .padding(32.dp) + .fillMaxWidth(), + text = "WooPosCard", + style = MaterialTheme.typography.h5, + textAlign = TextAlign.Center, + ) + } + } +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/composeui/WooPosTheme.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/composeui/WooPosTheme.kt index 96cae91055d..e241a6c0cd4 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/composeui/WooPosTheme.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/composeui/WooPosTheme.kt @@ -23,52 +23,190 @@ data class CustomColors( val paymentSuccessIcon: Color, val paymentSuccessIconBackground: Color, val dialogSubtitleHighlightBackground: Color = Color(0x14747480), + val homeBackground: Color, ) +private object WooPosColors { + // Woo POS specific colors: + + // Adding missing colors from the old code to match exactly + val primary = Color(0xFF9C70D3) + val oldGrayLight = Color(0xFFF2EBFF) + val oldGrayMedium = Color(0xFF8D8D8D) + + val primaryVariant = Color(0xFF3700B3) + val secondary = Color(0xFF0A9400) + val surface = Color(0xFF2E2E2E) + + // LightColorPalette + val lightColorPaletteSecondary = Color(0xFF004B3E) + val lightColorPaletteSecondaryVariant = Color(0xFF50575E) + val lightColorPaletteBackground = Color(0xFFFDFDFD) + + // DarkCustomColors + val darkCustomColorsError = Color(0xFFBE4400) + val darkCustomColorsPaymentSuccessBackground = Color(0xFF005139) + val darkCustomColorsPaymentSuccessIconBackground = Color(0xFF00AD64) + val darkCustomloadingSkeleton = Color(0xFF616161) + val darkCustomColorsSuccess = Color(0xFF06B166) + val darkCustomColorsHomeBackground = Color(0xFF1E1E1E) + + // LightCustomColors + val lightCustomColorsError = Color(0xFFF16618) + val lightCustomColorsPaymentSuccessBackground = Color(0xFF98F179) + val lightCustomColorsSuccess = Color(0xFF03D479) + val lightCustomColorsLoadingSkeleton = Color(0xFFE1E1E1) + val lightCustomColorsBorder = Color(0xFFC6C6C8) + + // Woo colors from here: W5OBIbzWilNI8qely8Y4OHQd-fi-144_2 + val WooPurple0 = Color(0xFFF2EDFF) + val WooPurple5 = Color(0xFFDFD1FB) + val WooPurple10 = Color(0xFFCFB9F6) + val WooPurple20 = Color(0xFFBEA0F2) + val WooPurple30 = Color(0xFFAD86E9) + val WooPurple40 = Color(0xFF966CCF) + val WooPurple50 = Color(0xFF7F54B3) + val WooPurple60 = Color(0xFF674399) + val WooPurple70 = Color(0xFF533582) + val WooPurple80 = Color(0xFF3C2861) + val WooPurple90 = Color(0xFF271B3D) + val WooPurple100 = Color(0xFF140E1F) + + val Purple10 = Color(0xFFF7EDF7) + val Purple15 = Color(0xFFE5CFE8) + val Purple20 = Color(0xFFC792E0) + val Purple30 = Color(0xFFB17FD4) + val Purple40 = Color(0xFFAF7DD1) + val Purple50 = Color(0xFF7F54B3) + val Purple60 = Color(0xFF674399) + val Purple60Alpha33 = Color(0x33674399) + val Purple80 = Color(0xFF3C2861) + val Purple90 = Color(0xFF271B3D) + + val Pink10 = Color(0xFFED9BB8) + val Pink30 = Color(0xFFEB6594) + val Pink50 = Color(0xFFC9356E) + val Pink70 = Color(0xFF880E4F) + val Pink90 = Color(0xFF5C0935) + + val Red5 = Color(0xFFFACFD2) + val Red10 = Color(0xFFFFA6AB) + val Red20 = Color(0xFFFF8085) + val Red30 = Color(0xFFF86368) + val Red50 = Color(0xFFD63638) + val Red60 = Color(0xFFB32D2E) + val Red70 = Color(0xFF8A2424) + + val Blue5 = Color(0xFFBBE0FA) + val Blue30 = Color(0xFF5198D9) + val Blue40 = Color(0xFF1689DB) + val Blue50 = Color(0xFF2271B1) + + val Orange5 = Color(0xFFF7DCC6) + val Orange10 = Color(0xFFFFBF86) + val Orange30 = Color(0xFFE68B28) + val Orange50 = Color(0xFFB26200) + val Orange70 = Color(0xFF351F04) + + val Yellow10 = Color(0xFFF2CF75) + val Yellow20 = Color(0xFFF0C443) + val Yellow30 = Color(0xFFDBAE17) + val Yellow50 = Color(0xFF907300) + val Yellow70 = Color(0xFF5C4B00) + val Celadon0 = Color(0xFFECF7F4) + + val Celadon5 = Color(0xFFA7E8D4) + val Celadon10 = Color(0xFF65D9B9) + val Celadon20 = Color(0xFF2FC39E) + val Celadon40 = Color(0xFF009172) + + val Green0 = Color(0xFFEBF7F1) + val Green5 = Color(0xFFA4F5C8) + val Green10 = Color(0xFF59E38F) + val Green20 = Color(0xFF1ED15A) + val Green50 = Color(0xFF008A20) + + val White = Color(0xFFFFFFFF) + val WhiteAlpha005 = Color(0x0DFFFFFF) + val WhiteAlpha008 = Color(0x14FFFFFF) + val WhiteAlpha009 = Color(0x17FFFFFF) + val WhiteAlpha012 = Color(0x1FFFFFFF) + val WhiteAlpha038 = Color(0x61FFFFFF) + val WhiteAlpha060 = Color(0x99FFFFFF) + val WhiteAlpha087 = Color(0xDEFFFFFF) + + val Gray0 = Color(0xFFF6F7F7) + val Gray5 = Color(0xFFDCDCDE) + val Gray6 = Color(0xFFF2F2F7) + val Gray20 = Color(0xFFB4B1B8) + val Gray40 = Color(0xFF787C82) + val Gray60 = Color(0xFF51565F) + val Gray70 = Color(0xFF3D444B) + val Gray80 = Color(0xFF2C3338) + val Gray80Alpha012 = Color(0x1F2C3338) + val Gray80Alpha030 = Color(0x4D3C3C43) + val Gray900 = Color(0xFFF7F7F7) + + val Black = Color(0xFF000000) + val Black90 = Color(0xFF121212) + val Black90Alpha004 = Color(0x0A000000) + val Black90Alpha012 = Color(0x1F121212) + val Black90Alpha020 = Color(0x33121212) + val Black90Alpha038 = Color(0x61121212) + val Black90Alpha060 = Color(0x99121212) + val Black90Alpha087 = Color(0xDE121212) + val Black900 = Color(0xFF272727) + val Black60 = Color(0xFF6A6A6A) + val Black80 = Color(0xFF363636) + val BlackAlpha008 = Color(0x14212121) +} + private val DarkColorPalette = darkColors( - primary = Color(0xFF9C70D3), - primaryVariant = Color(0xFF3700B3), + primary = WooPosColors.primary, + primaryVariant = WooPosColors.primaryVariant, onPrimary = Color.Black, - secondary = Color(0xFF0A9400), - secondaryVariant = Color(0xFF8D8D8D), - surface = Color(0xFF2E2E2E), + secondary = WooPosColors.secondary, + secondaryVariant = WooPosColors.oldGrayMedium, + surface = WooPosColors.surface, onSurface = Color.White, - background = Color(0xFF121212), + background = WooPosColors.Black90, onBackground = Color.White, ) private val LightColorPalette = lightColors( - primary = Color(0xFF7F54B3), - primaryVariant = Color(0xFF3700B3), + primary = WooPosColors.Purple50, + primaryVariant = WooPosColors.primaryVariant, onPrimary = Color.White, - secondary = Color(0xFF004B3E), - secondaryVariant = Color(0xFF50575E), + secondary = WooPosColors.lightColorPaletteSecondary, + secondaryVariant = WooPosColors.lightColorPaletteSecondaryVariant, surface = Color.White, onSurface = Color.Black, - background = Color(0xFFF6F7F7), + background = WooPosColors.lightColorPaletteBackground, onBackground = Color.Black, ) private val DarkCustomColors = CustomColors( - loadingSkeleton = Color(0xFF616161), - border = Color(0xFF8D8D8D), - success = Color(0xFF06B166), - error = Color(0xFFBE4400), - paymentSuccessBackground = Color(0xFF005139), - paymentSuccessText = Color(0xFFF2EBFF), + loadingSkeleton = WooPosColors.darkCustomloadingSkeleton, + border = WooPosColors.oldGrayMedium, + success = WooPosColors.darkCustomColorsSuccess, + error = WooPosColors.darkCustomColorsError, + paymentSuccessBackground = WooPosColors.darkCustomColorsPaymentSuccessBackground, + paymentSuccessText = WooPosColors.oldGrayLight, paymentSuccessIcon = Color.White, - paymentSuccessIconBackground = Color(0xFF00AD64) + paymentSuccessIconBackground = WooPosColors.darkCustomColorsPaymentSuccessIconBackground, + homeBackground = WooPosColors.darkCustomColorsHomeBackground ) private val LightCustomColors = CustomColors( - loadingSkeleton = Color(0xFFE1E1E1), - border = Color(0xFFC6C6C8), - success = Color(0xFF03D479), - error = Color(0xFFF16618), - paymentSuccessBackground = Color(0xFF98F179), - paymentSuccessText = Color(0xFF271B3D), - paymentSuccessIcon = Color(0xFF03D479), + loadingSkeleton = WooPosColors.lightCustomColorsLoadingSkeleton, + border = WooPosColors.lightCustomColorsBorder, + success = WooPosColors.lightCustomColorsSuccess, + error = WooPosColors.lightCustomColorsError, + paymentSuccessBackground = WooPosColors.lightCustomColorsPaymentSuccessBackground, + paymentSuccessText = WooPosColors.Purple90, + paymentSuccessIcon = WooPosColors.lightCustomColorsSuccess, paymentSuccessIconBackground = Color.White, + homeBackground = WooPosColors.Gray0 ) private val LocalCustomColors = staticCompositionLocalOf { @@ -110,6 +248,5 @@ private fun SurfacedContent( object WooPosTheme { val colors: CustomColors - @Composable - get() = LocalCustomColors.current + @Composable get() = LocalCustomColors.current } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/composeui/component/WooPosButtons.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/composeui/component/WooPosButtons.kt index 3870699a89c..c7e24c93e6c 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/composeui/component/WooPosButtons.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/composeui/component/WooPosButtons.kt @@ -32,7 +32,14 @@ fun WooPosButton( enabled = enabled, modifier = modifier .fillMaxWidth() - .height(72.dp) + .height(72.dp), + elevation = ButtonDefaults.elevation( + defaultElevation = 0.dp, + pressedElevation = 0.dp, + disabledElevation = 0.dp, + hoveredElevation = 0.dp, + focusedElevation = 0.dp + ) ) { Text( text = text, @@ -52,6 +59,13 @@ fun WooPosButtonLarge( Button( onClick = onClick, shape = RoundedCornerShape(16.dp), + elevation = ButtonDefaults.elevation( + defaultElevation = 0.dp, + pressedElevation = 0.dp, + focusedElevation = 0.dp, + hoveredElevation = 0.dp, + disabledElevation = 0.dp + ), modifier = modifier .fillMaxWidth() .height(160.dp) @@ -69,6 +83,7 @@ fun WooPosButtonLarge( fun WooPosOutlinedButton( modifier: Modifier = Modifier, text: String, + shape: RoundedCornerShape = RoundedCornerShape(4.dp), onClick: () -> Unit, ) { Button( @@ -79,7 +94,7 @@ fun WooPosOutlinedButton( backgroundColor = MaterialTheme.colors.surface, contentColor = MaterialTheme.colors.onBackground, ), - shape = RoundedCornerShape(8.dp), + shape = shape, elevation = ButtonDefaults.elevation( defaultElevation = 0.dp, pressedElevation = 0.dp, diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/composeui/component/WooPosCircularLoadingIndicator.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/composeui/component/WooPosCircularLoadingIndicator.kt index f987925dac6..7ac0698d96d 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/composeui/component/WooPosCircularLoadingIndicator.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/composeui/component/WooPosCircularLoadingIndicator.kt @@ -14,7 +14,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.Fill import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.unit.dp @@ -36,6 +35,7 @@ fun WooPosCircularLoadingIndicator(modifier: Modifier = Modifier) { ) val backgroundColor = MaterialTheme.colors.primary + val centerCircleColor = MaterialTheme.colors.background Canvas(modifier = modifier) { val radius = size.width / 2 @@ -55,7 +55,7 @@ fun WooPosCircularLoadingIndicator(modifier: Modifier = Modifier) { } drawCircle( - color = Color.White, + color = centerCircleColor, radius = radius * 0.4f, ) } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/composeui/component/WooPosDialogWrapper.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/composeui/component/WooPosDialogWrapper.kt index 93048bdf876..cc7654156c8 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/composeui/component/WooPosDialogWrapper.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/composeui/component/WooPosDialogWrapper.kt @@ -8,14 +8,17 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Card import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp +import com.woocommerce.android.ui.woopos.common.composeui.WooPosCard @Composable fun WooPosDialogWrapper( @@ -25,32 +28,38 @@ fun WooPosDialogWrapper( onDismissRequest: () -> Unit, content: @Composable AnimatedVisibilityScope.() -> Unit ) { - Box(contentAlignment = Alignment.Center) { - WooPosBackgroundOverlay( - modifier = Modifier - .semantics { - contentDescription = dialogBackgroundContentDescription - }, - isVisible = isVisible, - onClick = onDismissRequest - ) - AnimatedVisibility( - visible = isVisible, - enter = fadeIn(animationSpec = tween(300)) + slideInVertically( - initialOffsetY = { it / 8 }, - animationSpec = tween(300) - ), - exit = fadeOut(animationSpec = tween(300)) + slideOutVertically( - targetOffsetY = { it / 8 }, - animationSpec = tween(300) - ), - ) { - Card( - shape = RoundedCornerShape(24.dp), - elevation = 8.dp, - modifier = modifier + BoxWithConstraints( + modifier = modifier + .fillMaxWidth() + ) { + val dialogWidth = maxWidth * 0.75f + Box(contentAlignment = Alignment.Center) { + WooPosBackgroundOverlay( + modifier = Modifier + .semantics { + contentDescription = dialogBackgroundContentDescription + }, + isVisible = isVisible, + onClick = onDismissRequest + ) + AnimatedVisibility( + visible = isVisible, + enter = fadeIn(animationSpec = tween(300)) + slideInVertically( + initialOffsetY = { it / 8 }, + animationSpec = tween(300) + ), + exit = fadeOut(animationSpec = tween(300)) + slideOutVertically( + targetOffsetY = { it / 8 }, + animationSpec = tween(300) + ), ) { - content() + WooPosCard( + shape = RoundedCornerShape(24.dp), + elevation = 8.dp, + modifier = modifier.width(dialogWidth) + ) { + content() + } } } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/composeui/component/WooPosExitConfirmationDialog.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/composeui/component/WooPosExitConfirmationDialog.kt index 366a96afa7f..3818cb307f4 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/composeui/component/WooPosExitConfirmationDialog.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/composeui/component/WooPosExitConfirmationDialog.kt @@ -14,6 +14,7 @@ import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag @@ -24,6 +25,8 @@ import com.woocommerce.android.R import com.woocommerce.android.ui.woopos.common.composeui.WooPosPreview import com.woocommerce.android.ui.woopos.common.composeui.WooPosTheme import com.woocommerce.android.ui.woopos.common.composeui.toAdaptivePadding +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch @Composable fun WooPosExitConfirmationDialog( @@ -35,10 +38,9 @@ fun WooPosExitConfirmationDialog( onDismissRequest: () -> Unit, onExit: () -> Unit ) { + val scope = rememberCoroutineScope() WooPosDialogWrapper( - modifier = modifier - .fillMaxWidth() - .padding(148.dp.toAdaptivePadding()), + modifier = modifier, isVisible = isVisible, dialogBackgroundContentDescription = stringResource( id = R.string.woopos_dialog_exit_confirmation_background_content_description @@ -70,7 +72,11 @@ fun WooPosExitConfirmationDialog( .fillMaxWidth() .testTag("woo_pos_exit_confirmation_dialog_exit"), onClick = { - onExit() + scope.launch { + onDismissRequest() + delay(300) + onExit() + } }, text = dismissButtonText ) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/composeui/component/WooPosLazyColumn.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/composeui/component/WooPosLazyColumn.kt index 21747280276..40e50b68519 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/composeui/component/WooPosLazyColumn.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/composeui/component/WooPosLazyColumn.kt @@ -9,16 +9,17 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.Card import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.dp +import com.woocommerce.android.ui.woopos.common.composeui.WooPosCard import com.woocommerce.android.ui.woopos.common.composeui.WooPosPreview import com.woocommerce.android.ui.woopos.common.composeui.WooPosTheme import com.woocommerce.android.ui.woopos.common.composeui.toAdaptivePadding @@ -30,11 +31,11 @@ fun WooPosLazyColumn( verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(8.dp), horizontalAlignment: Alignment.Horizontal = Alignment.Start, state: LazyListState = rememberLazyListState(), + withBottomShadow: Boolean = false, content: LazyListScope.() -> Unit ) { - Box { + Box(modifier = modifier) { LazyColumn( - modifier = modifier, contentPadding = contentPadding, verticalArrangement = verticalArrangement, horizontalAlignment = horizontalAlignment, @@ -42,43 +43,72 @@ fun WooPosLazyColumn( content = content ) - val showShadow = remember { + val showTopShadow = remember { derivedStateOf { state.firstVisibleItemIndex > 0 || state.firstVisibleItemScrollOffset > 0 } } - if (showShadow.value) { - Surface( - modifier = modifier - .fillMaxWidth() - .height(0.5.dp) - .align(Alignment.TopCenter), - elevation = 4.dp.toAdaptivePadding(), - color = MaterialTheme.colors.onBackground.copy(alpha = 0.1f) - ) {} + val showBottomShadow = remember { + derivedStateOf { + val lastVisibleItem = state.layoutInfo.visibleItemsInfo.lastOrNull() + val totalItemCount = state.layoutInfo.totalItemsCount + + if (lastVisibleItem != null) { + val lastItemPartiallyVisible = + lastVisibleItem.offset + lastVisibleItem.size > state.layoutInfo.viewportEndOffset + lastVisibleItem.index < totalItemCount - 1 || lastItemPartiallyVisible + } else { + false + } + } + } + + if (showTopShadow.value) { + Shadow() + } + + if (showBottomShadow.value && withBottomShadow) { + Shadow( + Modifier + .align(Alignment.BottomCenter) + .graphicsLayer(rotationZ = 180f) + ) } } } +@Composable +private fun Shadow(modifier: Modifier = Modifier) { + WooPosCard( + shape = MaterialTheme.shapes.large, + backgroundColor = Color.Black.copy(alpha = 0.1f), + modifier = modifier + .fillMaxWidth() + .height(1.dp), + elevation = 4.dp.toAdaptivePadding(), + ) {} +} + @WooPosPreview @Composable fun WooPosLazyColumnPreview() { - WooPosTheme - WooPosLazyColumn { - items(10) { i -> - Card( - modifier = Modifier.fillMaxWidth(), - elevation = 4.dp, - ) { - Text( - "Item $i", - modifier = Modifier - .height(64.dp) - .fillMaxWidth(), - style = MaterialTheme.typography.h6, - color = MaterialTheme.colors.onSurface, - ) + WooPosTheme { + WooPosLazyColumn { + items(10) { i -> + WooPosCard( + modifier = Modifier.fillMaxWidth(), + elevation = 4.dp, + ) { + Text( + "Item $i", + modifier = Modifier + .height(64.dp) + .fillMaxWidth(), + style = MaterialTheme.typography.h6, + color = MaterialTheme.colors.onSurface, + ) + } } } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeChildToParentCommunication.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeChildToParentCommunication.kt index b50a6432df4..105ba84ff87 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeChildToParentCommunication.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeChildToParentCommunication.kt @@ -25,14 +25,11 @@ sealed class ChildToParentEvent { data object OrderSuccessfullyPaid : ChildToParentEvent() data object ExitPosClicked : ChildToParentEvent() data object ProductsDialogInfoIconClicked : ChildToParentEvent() - sealed class CartStatusChanged : ChildToParentEvent() { - data object Empty : CartStatusChanged() - data object NotEmpty : CartStatusChanged() - } sealed class ProductsStatusChanged : ChildToParentEvent() { data object FullScreen : ProductsStatusChanged() data object WithCart : ProductsStatusChanged() } + data object NoInternet : ChildToParentEvent() } interface WooPosChildrenToParentEventReceiver { diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeNavigation.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeNavigation.kt index f33d2e7f82c..6b59231834f 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeNavigation.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeNavigation.kt @@ -1,9 +1,8 @@ package com.woocommerce.android.ui.woopos.home -import android.view.animation.AccelerateDecelerateInterpolator -import android.view.animation.OvershootInterpolator +import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween -import androidx.compose.animation.slideInVertically +import androidx.compose.animation.fadeIn import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable @@ -21,15 +20,11 @@ fun NavGraphBuilder.homeScreen( composable( route = HOME_ROUTE, enterTransition = { - slideInVertically( - initialOffsetY = { -it }, + fadeIn( animationSpec = tween( - durationMillis = 800, - easing = { time -> - val accelerateDecelerate = AccelerateDecelerateInterpolator().getInterpolation(time) - @Suppress("MagicNumber") - OvershootInterpolator(1.5f).getInterpolation(accelerateDecelerate) - } + durationMillis = 300, + delayMillis = 200, + easing = FastOutSlowInEasing ) ) } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeProductInfoDialog.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeProductInfoDialog.kt index 30bdaba935c..3104c4dc144 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeProductInfoDialog.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeProductInfoDialog.kt @@ -2,7 +2,6 @@ package com.woocommerce.android.ui.woopos.home import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background -import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -10,15 +9,10 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Icon -import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme import androidx.compose.material.OutlinedButton import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -51,9 +45,7 @@ fun WooPosProductInfoDialog( id = R.string.woopos_dialog_products_info_background_content_description ) WooPosDialogWrapper( - modifier = Modifier - .fillMaxWidth() - .padding(102.dp.toAdaptivePadding()), + modifier = Modifier, isVisible = state.isVisible, dialogBackgroundContentDescription = dialogBackgroundContentDescription, onDismissRequest = onDismissRequest @@ -78,7 +70,10 @@ fun WooPosProductInfoDialog( fontWeight = FontWeight.Bold, color = MaterialTheme.colors.onBackground.copy(alpha = 0.87f), modifier = Modifier - .padding(bottom = 16.dp.toAdaptivePadding()) + .padding( + top = 40.dp.toAdaptivePadding(), + bottom = 16.dp.toAdaptivePadding() + ) .constrainAs(header) { top.linkTo(closeIcon.bottom) start.linkTo(parent.start) @@ -87,27 +82,6 @@ fun WooPosProductInfoDialog( } ) - IconButton( - onClick = { onDismissRequest() }, - modifier = Modifier - .constrainAs(closeIcon) { - top.linkTo(parent.top) - end.linkTo(parent.end) - } - .focusable(enabled = false) - ) { - Icon( - modifier = Modifier - .size(40.dp) - .focusable(enabled = false), - imageVector = Icons.Default.Close, - tint = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), - contentDescription = stringResource( - id = R.string.woopos_banner_simple_products_close_content_description - ), - ) - } - Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.constrainAs(content) { @@ -123,13 +97,20 @@ fun WooPosProductInfoDialog( textAlign = TextAlign.Center, modifier = Modifier.padding(bottom = 16.dp.toAdaptivePadding()) ) + Text( + text = stringResource(id = state.secondaryMessage), + style = MaterialTheme.typography.h5, + color = MaterialTheme.colors.onBackground.copy(alpha = 0.87f), + textAlign = TextAlign.Center, + modifier = Modifier.padding(bottom = 16.dp.toAdaptivePadding()) + ) Box( Modifier .clip(RoundedCornerShape(8.dp)) .background( color = WooPosTheme.colors.dialogSubtitleHighlightBackground ) - .padding(16.dp.toAdaptivePadding()), + .padding(24.dp.toAdaptivePadding()), contentAlignment = Alignment.Center, ) { Column( @@ -137,7 +118,7 @@ fun WooPosProductInfoDialog( modifier = Modifier.fillMaxWidth() ) { Text( - text = stringResource(id = state.secondaryMessage), + text = stringResource(id = state.tertiaryMessage), style = MaterialTheme.typography.subtitle1, textAlign = TextAlign.Center, fontWeight = FontWeight.Normal, @@ -178,7 +159,7 @@ private fun getCombinedContentDescription(state: WooPosHomeState.ProductsInfoDia id = R.string.woopos_banner_simple_products_dialog_content_description ) return "$dialogContentDescription\n${stringResource(id = state.header)}" + - "\n${stringResource(id = state.primaryMessage)}\n${stringResource(id = state.secondaryMessage)}" + "\n${stringResource(id = state.primaryMessage)}\n${stringResource(id = state.tertiaryMessage)}" } @WooPosPreview diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeScreen.kt index f8921fdddc7..d164441a413 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp @@ -42,6 +43,7 @@ import com.woocommerce.android.ui.woopos.home.toolbar.WooPosFloatingToolbar import com.woocommerce.android.ui.woopos.home.totals.WooPosTotalsScreen import com.woocommerce.android.ui.woopos.home.totals.WooPosTotalsScreenPreview import com.woocommerce.android.ui.woopos.root.navigation.WooPosNavigationEvent +import org.wordpress.android.util.ToastUtils @Composable fun WooPosHomeScreen( @@ -49,6 +51,16 @@ fun WooPosHomeScreen( ) { val viewModel: WooPosHomeViewModel = hiltViewModel() val state = viewModel.state.collectAsState().value + val context = LocalContext.current + LaunchedEffect(viewModel.toastEvent) { + viewModel.toastEvent.collect { message -> + ToastUtils.showToast( + context, + context.getString(message.message), + ToastUtils.Duration.LONG + ) + } + } WooPosHomeScreen( state, @@ -77,8 +89,7 @@ private fun WooPosHomeScreen( when (state.screenPositionState) { WooPosHomeState.ScreenPositionState.Cart.Hidden -> screenWidthDp - is WooPosHomeState.ScreenPositionState.Cart.Visible.Empty, - WooPosHomeState.ScreenPositionState.Cart.Visible.NotEmpty, + is WooPosHomeState.ScreenPositionState.Cart.Visible, WooPosHomeState.ScreenPositionState.Checkout.NotPaid -> productsWidthDp WooPosHomeState.ScreenPositionState.Checkout.Paid -> productsWidthDp - cartWidthDp @@ -127,7 +138,7 @@ private fun WooPosHomeScreen( Box( modifier = Modifier .fillMaxSize() - .background(MaterialTheme.colors.background) + .background(WooPosTheme.colors.homeBackground) .testTag("woo_pos_home_screen") ) { Row( @@ -236,23 +247,7 @@ fun WooPosHomeCartScreenPreview() { WooPosTheme { WooPosHomeScreen( state = WooPosHomeState( - screenPositionState = WooPosHomeState.ScreenPositionState.Cart.Visible.NotEmpty, - productsInfoDialog = ProductsInfoDialog(isVisible = false), - exitConfirmationDialog = WooPosHomeState.ExitConfirmationDialog(isVisible = false), - ), - onHomeUIEvent = { }, - onNavigationEvent = {}, - ) - } -} - -@Composable -@WooPosPreview -fun WooPosHomeCartEmptyScreenPreview() { - WooPosTheme { - WooPosHomeScreen( - state = WooPosHomeState( - screenPositionState = WooPosHomeState.ScreenPositionState.Cart.Visible.Empty, + screenPositionState = WooPosHomeState.ScreenPositionState.Cart.Visible, productsInfoDialog = ProductsInfoDialog(isVisible = false), exitConfirmationDialog = WooPosHomeState.ExitConfirmationDialog(isVisible = false), ), diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeState.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeState.kt index 3cd99a52d59..177e58b10e4 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeState.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeState.kt @@ -17,13 +17,7 @@ data class WooPosHomeState( @Parcelize sealed class Cart : ScreenPositionState() { @Parcelize - sealed class Visible : Cart() { - @Parcelize - data object Empty : Cart() - - @Parcelize - data object NotEmpty : Cart() - } + data object Visible : Cart() @Parcelize data object Hidden : Cart() @@ -50,6 +44,9 @@ data class WooPosHomeState( @IgnoredOnParcel val secondaryMessage: Int = R.string.woopos_dialog_products_info_secondary_message + @IgnoredOnParcel + val tertiaryMessage: Int = R.string.woopos_dialog_products_info_tertiary_message + @IgnoredOnParcel val primaryButton: PrimaryButton = PrimaryButton( label = R.string.woopos_dialog_products_info_button_label, diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeViewModel.kt index 0efd05e8405..2e1f6c56054 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeViewModel.kt @@ -1,10 +1,14 @@ package com.woocommerce.android.ui.woopos.home +import androidx.annotation.StringRes import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.woocommerce.android.R import com.woocommerce.android.viewmodel.getStateFlow import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import javax.inject.Inject @@ -19,13 +23,20 @@ class WooPosHomeViewModel @Inject constructor( scope = viewModelScope, key = "home_state", initialValue = WooPosHomeState( - screenPositionState = WooPosHomeState.ScreenPositionState.Cart.Visible.Empty, + screenPositionState = WooPosHomeState.ScreenPositionState.Cart.Visible, productsInfoDialog = WooPosHomeState.ProductsInfoDialog(isVisible = false), - exitConfirmationDialog = WooPosHomeState.ExitConfirmationDialog(isVisible = false) + exitConfirmationDialog = WooPosHomeState.ExitConfirmationDialog(isVisible = false), ) ) val state: StateFlow = _state + private val _toastEvent = MutableSharedFlow() + val toastEvent: SharedFlow = _toastEvent + + data class Toast( + @StringRes val message: Int, + ) + init { listenBottomEvents() } @@ -36,14 +47,14 @@ class WooPosHomeViewModel @Inject constructor( when (_state.value.screenPositionState) { WooPosHomeState.ScreenPositionState.Checkout.NotPaid -> { _state.value = _state.value.copy( - screenPositionState = WooPosHomeState.ScreenPositionState.Cart.Visible.NotEmpty + screenPositionState = WooPosHomeState.ScreenPositionState.Cart.Visible ) sendEventToChildren(ParentToChildrenEvent.BackFromCheckoutToCartClicked) } WooPosHomeState.ScreenPositionState.Checkout.Paid -> { _state.value = _state.value.copy( - screenPositionState = WooPosHomeState.ScreenPositionState.Cart.Visible.NotEmpty + screenPositionState = WooPosHomeState.ScreenPositionState.Cart.Visible ) sendEventToChildren(ParentToChildrenEvent.OrderSuccessfullyPaid) } @@ -83,7 +94,7 @@ class WooPosHomeViewModel @Inject constructor( is ChildToParentEvent.BackFromCheckoutToCartClicked -> { _state.value = _state.value.copy( - screenPositionState = WooPosHomeState.ScreenPositionState.Cart.Visible.NotEmpty + screenPositionState = WooPosHomeState.ScreenPositionState.Cart.Visible ) } @@ -95,7 +106,7 @@ class WooPosHomeViewModel @Inject constructor( is ChildToParentEvent.NewTransactionClicked -> { _state.value = _state.value.copy( - screenPositionState = WooPosHomeState.ScreenPositionState.Cart.Visible.Empty + screenPositionState = WooPosHomeState.ScreenPositionState.Cart.Visible ) sendEventToChildren(ParentToChildrenEvent.OrderSuccessfullyPaid) } @@ -106,8 +117,6 @@ class WooPosHomeViewModel @Inject constructor( ) } - is ChildToParentEvent.CartStatusChanged -> handleCartStatusChanged(event) - ChildToParentEvent.ExitPosClicked -> { _state.value = _state.value.copy( exitConfirmationDialog = WooPosHomeState.ExitConfirmationDialog(isVisible = true) @@ -121,6 +130,12 @@ class WooPosHomeViewModel @Inject constructor( productsInfoDialog = WooPosHomeState.ProductsInfoDialog(isVisible = true) ) } + + ChildToParentEvent.NoInternet -> { + viewModelScope.launch { + _toastEvent.emit(Toast(R.string.woopos_no_internet_message)) + } + } } } } @@ -138,10 +153,9 @@ class WooPosHomeViewModel @Inject constructor( ChildToParentEvent.ProductsStatusChanged.WithCart -> { when (screenPosition) { WooPosHomeState.ScreenPositionState.Cart.Hidden -> - WooPosHomeState.ScreenPositionState.Cart.Visible.Empty + WooPosHomeState.ScreenPositionState.Cart.Visible - WooPosHomeState.ScreenPositionState.Cart.Visible.Empty, - WooPosHomeState.ScreenPositionState.Cart.Visible.NotEmpty, + WooPosHomeState.ScreenPositionState.Cart.Visible, WooPosHomeState.ScreenPositionState.Checkout.NotPaid, WooPosHomeState.ScreenPositionState.Checkout.Paid -> screenPosition } @@ -150,18 +164,6 @@ class WooPosHomeViewModel @Inject constructor( _state.value = _state.value.copy(screenPositionState = newScreenPositionState) } - private fun handleCartStatusChanged(event: ChildToParentEvent.CartStatusChanged) { - if (_state.value.screenPositionState is WooPosHomeState.ScreenPositionState.Checkout.Paid) { - return - } - - val newScreenPositionState = when (event) { - ChildToParentEvent.CartStatusChanged.Empty -> WooPosHomeState.ScreenPositionState.Cart.Visible.Empty - ChildToParentEvent.CartStatusChanged.NotEmpty -> WooPosHomeState.ScreenPositionState.Cart.Visible.NotEmpty - } - _state.value = _state.value.copy(screenPositionState = newScreenPositionState) - } - private fun sendEventToChildren(event: ParentToChildrenEvent) { viewModelScope.launch { parentToChildrenEventSender.sendToChildren(event) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/cart/WooPosCartScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/cart/WooPosCartScreen.kt index f6466cd6d98..d4e6ff85918 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/cart/WooPosCartScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/cart/WooPosCartScreen.kt @@ -1,12 +1,15 @@ -@file:OptIn(ExperimentalFoundationApi::class) - package com.woocommerce.android.ui.woopos.home.cart -import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween -import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -25,7 +28,6 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Card import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme @@ -40,11 +42,12 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.painter.ColorPainter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource @@ -61,6 +64,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import coil.compose.AsyncImage import coil.request.ImageRequest import com.woocommerce.android.R +import com.woocommerce.android.ui.woopos.common.composeui.WooPosCard import com.woocommerce.android.ui.woopos.common.composeui.WooPosPreview import com.woocommerce.android.ui.woopos.common.composeui.WooPosTheme import com.woocommerce.android.ui.woopos.common.composeui.component.WooPosButton @@ -78,61 +82,110 @@ fun WooPosCartScreen(modifier: Modifier = Modifier) { } @Composable +@Suppress("DestructuringDeclarationWithTooManyEntries") private fun WooPosCartScreen( modifier: Modifier = Modifier, state: WooPosCartState, onUIEvent: (WooPosCartUIEvent) -> Unit, ) { - Box( + ConstraintLayout( modifier = modifier .fillMaxSize() .background(MaterialTheme.colors.surface) ) { - Column( - modifier = modifier - .padding( - top = 40.dp.toAdaptivePadding(), - bottom = 16.dp.toAdaptivePadding() - ) - ) { - Column(modifier = Modifier.weight(1f)) { - CartToolbar( - toolbar = state.toolbar, - onClearAllClicked = { onUIEvent(WooPosCartUIEvent.ClearAllClicked) }, - onBackClicked = { onUIEvent(WooPosCartUIEvent.BackClicked) } - ) + val (topMargin, toolbar, body, checkoutButton, overlay) = createRefs() - when (state.body) { - WooPosCartState.Body.Empty -> { - CartBodyEmpty() - } + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(40.dp.toAdaptivePadding()) + .constrainAs(topMargin) { + top.linkTo(parent.top) + start.linkTo(parent.start) + end.linkTo(parent.end) + } + ) - is WooPosCartState.Body.WithItems -> { - CartBodyWithItems( - items = state.body.itemsInCart, - areItemsRemovable = state.areItemsRemovable, - onUIEvent = onUIEvent, - ) + CartToolbar( + modifier = Modifier.constrainAs(toolbar) { + top.linkTo(topMargin.bottom) + start.linkTo(parent.start) + end.linkTo(parent.end) + }, + toolbar = state.toolbar, + onClearAllClicked = { onUIEvent(WooPosCartUIEvent.ClearAllClicked) }, + onBackClicked = { onUIEvent(WooPosCartUIEvent.BackClicked) }, + ) + + when (state.body) { + WooPosCartState.Body.Empty -> { + CartBodyEmpty( + modifier = Modifier.constrainAs(body) { + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + start.linkTo(parent.start) + end.linkTo(parent.end) + height = Dimension.fillToConstraints } - } + ) } - if (state.isCheckoutButtonVisible) { - WooPosButton( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp.toAdaptivePadding()), - text = stringResource(R.string.woopos_checkout_button), - onClick = { onUIEvent(WooPosCartUIEvent.CheckoutClicked) } + is WooPosCartState.Body.WithItems -> { + val productsTopMargin = 24.dp.toAdaptivePadding() + CartBodyWithItems( + modifier = Modifier.constrainAs(body) { + top.linkTo(toolbar.bottom, margin = productsTopMargin) + bottom.linkTo(checkoutButton.top) + start.linkTo(parent.start) + end.linkTo(parent.end) + height = Dimension.fillToConstraints + }, + items = state.body.itemsInCart, + areItemsRemovable = state.areItemsRemovable, + isCheckoutButtonVisible = state.isCheckoutButtonVisible, + onUIEvent = onUIEvent ) } } - CartOverlay(state) + + AnimatedVisibility( + visible = state.isCheckoutButtonVisible, + enter = fadeIn(animationSpec = tween(300)), + exit = fadeOut(animationSpec = tween(300)), + modifier = Modifier + .fillMaxWidth() + .padding(16.dp.toAdaptivePadding()) + .constrainAs(checkoutButton) { + bottom.linkTo(parent.bottom) + start.linkTo(parent.start) + end.linkTo(parent.end) + } + .testTag("woo_pos_checkout_button"), + ) { + WooPosButton( + text = stringResource(R.string.woopos_checkout_button), + onClick = { onUIEvent(WooPosCartUIEvent.CheckoutClicked) } + ) + } + + CartOverlay( + modifier = Modifier.constrainAs(overlay) { + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + start.linkTo(parent.start) + end.linkTo(parent.end) + height = Dimension.fillToConstraints + }, + state = state, + ) } } @Composable -private fun CartOverlay(state: WooPosCartState) { +private fun CartOverlay( + modifier: Modifier = Modifier, + state: WooPosCartState, +) { val cartOverlayIntensityAnimated by animateFloatAsState( when (state.body) { WooPosCartState.Body.Empty -> .6f @@ -141,7 +194,7 @@ private fun CartOverlay(state: WooPosCartState) { label = "cartOverlayAnimated" ) Box( - modifier = Modifier + modifier = modifier .fillMaxSize() .background( color = MaterialTheme.colors.background.copy(alpha = cartOverlayIntensityAnimated), @@ -150,10 +203,9 @@ private fun CartOverlay(state: WooPosCartState) { } @Composable -fun CartBodyEmpty() { +fun CartBodyEmpty(modifier: Modifier = Modifier) { Column( - modifier = Modifier - .fillMaxSize() + modifier = modifier .padding(16.dp.toAdaptivePadding()), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally @@ -161,48 +213,62 @@ fun CartBodyEmpty() { Image( imageVector = ImageVector.vectorResource(R.drawable.woo_pos_ic_empty_cart), contentDescription = stringResource(R.string.woopos_cart_empty_content_description), + modifier = Modifier.size(104.dp) ) - Spacer(modifier = Modifier.height(40.dp.toAdaptivePadding())) + Spacer(modifier = Modifier.height(32.dp.toAdaptivePadding())) Text( text = stringResource(R.string.woopos_cart_empty_subtitle), - style = MaterialTheme.typography.subtitle1, - color = MaterialTheme.colors.secondaryVariant, + style = MaterialTheme.typography.h6, + fontWeight = FontWeight.Normal, + color = MaterialTheme.colors.onSurface, textAlign = TextAlign.Center ) } } -@OptIn(ExperimentalFoundationApi::class) @Composable private fun CartBodyWithItems( + modifier: Modifier = Modifier, items: List, areItemsRemovable: Boolean, + isCheckoutButtonVisible: Boolean, onUIEvent: (WooPosCartUIEvent) -> Unit, ) { - Spacer(modifier = Modifier.height(20.dp.toAdaptivePadding())) - val listState = rememberLazyListState() ScrollToTopHandler(items, listState) + val spacerHeight by animateDpAsState( + targetValue = if (!isCheckoutButtonVisible) 182.dp else 0.dp, + label = "cart list height animation" + ) + WooPosLazyColumn( - modifier = Modifier - .padding(horizontal = 16.dp.toAdaptivePadding()), + modifier = modifier + .padding(horizontal = 16.dp.toAdaptivePadding()) + .testTag("woo_pos_cart_list"), state = listState, verticalArrangement = Arrangement.spacedBy(8.dp.toAdaptivePadding()), horizontalAlignment = Alignment.CenterHorizontally, - contentPadding = PaddingValues(2.dp), + contentPadding = PaddingValues( + top = 2.dp.toAdaptivePadding(), + bottom = 8.dp.toAdaptivePadding() + ), + withBottomShadow = true, ) { items( items, key = { item -> item.id.itemNumber } ) { item -> ProductItem( - modifier = Modifier.animateItemPlacement(), + modifier = Modifier, item = item, canRemoveItems = areItemsRemovable, onUIEvent = onUIEvent, ) } + item { + Spacer(modifier = Modifier.height(spacerHeight)) + } } } @@ -224,6 +290,7 @@ private fun ScrollToTopHandler( @Composable @Suppress("DestructuringDeclarationWithTooManyEntries") private fun CartToolbar( + modifier: Modifier = Modifier, toolbar: WooPosCartState.Toolbar, onClearAllClicked: () -> Unit, onBackClicked: () -> Unit @@ -231,15 +298,23 @@ private fun CartToolbar( val iconSize = 28.dp val iconTitlePadding = 16.dp.toAdaptivePadding() val titleOffset by animateDpAsState( - targetValue = if (toolbar.icon != null) iconSize + iconTitlePadding else 0.dp, + targetValue = if (toolbar.backIconVisible) iconSize + iconTitlePadding else 0.dp, animationSpec = tween(durationMillis = 300), label = "titleOffset" ) - ConstraintLayout(modifier = Modifier.fillMaxWidth()) { + ConstraintLayout( + modifier = modifier + .fillMaxWidth() + .height(40.dp) + ) { val (backButton, title, spacer, itemsCount, clearAllButton) = createRefs() - toolbar.icon?.let { + AnimatedVisibility( + visible = toolbar.backIconVisible, + enter = fadeIn(animationSpec = tween(300)) + expandHorizontally(), + exit = fadeOut(animationSpec = tween(300)) + shrinkHorizontally() + ) { IconButton( onClick = { onBackClicked() }, modifier = Modifier @@ -250,7 +325,7 @@ private fun CartToolbar( .padding(start = 8.dp.toAdaptivePadding()) ) { Icon( - imageVector = ImageVector.vectorResource(it), + imageVector = ImageVector.vectorResource(R.drawable.ic_back_24dp), contentDescription = stringResource(R.string.woopos_cart_back_content_description), tint = MaterialTheme.colors.onBackground, modifier = Modifier.size(iconSize) @@ -272,7 +347,6 @@ private fun CartToolbar( .padding( start = 16.dp.toAdaptivePadding(), end = 4.dp, - top = 4.dp, ) ) @@ -337,98 +411,98 @@ private fun ProductItem( val elevation by animateDpAsState( targetValue = if (hasAnimationStarted) 4.dp else 0.dp, - animationSpec = tween(durationMillis = 200, delayMillis = 100), + animationSpec = tween(durationMillis = 150, delayMillis = 250), label = "elevation" ) - val alpha by animateFloatAsState( - targetValue = if (hasAnimationStarted) 1f else 0f, - animationSpec = tween( - durationMillis = 200, - easing = LinearEasing - ), - label = "alpha" - ) - val itemContentDescription = stringResource( id = R.string.woopos_cart_item_content_description, item.name, item.price ) - LaunchedEffect(alpha) { - if (alpha == 1f) { + LaunchedEffect(elevation) { + if (elevation == 4.dp) { onUIEvent(WooPosCartUIEvent.OnCartItemAppearanceAnimationPlayed(item)) } } - Card( - modifier = modifier - .height(64.dp) - .semantics { contentDescription = itemContentDescription } - .graphicsLayer(alpha = alpha), - elevation = elevation, - shape = RoundedCornerShape(8.dp), + AnimatedVisibility( + visible = hasAnimationStarted, + enter = expandVertically( + animationSpec = tween(durationMillis = 200) + ), + exit = shrinkVertically() ) { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + WooPosCard( + modifier = modifier + .height(64.dp) + .semantics { contentDescription = itemContentDescription } + .testTag("woo_pos_cart_item_${item.name}"), + elevation = elevation, + shape = RoundedCornerShape(8.dp), ) { - AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(item.imageUrl) - .crossfade(true) - .build(), - fallback = ColorPainter(WooPosTheme.colors.loadingSkeleton), - error = ColorPainter(WooPosTheme.colors.loadingSkeleton), - placeholder = ColorPainter(WooPosTheme.colors.loadingSkeleton), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.size(64.dp) - ) - - Spacer(modifier = Modifier.width(16.dp.toAdaptivePadding())) - - Column( - modifier = Modifier.weight(1f) + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { - Text( - text = item.name, - style = MaterialTheme.typography.body1, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.clearAndSetSemantics { } + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(item.imageUrl) + .crossfade(true) + .build(), + fallback = ColorPainter(WooPosTheme.colors.loadingSkeleton), + error = ColorPainter(WooPosTheme.colors.loadingSkeleton), + placeholder = ColorPainter(WooPosTheme.colors.loadingSkeleton), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.size(64.dp) ) - Spacer(modifier = Modifier.height(4.dp.toAdaptivePadding())) - Text( - text = item.price, - style = MaterialTheme.typography.body1, - modifier = Modifier.clearAndSetSemantics { } - ) - } - if (canRemoveItems) { - Spacer(modifier = Modifier.width(8.dp.toAdaptivePadding())) + Spacer(modifier = Modifier.width(16.dp.toAdaptivePadding())) - val removeButtonContentDescription = stringResource( - id = R.string.woopos_remove_item_button_from_cart_content_description, - item.name - ) - IconButton( - onClick = { onUIEvent(WooPosCartUIEvent.ItemRemovedFromCart(item)) }, - modifier = Modifier - .size(24.dp) - .semantics { contentDescription = removeButtonContentDescription } + Column( + modifier = Modifier.weight(1f) ) { - Icon( - painter = painterResource(id = R.drawable.ic_pos_remove_cart_item), - tint = MaterialTheme.colors.onBackground, - contentDescription = null, + Text( + text = item.name, + style = MaterialTheme.typography.body1, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.clearAndSetSemantics { } + ) + Spacer(modifier = Modifier.height(4.dp.toAdaptivePadding())) + Text( + text = item.price, + style = MaterialTheme.typography.body1, + modifier = Modifier.clearAndSetSemantics { } + ) + } + + if (canRemoveItems) { + Spacer(modifier = Modifier.width(8.dp.toAdaptivePadding())) + + val removeButtonContentDescription = stringResource( + id = R.string.woopos_remove_item_button_from_cart_content_description, + item.name ) + IconButton( + onClick = { onUIEvent(WooPosCartUIEvent.ItemRemovedFromCart(item)) }, + modifier = Modifier + .size(24.dp) + .semantics { contentDescription = removeButtonContentDescription } + .testTag("woo_pos_cart_item_close_icon_${item.name}") + ) { + Icon( + painter = painterResource(id = R.drawable.ic_pos_remove_cart_item), + tint = MaterialTheme.colors.onBackground, + contentDescription = null, + ) + } } + Spacer(modifier = Modifier.width(16.dp.toAdaptivePadding())) } - Spacer(modifier = Modifier.width(16.dp.toAdaptivePadding())) } } } @@ -441,7 +515,7 @@ fun WooPosCartScreenProductsPreview(modifier: Modifier = Modifier) { modifier = modifier, state = WooPosCartState( toolbar = WooPosCartState.Toolbar( - icon = null, + backIconVisible = false, itemsCount = "3 items", isClearAllButtonVisible = true ), @@ -486,7 +560,7 @@ fun WooPosCartScreenCheckoutPreview(modifier: Modifier = Modifier) { modifier = modifier, state = WooPosCartState( toolbar = WooPosCartState.Toolbar( - icon = R.drawable.ic_back_24dp, + backIconVisible = true, itemsCount = "3 items", isClearAllButtonVisible = true ), @@ -530,7 +604,7 @@ fun WooPosCartScreenEmptyPreview(modifier: Modifier = Modifier) { modifier = modifier, state = WooPosCartState( toolbar = WooPosCartState.Toolbar( - icon = null, + backIconVisible = false, itemsCount = null, isClearAllButtonVisible = false ), diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/cart/WooPosCartState.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/cart/WooPosCartState.kt index 4e1c462ed37..ffe6a68318a 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/cart/WooPosCartState.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/cart/WooPosCartState.kt @@ -1,7 +1,6 @@ package com.woocommerce.android.ui.woopos.home.cart import android.os.Parcelable -import androidx.annotation.DrawableRes import kotlinx.parcelize.Parcelize @Parcelize @@ -43,7 +42,7 @@ data class WooPosCartState( @Parcelize data class Toolbar( - @DrawableRes val icon: Int? = null, + val backIconVisible: Boolean = false, val itemsCount: String? = null, val isClearAllButtonVisible: Boolean = false, ) : Parcelable diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/cart/WooPosCartViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/cart/WooPosCartViewModel.kt index 10209b7e9be..1d7fe57ecfe 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/cart/WooPosCartViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/cart/WooPosCartViewModel.kt @@ -23,7 +23,6 @@ import com.woocommerce.android.viewmodel.ResourceProvider import com.woocommerce.android.viewmodel.getStateFlow import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.async -import kotlinx.coroutines.flow.scan import kotlinx.coroutines.launch import javax.inject.Inject @@ -44,10 +43,6 @@ class WooPosCartViewModel @Inject constructor( ) val state: LiveData = _state - .scan(_state.value) { previousState, newState -> - updateParentCartStatusIfCartChanged(previousState, newState) - newState - } .asLiveData() .map { updateCartStatusDependingOnItems(it) } .map { updateToolbarState(it) } @@ -167,7 +162,7 @@ class WooPosCartViewModel @Inject constructor( val newToolbar = when (newState.cartStatus) { EDITABLE -> { WooPosCartState.Toolbar( - icon = null, + backIconVisible = false, itemsCount = itemsCount, isClearAllButtonVisible = newState.body is WooPosCartState.Body.WithItems ) @@ -175,7 +170,7 @@ class WooPosCartViewModel @Inject constructor( CHECKOUT -> { WooPosCartState.Toolbar( - icon = R.drawable.ic_back_24dp, + backIconVisible = true, itemsCount = itemsCount, isClearAllButtonVisible = false ) @@ -183,7 +178,7 @@ class WooPosCartViewModel @Inject constructor( EMPTY -> { WooPosCartState.Toolbar( - icon = null, + backIconVisible = false, itemsCount = null, isClearAllButtonVisible = false ) @@ -209,19 +204,6 @@ class WooPosCartViewModel @Inject constructor( } } - private fun updateParentCartStatusIfCartChanged(previousState: WooPosCartState, newState: WooPosCartState) { - if (previousState.body.amountOfItems == newState.body.amountOfItems) return - when (newState.body) { - is WooPosCartState.Body.Empty -> { - sendEventToParent(ChildToParentEvent.CartStatusChanged.Empty) - } - - is WooPosCartState.Body.WithItems -> { - sendEventToParent(ChildToParentEvent.CartStatusChanged.NotEmpty) - } - } - } - private fun updateCartStatusDependingOnItems(newState: WooPosCartState): WooPosCartState = when (newState.body) { is WooPosCartState.Body.Empty -> newState.copy(cartStatus = EMPTY) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/products/WooPosBanner.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/products/WooPosBanner.kt index 80531a92d44..b49858dfc37 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/products/WooPosBanner.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/products/WooPosBanner.kt @@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Card import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme @@ -31,6 +30,7 @@ import androidx.compose.ui.unit.dp import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.Dimension import com.woocommerce.android.R +import com.woocommerce.android.ui.woopos.common.composeui.WooPosCard import com.woocommerce.android.ui.woopos.common.composeui.WooPosPreview import com.woocommerce.android.ui.woopos.common.composeui.WooPosTheme import com.woocommerce.android.ui.woopos.common.composeui.toAdaptivePadding @@ -59,117 +59,112 @@ fun WooPosBanner( .focusable() .testTag("woo_pos_simple_products_banner") ) { - Card( + WooPosCard( shape = RoundedCornerShape(8.dp), backgroundColor = MaterialTheme.colors.surface, elevation = 4.dp, modifier = Modifier .fillMaxWidth() ) { - Box( + ConstraintLayout( modifier = Modifier + .padding(24.dp.toAdaptivePadding()) .fillMaxWidth() - .padding(32.dp.toAdaptivePadding()) ) { - ConstraintLayout( + val (icon, header, description, close) = createRefs() + + Box( modifier = Modifier - .fillMaxWidth() + .size(48.dp) + .constrainAs(icon) { + top.linkTo(parent.top) + start.linkTo(parent.start) + bottom.linkTo(parent.bottom) + } ) { - val (icon, header, description, close) = createRefs() - - Box( - modifier = Modifier - .size(48.dp) - .constrainAs(icon) { - top.linkTo(parent.top) - start.linkTo(parent.start) - bottom.linkTo(parent.bottom) - } - ) { - Icon( - painterResource(id = bannerIcon), - contentDescription = stringResource( - id = R.string.woopos_banner_simple_products_info_content_description - ), - tint = MaterialTheme.colors.primary, - modifier = Modifier.align(Alignment.Center) - ) - } - - Text( - text = title, - style = MaterialTheme.typography.h5, - color = MaterialTheme.colors.onBackground.copy(alpha = 0.87f), - fontWeight = FontWeight.SemiBold, - modifier = Modifier - .padding( - start = 32.dp.toAdaptivePadding(), - bottom = 8.dp.toAdaptivePadding() - ) - .constrainAs(header) { - top.linkTo(parent.top) - start.linkTo(icon.end) - end.linkTo(close.start) - width = Dimension.fillToConstraints - } + Icon( + painterResource(id = bannerIcon), + contentDescription = stringResource( + id = R.string.woopos_banner_simple_products_info_content_description + ), + tint = MaterialTheme.colors.primary, + modifier = Modifier.align(Alignment.Center) ) + } - val annotatedText = buildAnnotatedString { - append(message) - withStyle(style = SpanStyle(color = MaterialTheme.colors.primary)) { - append(" ") - append(stringResource(id = R.string.woopos_banner_simple_products_only_message_learn_more)) + Text( + text = title, + style = MaterialTheme.typography.h5, + color = MaterialTheme.colors.onBackground.copy(alpha = 0.87f), + fontWeight = FontWeight.SemiBold, + modifier = Modifier + .padding( + start = 32.dp.toAdaptivePadding(), + bottom = 8.dp.toAdaptivePadding() + ) + .constrainAs(header) { + top.linkTo(parent.top) + start.linkTo(icon.end) + end.linkTo(close.start) + width = Dimension.fillToConstraints } + ) + + val annotatedText = buildAnnotatedString { + append(message) + withStyle(style = SpanStyle(color = MaterialTheme.colors.primary)) { + append(" ") + append(stringResource(id = R.string.woopos_banner_simple_products_only_message_learn_more)) } + } - Box( + Box( + modifier = Modifier + .constrainAs(description) { + top.linkTo(header.bottom) + start.linkTo(header.start) + end.linkTo(close.start) + width = Dimension.fillToConstraints + } + .padding( + start = 24.dp.toAdaptivePadding(), + end = 18.dp.toAdaptivePadding() + ) + ) { + Text( modifier = Modifier - .constrainAs(description) { - top.linkTo(header.bottom) - start.linkTo(header.start) - end.linkTo(close.start) - width = Dimension.fillToConstraints + .clickable { + onLearnMore() } .padding( - start = 24.dp.toAdaptivePadding(), - end = 18.dp.toAdaptivePadding() - ) - ) { - Text( - modifier = Modifier - .clickable { - onLearnMore() - } - .padding( - start = 8.dp.toAdaptivePadding(), - top = 8.dp.toAdaptivePadding(), - bottom = 8.dp.toAdaptivePadding(), - ), - text = annotatedText, - style = MaterialTheme.typography.body1, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colors.onBackground.copy(alpha = 0.87f) - ) - } - - IconButton( - modifier = Modifier - .constrainAs(close) { - top.linkTo(header.top) - bottom.linkTo(header.bottom) - end.linkTo(parent.end) - }, - onClick = { onClose() } - ) { - Icon( - modifier = Modifier.size(32.dp), - imageVector = Icons.Default.Close, - tint = MaterialTheme.colors.onSurface, - contentDescription = stringResource( - id = R.string.woopos_banner_simple_products_close_content_description + start = 8.dp.toAdaptivePadding(), + top = 8.dp.toAdaptivePadding(), + bottom = 8.dp.toAdaptivePadding(), ), - ) - } + text = annotatedText, + style = MaterialTheme.typography.body1, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colors.onBackground.copy(alpha = 0.87f) + ) + } + + IconButton( + modifier = Modifier + .constrainAs(close) { + top.linkTo(header.top) + bottom.linkTo(header.bottom) + end.linkTo(parent.end) + }, + onClick = { onClose() } + ) { + Icon( + modifier = Modifier.size(32.dp), + imageVector = Icons.Default.Close, + tint = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), + contentDescription = stringResource( + id = R.string.woopos_banner_simple_products_close_content_description + ), + ) } } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/products/WooPosProductsScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/products/WooPosProductsScreen.kt index 5ffff112188..5e1833936de 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/products/WooPosProductsScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/products/WooPosProductsScreen.kt @@ -25,7 +25,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material.Card +import androidx.compose.material.ContentAlpha import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Icon import androidx.compose.material.IconButton @@ -62,6 +62,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import coil.compose.AsyncImage import coil.request.ImageRequest import com.woocommerce.android.R +import com.woocommerce.android.ui.woopos.common.composeui.WooPosCard import com.woocommerce.android.ui.woopos.common.composeui.WooPosPreview import com.woocommerce.android.ui.woopos.common.composeui.WooPosTheme import com.woocommerce.android.ui.woopos.common.composeui.component.Button @@ -137,7 +138,7 @@ private fun WooPosProductsScreen( is WooPosProductsViewState.Content -> MaterialTheme.colors.onSurface } - ProductsToolbar(state.value, modifier, titleColor, onToolbarInfoIconClicked) + ProductsToolbar(state.value, titleColor, onToolbarInfoIconClicked) Spacer(modifier = Modifier.height(24.dp)) @@ -175,13 +176,13 @@ private fun WooPosProductsScreen( @Composable private fun ProductsToolbar( productViewState: WooPosProductsViewState, - modifier: Modifier, titleColor: Color, onToolbarInfoIconClicked: () -> Unit, ) { Row( - modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween + modifier = Modifier.fillMaxWidth().height(40.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, ) { Text( text = stringResource(id = R.string.woopos_products_screen_title), @@ -203,7 +204,7 @@ private fun ProductsToolbar( contentDescription = stringResource( id = R.string.woopos_banner_simple_products_info_content_description ), - tint = MaterialTheme.colors.primary, + tint = MaterialTheme.colors.onSurface.copy(ContentAlpha.high), ) } } @@ -297,7 +298,7 @@ fun ProductsLoadingIndicator() { @Composable private fun ProductLoadingItem() { - Card( + WooPosCard( shape = RoundedCornerShape(8.dp), backgroundColor = MaterialTheme.colors.surface, ) { @@ -347,9 +348,10 @@ private fun ProductItem( item.name, item.price ) - Card( + WooPosCard( modifier = modifier - .semantics { contentDescription = itemContentDescription }, + .semantics { contentDescription = itemContentDescription } + .testTag("woo_pos_product_item_${item.name}"), shape = RoundedCornerShape(8.dp), backgroundColor = MaterialTheme.colors.surface, ) { diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/toolbar/WooPosToolbar.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/toolbar/WooPosToolbar.kt index 9d2e2c9f7e3..b3392656fd7 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/toolbar/WooPosToolbar.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/toolbar/WooPosToolbar.kt @@ -19,9 +19,9 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.ButtonDefaults -import androidx.compose.material.Card import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.TextButton import androidx.compose.runtime.Composable @@ -48,6 +48,7 @@ import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.Dimension import androidx.hilt.navigation.compose.hiltViewModel import com.woocommerce.android.R +import com.woocommerce.android.ui.woopos.common.composeui.WooPosCard import com.woocommerce.android.ui.woopos.common.composeui.WooPosPreview import com.woocommerce.android.ui.woopos.common.composeui.WooPosTheme import com.woocommerce.android.ui.woopos.common.composeui.component.WooPosBackgroundOverlay @@ -55,6 +56,8 @@ import com.woocommerce.android.ui.woopos.common.composeui.toAdaptivePadding import com.woocommerce.android.ui.woopos.home.toolbar.WooPosToolbarState.Menu import com.woocommerce.android.ui.woopos.home.toolbar.WooPosToolbarState.WooPosCardReaderStatus +private val TOOLBAR_ELEVATION = 6.dp + @Composable fun WooPosFloatingToolbar(modifier: Modifier = Modifier) { val viewModel: WooPosToolbarViewModel = hiltViewModel() @@ -166,6 +169,7 @@ private fun Toolbar( contentDescription = labels.cardReaderStatusContentDescription }, state = cardReaderStatus, + menuCardDisabled = menuCardDisabled, ) { onUIEvent(WooPosToolbarUIEvent.OnCardReaderStatusClicked) } MenuButtonWithPopUpMenu( @@ -196,10 +200,10 @@ private fun MenuButtonWithPopUpMenu( onClick: () -> Unit ) { val menuContentDescription = stringResource(id = R.string.woopos_menu_toolbar_content_description) - Card( + WooPosCard( modifier = modifier, backgroundColor = MaterialTheme.colors.surface, - elevation = 8.dp, + elevation = TOOLBAR_ELEVATION, shape = RoundedCornerShape(8.dp), ) { TextButton( @@ -237,9 +241,9 @@ private fun PopUpMenu( menuItems: List, onClick: (Menu.MenuItem) -> Unit ) { - Card( + WooPosCard( modifier = modifier.width(214.dp), - elevation = 8.dp, + elevation = TOOLBAR_ELEVATION, ) { Column { Spacer(modifier = Modifier.height(8.dp.toAdaptivePadding())) @@ -285,6 +289,7 @@ private fun PopUpMenuItem( private fun CardReaderStatusButton( modifier: Modifier, state: WooPosCardReaderStatus, + menuCardDisabled: Boolean, onClick: () -> Unit ) { val transition = updateTransition( @@ -330,33 +335,41 @@ private fun CardReaderStatusButton( } } - Card( + WooPosCard( modifier = modifier .height(56.dp), backgroundColor = MaterialTheme.colors.surface, - elevation = 8.dp, + elevation = TOOLBAR_ELEVATION, shape = RoundedCornerShape(8.dp), ) { - TextButton( - onClick = onClick, - modifier = Modifier - .padding(8.dp.toAdaptivePadding()) - .border( - width = 2.dp, - color = borderColor, - shape = RoundedCornerShape(4.dp) - ) - .height(40.dp), + Surface( + color = if (menuCardDisabled) { + MaterialTheme.colors.onSurface.copy(alpha = 0.2f) + } else { + Color.Transparent + }, ) { - Spacer(modifier = Modifier.width(16.dp.toAdaptivePadding())) - Circle(size = 12.dp, color = illustrationColor) - Spacer(modifier = Modifier.width(4.dp.toAdaptivePadding())) - ReaderStatusText( - modifier = Modifier.animateContentSize(), - title = title, - color = textColor, - ) - Spacer(modifier = Modifier.width(16.dp.toAdaptivePadding())) + TextButton( + onClick = onClick, + modifier = Modifier + .padding(8.dp.toAdaptivePadding()) + .border( + width = 2.dp, + color = borderColor, + shape = RoundedCornerShape(4.dp) + ) + .height(40.dp), + ) { + Spacer(modifier = Modifier.width(16.dp.toAdaptivePadding())) + Circle(size = 12.dp, color = illustrationColor) + Spacer(modifier = Modifier.width(4.dp.toAdaptivePadding())) + ReaderStatusText( + modifier = Modifier.animateContentSize(), + title = title, + color = textColor, + ) + Spacer(modifier = Modifier.width(16.dp.toAdaptivePadding())) + } } } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/toolbar/WooPosToolbarViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/toolbar/WooPosToolbarViewModel.kt index 88495a1ffcb..5f83dd9bc22 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/toolbar/WooPosToolbarViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/toolbar/WooPosToolbarViewModel.kt @@ -15,6 +15,7 @@ import com.woocommerce.android.ui.woopos.home.toolbar.WooPosToolbarUIEvent.OnCar import com.woocommerce.android.ui.woopos.home.toolbar.WooPosToolbarUIEvent.OnOutsideOfToolbarMenuClicked import com.woocommerce.android.ui.woopos.home.toolbar.WooPosToolbarUIEvent.OnToolbarMenuClicked import com.woocommerce.android.ui.woopos.support.WooPosGetSupportFacade +import com.woocommerce.android.ui.woopos.util.WooPosNetworkStatus import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -26,6 +27,7 @@ class WooPosToolbarViewModel @Inject constructor( private val cardReaderFacade: WooPosCardReaderFacade, private val childrenToParentEventSender: WooPosChildrenToParentEventSender, private val getSupportFacade: WooPosGetSupportFacade, + private val networkStatus: WooPosNetworkStatus, ) : ViewModel() { private val _state = MutableStateFlow( WooPosToolbarState( @@ -92,7 +94,15 @@ class WooPosToolbarViewModel @Inject constructor( cardReaderFacade.disconnectFromReader() } } - WooPosToolbarState.WooPosCardReaderStatus.NotConnected -> cardReaderFacade.connectToReader() + WooPosToolbarState.WooPosCardReaderStatus.NotConnected -> { + if (!networkStatus.isConnected()) { + viewModelScope.launch { + childrenToParentEventSender.sendToParent(ChildToParentEvent.NoInternet) + } + } else { + cardReaderFacade.connectToReader() + } + } } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/totals/WooPosTotalsRepository.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/totals/WooPosTotalsRepository.kt index e23a3de3009..a197ca2aece 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/totals/WooPosTotalsRepository.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/totals/WooPosTotalsRepository.kt @@ -2,6 +2,7 @@ package com.woocommerce.android.ui.woopos.home.totals import com.woocommerce.android.model.Order import com.woocommerce.android.ui.orders.creation.OrderCreateEditRepository +import com.woocommerce.android.ui.woopos.common.data.WooPosGetProductById import com.woocommerce.android.util.DateUtils import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers.IO @@ -13,6 +14,7 @@ import javax.inject.Inject class WooPosTotalsRepository @Inject constructor( private val orderCreateEditRepository: OrderCreateEditRepository, private val dateUtils: DateUtils, + private val getProductById: WooPosGetProductById ) { private var orderCreationJob: Deferred>? = null @@ -36,6 +38,8 @@ class WooPosTotalsRepository @Inject constructor( .groupingBy { it } .eachCount() .map { (productId, quantity) -> + val productResult = getProductById(productId)!! + Order.Item.EMPTY.copy( itemId = 0L, productId = productId, @@ -43,8 +47,8 @@ class WooPosTotalsRepository @Inject constructor( quantity = quantity.toFloat(), total = EMPTY_TOTALS_SUBTOTAL_VALUE, subtotal = EMPTY_TOTALS_SUBTOTAL_VALUE, - price = EMPTY_TOTALS_SUBTOTAL_VALUE, attributesList = emptyList(), + name = productResult.name, ) } ) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/totals/WooPosTotalsScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/totals/WooPosTotalsScreen.kt index 6592efea3f4..856f45142d9 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/totals/WooPosTotalsScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/totals/WooPosTotalsScreen.kt @@ -2,8 +2,10 @@ package com.woocommerce.android.ui.woopos.home.totals import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -22,10 +24,16 @@ import androidx.compose.material.Divider import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight @@ -40,6 +48,7 @@ import com.woocommerce.android.ui.woopos.common.composeui.component.WooPosErrorS import com.woocommerce.android.ui.woopos.common.composeui.component.WooPosShimmerBox import com.woocommerce.android.ui.woopos.common.composeui.toAdaptivePadding import com.woocommerce.android.ui.woopos.home.totals.payment.success.WooPosPaymentSuccessScreen +import kotlinx.coroutines.delay @Composable fun WooPosTotalsScreen(modifier: Modifier = Modifier) { @@ -97,15 +106,24 @@ private fun StateChangeAnimated( ) } +@OptIn(ExperimentalAnimationApi::class) @Composable private fun TotalsLoaded( state: WooPosTotalsViewState.Totals, onUIEvent: (WooPosTotalsUIEvent) -> Unit ) { + var isButtonVisible by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + delay(300) + isButtonVisible = true + } + Column( modifier = Modifier .fillMaxSize() - .padding(16.dp.toAdaptivePadding()), + .padding(16.dp.toAdaptivePadding()) + .testTag("woo_pos_totals_loaded_screen"), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { @@ -124,10 +142,16 @@ private fun TotalsLoaded( Spacer(modifier = Modifier.weight(1f)) } - WooPosButtonLarge( - text = stringResource(R.string.woopos_payment_collect_payment_label), - onClick = { onUIEvent(WooPosTotalsUIEvent.CollectPaymentClicked) }, - ) + AnimatedVisibility(visible = isButtonVisible) { + WooPosButtonLarge( + text = stringResource(R.string.woopos_payment_collect_payment_label), + onClick = { onUIEvent(WooPosTotalsUIEvent.CollectPaymentClicked) }, + modifier = Modifier + .animateEnterExit( + enter = slideInVertically { it }, + ) + ) + } } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/totals/WooPosTotalsViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/totals/WooPosTotalsViewModel.kt index 9309220bc9f..194da209f86 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/totals/WooPosTotalsViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/totals/WooPosTotalsViewModel.kt @@ -7,11 +7,12 @@ import androidx.lifecycle.viewModelScope import com.woocommerce.android.R import com.woocommerce.android.model.Order import com.woocommerce.android.ui.woopos.cardreader.WooPosCardReaderFacade -import com.woocommerce.android.ui.woopos.cardreader.WooPosCardReaderPaymentResult +import com.woocommerce.android.ui.woopos.cardreader.WooPosCardReaderPaymentStatus import com.woocommerce.android.ui.woopos.home.ChildToParentEvent import com.woocommerce.android.ui.woopos.home.ParentToChildrenEvent import com.woocommerce.android.ui.woopos.home.WooPosChildrenToParentEventSender import com.woocommerce.android.ui.woopos.home.WooPosParentToChildrenEventReceiver +import com.woocommerce.android.ui.woopos.util.WooPosNetworkStatus import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsEvent import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsTracker import com.woocommerce.android.ui.woopos.util.format.WooPosFormatPrice @@ -35,6 +36,7 @@ class WooPosTotalsViewModel @Inject constructor( private val totalsRepository: WooPosTotalsRepository, private val priceFormat: WooPosFormatPrice, private val analyticsTracker: WooPosAnalyticsTracker, + private val networkStatus: WooPosNetworkStatus, savedState: SavedStateHandle, ) : ViewModel() { @@ -60,15 +62,12 @@ class WooPosTotalsViewModel @Inject constructor( init { listenUpEvents() + listenToPaymentsStatus() } fun onUIEvent(event: WooPosTotalsUIEvent) { when (event) { - is WooPosTotalsUIEvent.CollectPaymentClicked -> { - viewModelScope.launch { - collectPayment() - } - } + is WooPosTotalsUIEvent.CollectPaymentClicked -> collectPayment() is WooPosTotalsUIEvent.OnNewTransactionClicked -> { viewModelScope.launch { childrenToParentEventSender.sendToParent( @@ -82,18 +81,15 @@ class WooPosTotalsViewModel @Inject constructor( } } - private suspend fun collectPayment() { - val orderId = dataState.value.orderId - check(orderId != EMPTY_ORDER_ID) - val result = cardReaderFacade.collectPayment(orderId) - when (result) { - is WooPosCardReaderPaymentResult.Success -> { - val state = uiState.value - check(state is WooPosTotalsViewState.Totals) - uiState.value = WooPosTotalsViewState.PaymentSuccess(orderTotalText = state.orderTotalText) - childrenToParentEventSender.sendToParent(ChildToParentEvent.OrderSuccessfullyPaid) + private fun collectPayment() { + if (!networkStatus.isConnected()) { + viewModelScope.launch { + childrenToParentEventSender.sendToParent(ChildToParentEvent.NoInternet) } - else -> Unit + } else { + val orderId = dataState.value.orderId + check(orderId != EMPTY_ORDER_ID) + cardReaderFacade.collectPayment(orderId) } } @@ -110,7 +106,25 @@ class WooPosTotalsViewModel @Inject constructor( uiState.value = InitialState } - else -> Unit + is ParentToChildrenEvent.ItemClickedInProductSelector, + ParentToChildrenEvent.OrderSuccessfullyPaid -> Unit + } + } + } + } + + private fun listenToPaymentsStatus() { + viewModelScope.launch { + cardReaderFacade.paymentStatus.collect { status -> + when (status) { + is WooPosCardReaderPaymentStatus.Success -> { + val state = uiState.value + check(state is WooPosTotalsViewState.Totals) + uiState.value = WooPosTotalsViewState.PaymentSuccess(orderTotalText = state.orderTotalText) + childrenToParentEventSender.sendToParent(ChildToParentEvent.OrderSuccessfullyPaid) + } + is WooPosCardReaderPaymentStatus.Failure, + is WooPosCardReaderPaymentStatus.Unknown -> Unit } } } @@ -145,9 +159,9 @@ class WooPosTotalsViewModel @Inject constructor( } private suspend fun buildWooPosTotalsViewState(order: Order): WooPosTotalsViewState.Totals { - val subtotalAmount = order.items.sumOf { it.subtotal } + val subtotalAmount = order.productsTotal val taxAmount = order.totalTax - val totalAmount = subtotalAmount + taxAmount + val totalAmount = order.total return WooPosTotalsViewState.Totals( orderSubtotalText = priceFormat(subtotalAmount), diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/totals/payment/success/WooPosTotalsPaymentSuccessScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/totals/payment/success/WooPosTotalsPaymentSuccessScreen.kt index f88b2b1e474..3cf4d0dba60 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/totals/payment/success/WooPosTotalsPaymentSuccessScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/totals/payment/success/WooPosTotalsPaymentSuccessScreen.kt @@ -3,20 +3,18 @@ package com.woocommerce.android.ui.woopos.home.totals.payment.success import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.spring -import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme -import androidx.compose.material.OutlinedButton import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check @@ -29,8 +27,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -125,7 +121,7 @@ fun WooPosPaymentSuccessScreen( } ) - OutlinedButton( + Button( modifier = Modifier .constrainAs(button) { bottom.linkTo(parent.bottom) @@ -135,25 +131,16 @@ fun WooPosPaymentSuccessScreen( .height(80.dp) .width(604.dp), colors = ButtonDefaults.buttonColors( - backgroundColor = Color.Transparent, - contentColor = Color.Transparent, + backgroundColor = MaterialTheme.colors.onBackground ), - onClick = onNewTransactionClicked, - border = BorderStroke(1.dp, MaterialTheme.colors.onSurface), shape = RoundedCornerShape(8.dp), + onClick = onNewTransactionClicked, ) { - Icon( - modifier = Modifier.size(24.dp), - painter = painterResource(id = R.drawable.woo_pos_ic_return_home), - tint = MaterialTheme.colors.onSurface, - contentDescription = stringResource(id = R.string.woopos_new_order_button) - ) - Spacer(modifier = Modifier.width(12.dp.toAdaptivePadding())) Text( text = stringResource(R.string.woopos_new_order_button), style = MaterialTheme.typography.h5, fontWeight = FontWeight.SemiBold, - color = WooPosTheme.colors.paymentSuccessText, + color = MaterialTheme.colors.background, textAlign = TextAlign.Center ) } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/root/WooPosActivity.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/root/WooPosActivity.kt index fb1ed7609cd..a25d3f53ff3 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/root/WooPosActivity.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/root/WooPosActivity.kt @@ -76,8 +76,8 @@ private fun Modifier.gesturesOrButtonsNavigationPadding(): Modifier { } } -// That seems to be different on different devices, but 24dp is a common upper value -private const val GESTURE_NAVIGATION_BAR_HEIGHT_DP = 24 +// That seems to be different on different devices, but 32dp is a common upper value +private const val GESTURE_NAVIGATION_BAR_HEIGHT_DP = 32 private fun WindowInsetsCompat.isGestureNavigation(context: Context): Boolean { val bottomInset = getInsets(WindowInsetsCompat.Type.navigationBars()).bottom diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/splash/WooPosSplashScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/splash/WooPosSplashScreen.kt index 72661659dd2..940f66474e2 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/splash/WooPosSplashScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/splash/WooPosSplashScreen.kt @@ -1,27 +1,18 @@ package com.woocommerce.android.ui.woopos.splash 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.Box import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel -import com.woocommerce.android.R import com.woocommerce.android.ui.woopos.common.composeui.WooPosPreview import com.woocommerce.android.ui.woopos.common.composeui.WooPosTheme import com.woocommerce.android.ui.woopos.common.composeui.component.WooPosCircularLoadingIndicator -import com.woocommerce.android.ui.woopos.common.composeui.toAdaptivePadding import com.woocommerce.android.ui.woopos.root.navigation.WooPosNavigationEvent @Composable @@ -45,27 +36,11 @@ fun WooPosSplashScreen(onNavigationEvent: (WooPosNavigationEvent) -> Unit) { @Composable private fun Loading() { - Column( + Box( modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + contentAlignment = Alignment.Center ) { WooPosCircularLoadingIndicator(modifier = Modifier.size(156.dp)) - - Spacer(modifier = Modifier.height(56.dp.toAdaptivePadding())) - - Text( - text = stringResource(id = R.string.woopos_splash_title), - style = MaterialTheme.typography.h5, - ) - - Spacer(modifier = Modifier.height(16.dp.toAdaptivePadding())) - - Text( - text = stringResource(id = R.string.woopos_splash_message), - style = MaterialTheme.typography.h4, - fontWeight = FontWeight.Bold - ) } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/util/WooPosNetworkStatus.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/util/WooPosNetworkStatus.kt new file mode 100644 index 00000000000..092838dfa2d --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/util/WooPosNetworkStatus.kt @@ -0,0 +1,13 @@ +package com.woocommerce.android.ui.woopos.util + +import android.content.Context +import dagger.Reusable +import org.wordpress.android.util.NetworkUtils +import javax.inject.Inject + +@Reusable +class WooPosNetworkStatus @Inject constructor( + private val context: Context +) { + fun isConnected() = NetworkUtils.isNetworkAvailable(context) +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/util/ActivityUtils.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/util/ActivityUtils.kt index a55bca234aa..d5379d82a4c 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/util/ActivityUtils.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/util/ActivityUtils.kt @@ -23,15 +23,13 @@ object ActivityUtils { return false } - val intent = Intent(Intent.ACTION_MAIN) - intent.addCategory(Intent.CATEGORY_APP_EMAIL) + val intent = Intent.makeMainSelectorActivity(Intent.ACTION_MAIN, Intent.CATEGORY_APP_EMAIL) val emailApps = context.packageManager.intentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY) - return !emailApps.isEmpty() + return emailApps.isNotEmpty() } fun openEmailClient(context: Context) { - val intent = Intent(Intent.ACTION_MAIN) - intent.addCategory(Intent.CATEGORY_APP_EMAIL) + val intent = Intent.makeMainSelectorActivity(Intent.ACTION_MAIN, Intent.CATEGORY_APP_EMAIL) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) context.startActivity(intent) } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/util/FeatureFlag.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/util/FeatureFlag.kt index f1e4228f69d..614720ceff4 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/util/FeatureFlag.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/util/FeatureFlag.kt @@ -6,7 +6,6 @@ import android.content.Context * "Feature flags" are used to hide in-progress features from release versions */ enum class FeatureFlag { - WOO_POS, DB_DOWNGRADE, INBOX, WC_SHIPPING_BANNER, @@ -14,7 +13,10 @@ enum class FeatureFlag { ORDER_CREATION_AUTO_TAX_RATE, NEW_SHIPPING_SUPPORT, GOOGLE_ADS_M1, - SHOW_INBOX_CTA; + SHOW_INBOX_CTA, + ENDLESS_CAMPAIGNS_SUPPORT, + CUSTOM_FIELDS, + REVAMP_WOO_SHIPPING; fun isEnabled(context: Context? = null): Boolean { return when (this) { @@ -22,15 +24,17 @@ enum class FeatureFlag { PackageUtils.isDebugBuild() || context != null && PackageUtils.isBetaBuild(context) } - WOO_POS, WC_SHIPPING_BANNER, BETTER_CUSTOMER_SEARCH_M2, - ORDER_CREATION_AUTO_TAX_RATE -> PackageUtils.isDebugBuild() + ORDER_CREATION_AUTO_TAX_RATE, + CUSTOM_FIELDS, + REVAMP_WOO_SHIPPING -> PackageUtils.isDebugBuild() NEW_SHIPPING_SUPPORT, INBOX, SHOW_INBOX_CTA, - GOOGLE_ADS_M1 -> true + GOOGLE_ADS_M1, + ENDLESS_CAMPAIGNS_SUPPORT -> true } } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/util/IsRemoteFeatureFlagEnabled.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/util/IsRemoteFeatureFlagEnabled.kt index 643a69dbac2..c7a5fdb89cf 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/util/IsRemoteFeatureFlagEnabled.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/util/IsRemoteFeatureFlagEnabled.kt @@ -4,7 +4,7 @@ import com.woocommerce.android.config.WPComRemoteFeatureFlagRepository import com.woocommerce.android.util.RemoteFeatureFlag.LOCAL_NOTIFICATION_1D_AFTER_FREE_TRIAL_EXPIRES import com.woocommerce.android.util.RemoteFeatureFlag.LOCAL_NOTIFICATION_1D_BEFORE_FREE_TRIAL_EXPIRES import com.woocommerce.android.util.RemoteFeatureFlag.LOCAL_NOTIFICATION_STORE_CREATION_READY -import com.woocommerce.android.util.RemoteFeatureFlag.WOO_BLAZE +import com.woocommerce.android.util.RemoteFeatureFlag.WOO_POS import javax.inject.Inject class IsRemoteFeatureFlagEnabled @Inject constructor( @@ -15,7 +15,7 @@ class IsRemoteFeatureFlagEnabled @Inject constructor( LOCAL_NOTIFICATION_STORE_CREATION_READY, LOCAL_NOTIFICATION_1D_BEFORE_FREE_TRIAL_EXPIRES, LOCAL_NOTIFICATION_1D_AFTER_FREE_TRIAL_EXPIRES, - WOO_BLAZE -> + WOO_POS -> PackageUtils.isDebugBuild() || wpComRemoteFeatureFlagRepository.isRemoteFeatureFlagEnabled(featureFlag.remoteKey) } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/util/RemoteFeatureFlag.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/util/RemoteFeatureFlag.kt index 0cb167cce14..4a936614361 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/util/RemoteFeatureFlag.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/util/RemoteFeatureFlag.kt @@ -4,5 +4,5 @@ enum class RemoteFeatureFlag(val remoteKey: String) { LOCAL_NOTIFICATION_STORE_CREATION_READY("woo_notification_store_creation_ready"), LOCAL_NOTIFICATION_1D_BEFORE_FREE_TRIAL_EXPIRES("woo_notification_1d_before_free_trial_expires"), LOCAL_NOTIFICATION_1D_AFTER_FREE_TRIAL_EXPIRES("woo_notification_1d_after_free_trial_expires"), - WOO_BLAZE("woo_blaze") + WOO_POS("woo_pos"), } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/util/WooAnimUtils.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/util/WooAnimUtils.kt index d97e09335fe..9e025f5ee81 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/util/WooAnimUtils.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/util/WooAnimUtils.kt @@ -165,17 +165,40 @@ object WooAnimUtils { view.isVisible = isVisible } - fun rotate(view: View, duration: Duration = EXTRA_LONG) { - val rotationAnimation: Animation = RotateAnimation( - DEGREES_0, - DEGREES_360, - Animation.RELATIVE_TO_SELF, - PIVOT_CENTER, - Animation.RELATIVE_TO_SELF, - PIVOT_CENTER - ) + fun rotate( + view: View, + rotationDirection: RotationDirection = RotationDirection.CLOCKWISE, + duration: Duration = EXTRA_LONG + ) { + val rotationAnimation = when (rotationDirection) { + RotationDirection.CLOCKWISE -> { + RotateAnimation( + DEGREES_0, + DEGREES_360, + Animation.RELATIVE_TO_SELF, + PIVOT_CENTER, + Animation.RELATIVE_TO_SELF, + PIVOT_CENTER + ) + } + RotationDirection.ANTICLOCKWISE -> { + RotateAnimation( + DEGREES_360, + DEGREES_0, + Animation.RELATIVE_TO_SELF, + PIVOT_CENTER, + Animation.RELATIVE_TO_SELF, + PIVOT_CENTER + ) + } + } rotationAnimation.repeatCount = REPEAT_COUNT_LOOP rotationAnimation.duration = duration.toMillis(view.context) view.startAnimation(rotationAnimation) } + + enum class RotationDirection { + CLOCKWISE, + ANTICLOCKWISE, + } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/util/WooLog.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/util/WooLog.kt index 72d1aabf6d5..3001df31c76 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/util/WooLog.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/util/WooLog.kt @@ -42,7 +42,8 @@ object WooLog { THEMES, BLAZE, GOOGLE_ADS, - POS + POS, + CUSTOM_FIELDS } // Breaking convention to be consistent with org.wordpress.android.util.AppLog diff --git a/WooCommerce/src/main/res/drawable/woo_pos_ic_return_home.xml b/WooCommerce/src/main/res/drawable/woo_pos_ic_return_home.xml deleted file mode 100644 index ba781e979c5..00000000000 --- a/WooCommerce/src/main/res/drawable/woo_pos_ic_return_home.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/WooCommerce/src/main/res/layout/dialog_jetpack_install_progress.xml b/WooCommerce/src/main/res/layout/dialog_jetpack_install_progress.xml index c2d182908f2..c2a6150f0ae 100644 --- a/WooCommerce/src/main/res/layout/dialog_jetpack_install_progress.xml +++ b/WooCommerce/src/main/res/layout/dialog_jetpack_install_progress.xml @@ -238,7 +238,7 @@ diff --git a/WooCommerce/src/main/res/layout/dialog_jetpack_install_start.xml b/WooCommerce/src/main/res/layout/dialog_jetpack_install_start.xml index 39abd24c671..12c47f6bc94 100644 --- a/WooCommerce/src/main/res/layout/dialog_jetpack_install_start.xml +++ b/WooCommerce/src/main/res/layout/dialog_jetpack_install_start.xml @@ -73,7 +73,7 @@ diff --git a/WooCommerce/src/main/res/layout/fragment_add_shipment_tracking.xml b/WooCommerce/src/main/res/layout/fragment_add_shipment_tracking.xml index 2439de0dc88..d61398a2db4 100644 --- a/WooCommerce/src/main/res/layout/fragment_add_shipment_tracking.xml +++ b/WooCommerce/src/main/res/layout/fragment_add_shipment_tracking.xml @@ -70,6 +70,7 @@ app:counterEnabled="true" app:counterMaxLength="@integer/max_length_tracking_number" app:errorEnabled="true" + android:visibility="gone" tools:visibility="visible"> + app:errorEnabled="true" + app:endIconDrawable="@drawable/ic_barcode" + app:endIconTint="@color/woo_purple_60" + app:endIconMode="custom"> + app:errorEnabled="true" + tools:visibility="visible"> + + + + + + + + + + + diff --git a/WooCommerce/src/main/res/layout/view_aztec_outlined.xml b/WooCommerce/src/main/res/layout/view_aztec_outlined.xml new file mode 100644 index 00000000000..2ea00f1504e --- /dev/null +++ b/WooCommerce/src/main/res/layout/view_aztec_outlined.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + 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 0f49e9552d1..dbbfad740b8 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 @@ -98,6 +98,9 @@ + + + + diff --git a/WooCommerce/src/main/res/navigation/nav_graph_custom_fields.xml b/WooCommerce/src/main/res/navigation/nav_graph_custom_fields.xml new file mode 100644 index 00000000000..ee777fdb50e --- /dev/null +++ b/WooCommerce/src/main/res/navigation/nav_graph_custom_fields.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + diff --git a/WooCommerce/src/main/res/navigation/nav_graph_main.xml b/WooCommerce/src/main/res/navigation/nav_graph_main.xml index 5a64cb374b9..e7777a93224 100644 --- a/WooCommerce/src/main/res/navigation/nav_graph_main.xml +++ b/WooCommerce/src/main/res/navigation/nav_graph_main.xml @@ -811,6 +811,11 @@ android:name="isPostCampaignCreation" android:defaultValue="false" app:argType="boolean" /> + + + + + + + + - + مفتاح غير صالح: يرجى إزالة رمز \"_\" من البداية. + هذا المفتاح مستخدم بالفعل لحقل مخصص آخر.\nلا يدعم التطبيق حاليًا إنشاء مفاتيح مكررة. يرجى استخدام مسؤول ووردبريس لتكرار مفتاح إذا لزم الأمر. + إضافة حقول مخصصة + تم حذف الحقل المخصص + فشل حفظ التغييرات، يرجى المحاولة مجددًا + تم حفظ التغييرات + حفظ التغييرات + يبدو أنك غير متصل بالإنترنت. تأكد من تشغيل شبكة Wi-Fi الخاصة بك. إذا كنت تستخدم بيانات الهاتف المحمول، فتأكد من تمكينها ضمن إعدادات الجهاز لديك. + فشل الفحص. يرجى المحاولة مجددًا في وقت لاحق + القيمة + المفتاح + أما أنواع المنتجات الأخرى، مثل: المنتجات المتغيرة والظاهرية، ستتوفر في التحديثات المستقبلية. + لا يمكن استخدام إلا المنتجات المادية البسيطة التي تتضمن نقطة بيع الآن. + إلغاء + المدة + سيتم تشغيل الحملة حتى تُوقفها. + تحديد المدة + إلى ⁦%1$s⁩ + الجدولة + الإنفاق اليومي + ما المبلغ الذي ترغب في إنفاقه على حملتك، وما مقدار الوقت الذي ينبغي أن تستمر فيه؟ + %1$s ← %2$s + جعل منتجاتك مرئية لملايين الأشخاص من خلال Blaze وتعزيز مبيعاتك + هل تفكر في تعزيز مبيعاتك؟ + خطأ في أثناء تحميل الحقول المخصصة + حقول مخصصة + خلفية معتمة. انقر لتجاهل مربع الحوار. + ⁦%1$s⁩ أسبوعيًا + التشغيل حتى أوقفه + مستمر بدءًا من ⁦%1$s⁩ + الإنفاق الأسبوعي + ⁦%1$s⁩ أسبوعيًا، بدءًا من ⁦%2$s⁩ + أسبوعيًا + المتبقي + الإجمالي + مرات النقر + يبدو أن الجهاز في وضع توفير الطاقة. \nيتعذر علينا تقديم معلومات المتجر الخاصة بك بينما يكون نشطًا. + القائمة المنبثقة مع الخيارات. مرر للتنقل عبر العناصر. + فتح قائمة شريط الأدوات + شريط أدوات يتضمن حالة قارئ البطاقات. القائمة مفتوحة. انقر نقرًا مزدوجًا للتفاعل. + شريط أدوات يتضمن حالة قارئ البطاقات. انقر نقرًا مزدوجًا للتفاعل. + تم تعطيل القائمة + تم تمكين القائمة + قارئ البطاقات غير متصل. النقر نقرًا مزدوج للاتصال + قارئ البطاقات غير متصل + خلفية خافتة. انقر لإغلاق القائمة. + أيقونة علامة الاختيار للدفع الناجح + إزالة هذا العنصر من عربة التسوق + سيتم فقدان أي طلبات قيد التقدم. + هل تريد إنهاء وضع نقطة البيع؟ + إغلاق + خلفية خافتة. انقر لتجاهل مربع الحوار. + انقر نقرًا مزدوجًا لتجاهل مربع الحوار + مربع حوار المنتجات البسيطة فقط + النقر نقرًا مزدوجًا للتعرف على المزيد + شعار المنتجات البسيطة فقط + روِّج لمنتجاتك من خلال Blaze Ads وارفع مبيعاتك الآن. + تعزيز مبيعاتك + تلقي المدفوعات في أثناء التنقل + تم إدخال رمز PIN غير صحيح. المحاولة الآن، أو استخدام وسائل دفع أخرى القائمة المنتج في عربة التسوق %s، السعر %s المنتج %s، السعر %s @@ -14,8 +74,7 @@ Language: ar طلب جديد موافق + إنشاء طلب في إدارة المتجر - لتلقي دفعة مقابل منتج غير بسيط، اخرج من نقطة البيع وأنشئ طلبًا جديدًا من علامة تبويب الطلبات. - لا يمكن استخدام إلا المنتجات المادية البسيطة التي تتضمن نقطة بيع الآن.\nأما أنواع المنتجات الأخرى، مثل: المنتجات المتغيرة والظاهرية، ستتوفر في التحديثات المستقبلية. + لتلقي دفعة مقابل منتج غير بسيط، اخرج من نقطة البيع وأنشئ طلبًا جديدًا من علامة تبويب الطلبات. لماذا يتعذر علي رؤية منتجاتي؟ معلومات إغلاق @@ -39,8 +98,6 @@ Language: ar لا يدعم POS حاليًا سوى المنتجات البسيطة - \nأنشئ واحدًا هنا للبدء. لم يتم العثور على منتجات مدعومة لا توجد منتجات - لنقدم خدمات إلى بعض العملاء - تشغيل الحصول على الدعم توصيل القارئ الخاص بك تمت إزالة الصورة @@ -135,8 +192,7 @@ Language: ar لا توجد قواعد للكميات الجمهور إلغاء - إنهاء - هل تريد بالتأكيد إنهاء POS؟ + إنهاء إنهاء POS إزالة %s من عربة التسوق السداد @@ -419,20 +475,16 @@ Language: ar الشعار تغيير الصورة تطبيق - يبدأ في + تاريخ البدء ⁦%1$s⁩ من الأيام - تعيين المدة تعكس الانطباعات عدد مرات ظهور إعلانك للعملاء المحتملين.\n\n\n على الرغم من تعذر التأكد من الأرقام الدقيقة بسبب تأرجح حركة المرور على الإنترنت وسلوك المستخدمين، فإننا نهدف إلى مطابقة الانطباعات الفعلية على إعلانك قدر المستطاع مع العدد المستهدف.\n\n\n تذكر أن الانطباعات تدور حول الرؤية وليس الإجراء الذي يتخذه المشاهدون. تم الانطباعات تحديث تحرير - المدة عدد الأشخاص المتوقع وصولهم يوميًا ⁦%1$s⁩ يوميًا لمدة ⁦%1$s⁩ من الأيام - إجمالي الإنفاق - ما المبلغ الذي تود إنفاقه على حملة الترويج لمنتجك؟ تعيين ميزانيتك الكل ⁦%1$s⁩ من الأيام من ⁦%2$s⁩ @@ -634,7 +686,6 @@ Language: ar جذب مزيد من المبيعات إلى متجرك باستخدام Blaze كان هناك خطأ في أثناء تحديث قائمة الحملات. ترجى المحاولة مرة أخرى لاحقًا. تحديد مصدر الوسائط - حدث خطأ في أثناء إنشاء اسم المنتج ووصفه. لم يتم الكشف عن نص. يرجى تحديد صورة حزم أخرى أو إدخال تفاصيل المنتج يدويًا. إضافة منتج مسح الرمز الشريطي ضوئيًا @@ -656,9 +707,6 @@ Language: ar جرّب مدفوعات %s من خلال بطاقة الخصم أو الائتمان لديك.\nستُستَرد المدفوعات عندما تنتهي. إنها سهلة وآمنة وخاصة. اقبل كل أنواع المدفوعات الشخصية، مباشرة\nعلى هاتفك. لا يلزم أجهزة إضافة. - الميزانية - النقرات - الانطباعات مرفوض مكتمل نشط @@ -691,46 +739,23 @@ Language: ar الإعدادات إضافة التفاصيل يدويًا باستخدام البريد الإلكتروني تتعذر علينا معالجة طلب تسجيل الدخول إلى تطبيقك - خطأ في أثناء نسخ اسم المنتج إلى الحافظة. - تم نسخ اسم المنتج إلى الحافظة. - تم إنشاء اسم المنتج بواسطة الذكاء الاصطناعي خطأ في أثناء نسخ وصف المنتج إلى الحافظة. تم نسخ وصف المنتج إلى الحافظة. فشل إنشاء المنتج. يرجى المحاولة مجددًا مسح العنوان وإيقاف استخدام هذا المعدل تعيين معدل ضريبة جديد لهذا الطلب إضافة معدل الضريبة تلقائيًا - كانت هناك مشكلة في أثناء إنشاء اسم المنتج. ترجى المحاولة مجددًا. - إعادة الإنشاء - كتابة ذلك من أجلي - أخبرنا بمضمون منتجك وما الذي يجعله فريدًا! - السماح للذكاء الاصطناعي بإنشاء عناوين جذابة من أجلك - اسم المنتج حاول تعطيل عامل التصفية غير المقروء لرؤية كل مراجعات منتجك لا توجد مراجعات غير مقروءة للمنتج - تم تحديد النغمة الإقناع زهري رسمي عادي - قم بتعيين النغمة والصوت لتشكيل العرض التقديمي لمنتجك الذي يتوافق مع علامتك التجارية. النغمة والصوت تفاصيل - وصف المنتج اسم المنتج - يمكنك دومًا تغيير التفاصيل أدناه لاحقًا. معاينة - إنشاء تفاصيل المنتجات - تعيين النغمة والصوت - أضف الميزات الأساسية أو المزايا أو التفاصيل لمساعدة منتجك على الظهور على الإنترنت. على سبيل المثال، قماش ناعم وخياطة متينة وتصميم فريد - أبرز ما يجعل منتجك فريدًا، واسمح للذكاء الاصطناعي بنثر سحره. - نبذة عن منتجك - اقتراح اسم - على سبيل المثال: قماش ناعم وخياطة متينة وتصميم فريد. - اسم المنتج - أو وسِّع نطاق اختياراتك عن طريق النقر للحصول على مزيد من اقتراحات الأسماء. - إضافة اسم منتجك مدعوم من الذكاء الاصطناعي. <a href=\'guidelines\'><u>تعرّف على المزيد</u></a>. إضافة منتج والتفاصيل يدويًا إضافة يدويًا @@ -1054,7 +1079,6 @@ Language: ar زيادة المبيعات من خلال العروض الخاصة عرض متجرك البقاء على اطلاع - الانضمام إلى المدفوعات عبر الهاتف المحمول إدارة المزيد على المسؤول عام الإعدادات diff --git a/WooCommerce/src/main/res/values-de/strings.xml b/WooCommerce/src/main/res/values-de/strings.xml index d6deae43422..b073e720ba4 100644 --- a/WooCommerce/src/main/res/values-de/strings.xml +++ b/WooCommerce/src/main/res/values-de/strings.xml @@ -1,11 +1,71 @@ + Ungültiger Schlüssel: Bitte entferne „_“ vom Anfang des Schlüssels. + Dieser Schlüssel wird bereits für ein anderes individuelles Feld verwendet.\nDie App unterstützt derzeit nicht das Erstellen von doppelten Schlüsseln. Bitte verwende wp-admin, um einen Schlüssel zu duplizieren, falls dies erforderlich ist. + Individuelle Felder hinzufügen + Individuelles Feld gelöscht + Die Änderungen konnten nicht gespeichert werden, bitte versuche es erneut + Änderungen gespeichert + Änderungen werden gespeichert + Du bist anscheinend nicht mit dem Internet verbunden. Stelle sicher, dass dein WLAN eingeschaltet ist. Falls du mobile Daten verwendest, solltest du prüfen, ob sie in deinen Geräteeinstellungen aktiviert sind. + Scannen fehlgeschlagen. Bitte versuche es später erneut + Wert + Schlüssel + Weitere Produkttypen, wie variable und virtuelle, werden in künftigen Updates bereitgestellt. + Derzeit können nur einfache physische Produkte mit POS verwendet werden. + Abbrechen + Dauer + Die Kampagne läuft, bis du sie beendest. + Dauer angeben + bis zum %1$s + Zeitplan + Ausgaben für einen Tag + Wie viel Geld willst du für deine Kampagne ausgeben und wie lange soll sie laufen? + %1$s ➔ %2$s + Präsentiere deine Produkte mit Blaze Millionen von Interessenten und erhöhe deine Verkaufszahlen + Möchtest du deine Verkaufszahlen erhöhen? + Fehler beim Laden der individuellen Felder + Individuelle Felder + Abgedunkelter Hintergrund. Zum Schließen des Dialogs tippen. + %1$s wöchentlich + Ausführen, bis ich sie beende + Seit dem %1$s + Ausgaben für eine Woche + %1$s wöchentlich, ab dem %2$s + Wöchentlich + Verbleibend + Gesamt + Klickraten + Für dein Gerät wurde der Energiesparmodus aktiviert. \nSolange er aktiviert ist, können wir keine Informationen zu deinem Shop bereitstellen + Pop-up-Menü mit Optionen. Wische, um zwischen den Artikeln zu navigieren. + Werkzeugleiste öffnen + Werkzeugleiste mit Status des Kartenlesegeräts. Das Menü ist geöffnet. Zum Interagieren zweimal tippen. + Werkzeugleiste mit Status des Kartenlesegeräts. Zum Interagieren zweimal tippen. + Menü deaktiviert + Menü aktiviert + Kartenlesegerät nicht verbunden. Zum Auswählen zweimal tippen + Kartenlesegeräte verbunden + Abgedunkelter Hintergrund. Zum Schließen des Menüs tippen. + Häkchen-Icon bei erfolgreicher Zahlung + Diesen Artikel aus dem Warenkorb entfernen + Alle Bestellungen, die sich gerade in Bearbeitung befinden, gehen verloren. + Modus „Verkaufsort“ (POS) beenden? + Schließen + Abgedunkelter Hintergrund. Zum Schließen des Dialogs tippen. + Zum Schließen des Dialogs zweimal tippen + Dialog für einfache Produkte + Zweimal tippen, um weitere Informationen zu erhalten + Banner für einfache Produkte + Bewerbe deine Produkte mit Blaze-Werbung und steigere jetzt deinen Umsatz. + Erhöhe deinen Umsatz + Erhalte Zahlungen auch unterwegs + Es wurde eine falsche PIN eingegeben. Versuche es erneut oder verwende eine andere Zahlungsmethode Menü Produkt im Warenkorb %s, Preis %s Produkt %s, Preis %s @@ -14,12 +74,11 @@ Language: de Neue Bestellung OK + Eine Bestellung im Shop-Management erstellen - Um die Zahlung für ein nicht einfaches Produkt entgegenzunehmen, beende POS und erstelle eine neue Bestellung über den Tab „Bestellungen“. - Derzeit können nur einfache physische Produkte mit POS verwendet werden.\nWeitere Produkttypen, wie variable und virtuelle, werden in künftigen Updates bereitgestellt. + Um die Zahlung für ein nicht einfaches Produkt entgegenzunehmen, beende POS und erstelle eine neue Bestellung über den Tab „Bestellungen“. Warum werden mir meine Produkte nicht angezeigt? Info Schließen - Weitere Informationen + Mehr\u00A0erfahren Derzeit sind nur einfache physische Produkte mit POS kompatibel. Weitere Produkttypen, wie variable und virtuelle, werden in künftigen Updates bereitgestellt. Es werden nur einfache Produkte angezeigt Website-Adresse @@ -39,8 +98,6 @@ Language: de POS unterstützt derzeit nur einfache Produkte – \nerstelle eins, um anzufangen. Keine unterstützten Produkte gefunden Keine Produkte - Zeit, deine Kunden glücklich zu machen - Wird geladen Erhalte Support Kartenlesegerät verbinden Foto entfernt @@ -135,8 +192,7 @@ Language: de Keine Mengenregeln Zielgruppe Abbrechen - Beenden - Bist du sicher, dass du POS beenden möchtest? + Beenden POS beenden %s aus Warenkorb entfernen Bezahlen @@ -419,20 +475,16 @@ Language: de Untertitel Bild ändern Übernehmen - Beginnt + Startdatum %1$s Tage - Dauer festlegen Aufrufe geben die Häufigkeit an, mit der deine Werbung potenziellen Kunden angezeigt wird.\n\n\n Auch wenn aufgrund von schwankendem Internet-Traffic und Nutzerverhalten keine genauen Zahlen garantiert werden können, versuchen wir, die tatsächlichen Aufrufe deiner Werbeanzeige so genau wie möglich mit deiner Zielzahl abzugleichen.\n\n\n Denke daran, dass es bei Aufrufen um die Sichtbarkeit geht und nicht um vom Betrachter ausgeführte Aktionen. Fertig Aufrufe Update Bearbeiten - Dauer Geschätzte Anzahl erreichter Personen pro Tag %1$s täglich für %1$s Tage - Gesamtbestellwert - Wie viel möchtest du für deine Produkt-Werbekampagne ausgeben? Dein Budget festlegen Alle %1$s Tage seit %2$s @@ -634,7 +686,6 @@ Language: de Mit Blaze die Umsätze deines Shops steigern Beim Aktualisieren der Kampagnenliste ist ein Fehler aufgetreten. Bitte versuche es später erneut. Medienquelle auswählen - Beim Generieren von Produktname und -beschreibung ist ein Fehler aufgetreten. Kein Text erkannt. Bitte wähle ein anderes Foto der Verpackung aus oder gib die Produktdetails manuell ein. Produkt hinzufügen Barcode scannen @@ -656,9 +707,6 @@ Language: de Probiere eine %s-Zahlung mit deiner Debit- oder Kreditkarte aus.\nDie Zahlung wird zurückerstattet, wenn du fertig bist. Es ist einfach, sicher und privat. Du kannst alle Arten von persönlichen Zahlungen akzeptieren, direkt\nauf deinem Mobiltelefon. Es ist keine zusätzliche Hardware erforderlich. - Budget - Klicks - Aufrufe Abgelehnt Abgeschlossen Aktiv @@ -691,46 +739,23 @@ Language: de EINSTELLUNGEN Details anhand der E-Mail-Adresse manuell hinzufügen Deine App-Anmeldeanfrage konnte nicht verarbeitet werden - Fehler beim Kopieren des Produktnamens in die Zwischenablage. - Produktname in die Zwischenablage kopiert. - Produktname von KI generiert Fehler beim Kopieren der Produktbeschreibung in die Zwischenablage. Produktbeschreibung in die Zwischenablage kopiert. Produkterstellung ist fehlgeschlagen. Bitte versuche es erneut Adresse löschen und diesen Satz nicht mehr verwenden Neuen Steuersatz für diese Bestellung festlegen Steuersatz wird automatisch hinzugefügt - Beim Generieren des Produktnamens ist ein Problem aufgetreten. Bitte versuche es erneut. - Neu generieren - Für mich schreiben - Erzähle uns mehr von deinem Produkt und was es einzigartig macht! - Lasse die KI ansprechende Titel für dich generieren - Produktname Deaktiviere den Filter für ungelesene Produktbewertungen, damit dir alle angezeigt werden Keine ungelesenen Produktbewertungen - Tonalität ausgewählt Überzeugend Blumig Formell Locker - Lege Sprache und Tonalität fest, um die Präsentation deines Produkts an deine Marke anzupassen. Sprache und Tonalität Details - Produktbeschreibung Produktname - Du kannst die unten angegebenen Informationen später jederzeit ändern. Vorschau - Produktdetails erstellen - Sprache und Tonalität festlegen - Füge wichtige Funktionen, Vorteile oder Details hinzu, damit dein Produkt online besser gefunden wird. Zum Beispiel: weicher Stoff, langlebige Absteppung, einzigartiges Design - Hebe hervor, was dein Produkt einzigartig macht – die KI übernimmt den Rest. - Über dein Produkt - Namen vorschlagen - Zum Beispiel: weicher Stoff, langlebige Absteppung, einzigartiges Design. - Produktname - Oder erweitere deine Auswahl, indem du tippst, um weitere Namensvorschläge zu erhalten. - Deinen Produktnamen hinzufügen Mit Unterstützung von KI. <a href=\'guidelines\'><u>Weitere Informationen</u></a>. Füge ein Produkt und die entsprechenden Details manuell hinzu Manuell hinzufügen @@ -1054,7 +1079,6 @@ Language: de Steigere deine Umsätze mit speziellen Angeboten Deinen Shop ansehen Bleibe auf dem Laufenden - Nutze mobile Zahlungen Verwalte mehr als Admin Allgemeines Einstellungen diff --git a/WooCommerce/src/main/res/values-es/strings.xml b/WooCommerce/src/main/res/values-es/strings.xml index a0cb0b40d02..8a94b3295a3 100644 --- a/WooCommerce/src/main/res/values-es/strings.xml +++ b/WooCommerce/src/main/res/values-es/strings.xml @@ -1,11 +1,71 @@ + Clave no válida: elimina el carácter «_» del principio. + Esta clave ya se utiliza para otro campo personalizado.\nActualmente, la aplicación no permite crear claves duplicadas. Utiliza wp-admin para duplicar una clave si es necesario. + Añadir campos personalizados + Campo personalizado borrado + No se han podido guardar los cambios; inténtalo de nuevo + Cambios guardados + Guardando cambios + Parece que no estás conectado a Internet. Comprueba que tienes activado el wifi. Si te vas a conectar con datos móviles, asegúrate de que estén activados en los ajustes de tu dispositivo. + No se ha podido realizar la exploración. Inténtalo de nuevo más tarde + Valor + Clave + Otros tipos de productos, como los variables y los virtuales, estarán disponibles en futuras actualizaciones. + En estos momentos, solo se pueden utilizar productos físicos sencillos con TPV. + Cancelar + Duración + La campaña continuará hasta que la detengas. + Especificar la duración + a %1$s + Programar + Gasto diario + ¿Cuánto te gustaría gastar en tu campaña y cuánto tiempo debería durar? + %1$s ➔ %2$s + Consigue que tus productos sean vistos por millones de personas con Blaze e impulsa tus ventas + ¿Quieres aumentar tus ventas? + Error al cargar los campos personalizados + Campos personalizados + Fondo atenuado. Toca para cerrar el diálogo. + %1$s a la semana + Ejecutar hasta que yo lo detenga + En curso a partir del %1$s + gasto semanal + %1$s a la semana a partir del %2$s + A la semana + Restante + Total + Clics + Parece que tu dispositivo está en modo de ahorro de batería. \nNo podemos facilitarte información sobre tu tienda mientras esté activo + Menú emergente con opciones. Desliza el dedo para navegar por los elementos. + Abrir el menú de la barra de herramientas + Barra de herramientas con el estado del lector de tarjetas. El menú está abierto. Toca dos veces para interactuar. + Barra de herramientas con el estado del lector de tarjetas. Toca dos veces para interactuar. + Menú deshabilitado + Menú habilitado + Lector de tarjetas no conectado. Toca dos veces para conectarte + Lector de tarjetas conectado + Fondo atenuado. Toca para cerrar el menú. + Icono de pago efectuado con éxito + Eliminar este elemento del carrito + Se perderán los pedidos en curso. + ¿Salir del modo de punto de venta? + Cerrar + Fondo atenuado. Toca para cerrar el diálogo. + Toca dos veces para cerrar el diálogo + Diálogo de solo productos de tipo simple + Toca dos veces para obtener más información + Banner de solo productos de tipo simple + Promociona tus productos con Blaze Ads y aumenta tus ventas ahora. + Aumenta tus ventas + Acepta pagos sobre la marcha + Se ha introducido un PIN incorrecto. Inténtalo de nuevo o utiliza otro método de pago Menú Producto en el carrito %s, precio %s Producto %s, precio %s @@ -14,12 +74,11 @@ Language: es Nuevo pedido Aceptar + Crear un pedido en la gestión de la tienda - Para aceptar el pago de un producto no sencillo, sal de TPV y crea un nuevo pedido desde la pestaña de pedidos. - En estos momentos, solo se pueden utilizar productos físicos sencillos con TPV.\nOtros tipos de productos, como los variables y los virtuales, estarán disponibles en futuras actualizaciones. + Para aceptar el pago de un producto no sencillo, sal de TPV y crea un nuevo pedido desde la pestaña de pedidos. ¿Por qué no puedo ver mis productos? Información Cerrar - Más información + Más\u00A0información En estos momentos, solo los productos físicos sencillos son compatibles con TPV. Otros tipos de productos, como los variables y los virtuales, estarán disponibles en futuras actualizaciones. Mostrar solo productos sencillos Dirección del sitio @@ -39,8 +98,6 @@ Language: es Las órdenes de compra actualmente solo admiten productos simples: \ncrear uno para empezar. No se han encontrado productos compatibles Sin productos - Atendamos a algunos clientes - Puesta en marcha Conseguir ayuda Conectar con tu lector Foto eliminada @@ -135,8 +192,7 @@ Language: es No hay reglas de cantidad Público Cancelar - Salir - ¿Estás seguro que quieres salir del punto de venta? + Salir Salir del punto de venta Eliminar %s del carrito Finalizar compra @@ -154,7 +210,7 @@ Language: es Pocas existencias ENVÍO Comparte tu opinión - ¿Se compra fácilmente en Woo? + ¿Es fácil gestionar envíos con Woo? ¡Se ha añadido el envío! Editar envío Editar envío @@ -214,7 +270,7 @@ Language: es Los análisis de sesiones se basan en los recuentos de visitantes únicos, que no están disponibles para rangos de fechas personalizados Datos de la sesión no disponibles No disponible - Rendimiento + Estadísticas Personalizado Botón de cambio de rango de fechas Las imágenes no están disponibles porque tu sitio está marcado como privado. Puedes modificar este ajuste cambiando al modo Próximamente.\n @@ -256,7 +312,7 @@ Language: es Paquetes Paquetes vendidos Campañas de Blaze - Mejor rendimiento + Productos más vendidos ¿Seguro que deseas descartar los cambios realizados a este producto? Vas a descartar los cambios en %s La función de estadísticas no admite la visualización de los datos de visitantes y conversiones para rangos de fechas arbitrarios.\n\nSin embargo, puedes pulsar un valor del gráfico para consultar los visitantes y las conversiones de ese rango específico. @@ -419,20 +475,16 @@ Language: es Descripción corta Cambiar imagen Aplicar - Empieza + Fecha de inicio %1$s días - Fijar duración Las impresiones reflejan la frecuencia con la que aparece tu anuncio a los clientes potenciales.\n\n\n Aunque no se pueden garantizar cifras exactas debido a las fluctuaciones del tráfico en línea y del comportamiento de los usuarios, nuestro objetivo es que las impresiones reales de tu anuncio coincidan lo más posible con el recuento objetivo.\n\n\n No olvides que las impresiones tienen que ver con la visibilidad, no con la acción de los lectores. Hecho Impresiones Actualizar Editar - Duración Estimación diaria del número de personas a la que has llegado %1$s al día durante %1$s días - Gasto total - ¿Cuánto te gustaría gastar en la campaña de promoción de tu producto? Fija tu presupuesto Todos %1$s días desde %2$s @@ -634,7 +686,6 @@ Language: es Genera más ventas hacia tu tienda con Blaze Ha habido un error al actualizar la lista de campañas. Inténtalo de nuevo más tarde. Elige una fuente de medios - Ha ocurrido un error al generar el nombre y la descripción del producto. No se ha detectado texto. Elige otra foto del embalaje o introduce los detalles del producto manualmente. Añadir producto Escanear código de barras @@ -656,9 +707,6 @@ Language: es Haz un pago de %s con tu tarjeta de débito o crédito.\nEl pago se te reembolsará cuando hayas terminado. Es fácil, seguro y privado. Acepta todo tipo de pagos en persona, directamente\nen tu teléfono. No se necesita ningún hardware adicional. - Presupuesto - Clics - Impresiones Rechazada Completada Activa @@ -691,46 +739,23 @@ Language: es AJUSTES Añadir detalles de forma manual mediante el correo electrónico No hemos podido procesar tu solicitud de acceso a la aplicación - Se ha producido un error al copiar el nombre del producto en el portapapeles. - Nombre del producto copiado en el portapapeles. - Nombre del producto generado por la IA Se ha producido un error al copiar la descripción del producto en el portapapeles. Descripción del producto copiada en el portapapeles. La generación del producto ha fallado. Inténtalo de nuevo Borrar dirección y dejar de utilizar esta tasa Establecer una nueva tasa de impuesto para este pedido Añadir automáticamente la tasa de impuesto - Se ha producido un problema al generar el nombre del producto. Inténtalo de nuevo. - Regenerar - Escríbelo por mí - Cuéntanos en qué consiste tu producto y qué lo hace único. - Deja que la IA genere títulos cautivadores por ti - Nombre del producto Prueba a desactivar el filtro de no leídos para ver todas las reseñas de tus productos No hay reseñas de productos no leídas - Tono seleccionado Convincente Floral Formal Casual - Establece el tono y la voz para dar forma a una presentación de tu producto que se ajuste a tu marca. Tono y voz Detalles - Descripción del producto Nombre del producto - Siempre puedes cambiar los detalles de abajo más adelante. Vista previa - Crear detalles del producto - Establecer tono y voz - Añade características, ventajas o detalles clave para ayudar a que tu producto se encuentre en Internet. Por ejemplo, tejido suave, costuras duraderas, diseño único - Destaca lo que hace que tu producto sea único y deja que la IA haga la magia. - Acerca de tu producto - Sugiere un nombre - Por ejemplo: tejido suave, costuras duraderas, diseño único. - Nombre del producto - O amplía tus opciones pulsando para obtener más sugerencias de nombres. - Añada el nombre de tu producto Con tecnología de IA. <a href=\'guidelines\'><u>Más información</u></a>. Añadir un producto y los detalles manualmente Añadir manualmente @@ -742,8 +767,8 @@ Language: es Esto no afectará a los pedidos en línea Añadir este tipo a todos los pedidos creados Editar tipos impositivos - Editar tipos impositivos en el escritorio - Añade tipos impositivos al escritorio. Solo se mostrarán aquí los tipos impositivos con información sobre la ubicación. + Editar tipos impositivos en wp-admin + Añade tipos impositivos en wp-admin. Solo se mostrarán aquí los tipos impositivos con información sobre la ubicación. No hemos encontrado tipos impositivos Descubre otros proveedores de pago y \nelige un proveedor de pago. Métodos de pago @@ -1054,8 +1079,7 @@ Language: es Aumentar las ventas con ofertas especiales Ver tu tienda Mantengase actualizado - Únete a los pagos a través de móvil - Gestionar más en la administración + Gestionar más en wp-admin General Ajustes Puedes editar productos agrupados en el escritorio del sitio web. @@ -2342,7 +2366,7 @@ Language: es Ajustes de la tienda Atributos Puedes reembolsar %1$s - Recepción de pago + Recibir pago y ¿Eliminar este atributo? Cualquiera @@ -3354,7 +3378,7 @@ Language: es No se ha podido compartir el registro Se ha producido un error al copiar en el portapapeles Se ha copiado el registro en el portapapeles - Registro de solicitud + Ver logs de la aplicación Nuevo mensaje de \"Ayuda y soporte técnico\" WooCommerce No se ha configurado diff --git a/WooCommerce/src/main/res/values-fr/strings.xml b/WooCommerce/src/main/res/values-fr/strings.xml index faefd474014..c0c799c747d 100644 --- a/WooCommerce/src/main/res/values-fr/strings.xml +++ b/WooCommerce/src/main/res/values-fr/strings.xml @@ -1,11 +1,71 @@ + Clé non valide : veuillez supprimer le caractère « _ » se trouvant au début. + Cette clé est déjà utilisée pour un autre champ personnalisé.\nL’application ne prend actuellement pas en charge la création de clés dupliquées. Veuillez utiliser WP Admin pour dupliquer une clé si nécessaire. + Ajouter des champs personnalisés + Champ personnalisé supprimé + Échec de l’enregistrement des modifications, veuillez réessayer + Modifications enregistrées + Enregistrement des modifications + Il semblerait que vous ne soyez pas connecté(e) à Internet. Assurez-vous que votre Wi-Fi est activé. Si vous utilisez des données mobiles, assurez-vous qu’elles sont activées dans les réglages de votre appareil. + Échec du scan. Veuillez réessayer plus tard + Valeur + Clé + D’autres types de produits, tels que les produits virtuels et variables, seront disponibles dans les prochaines mises à jour. + Seuls les produits physiques simples peuvent être utilisés actuellement avec le PDV. + Annuler + Durée + La campagne durera jusqu’à ce que vous y mettiez un terme. + Préciser la durée + jusqu’au %1$s + Planifier + Dépense quotidienne + Combien souhaitez-vous dépenser pour votre campagne et combien de temps doit-elle durer ? + %1$s ➔ %2$s + Faites découvrir vos produits à des millions de personnes et stimulez vos ventes avec Blaze. + Vous souhaitez stimuler vos ventes ? + Erreur lors du chargement des champs personnalisés + Champs personnalisés + Arrière-plan peu éclairé. Appuyez pour ignorer la fenêtre de dialogue. + %1$s par semaine + Exécuter jusqu’à ce que je l’arrête + À partir du %1$s + dépense hebdomadaire + %1$s par semaine à partir du %2$s + Hebdomadaire + Restant + Total + Nombre de clics + Il semblerait que votre appareil soit en mode Économie d’énergie. \nNous ne pouvons pas fournir d’informations sur votre boutique tant qu’il est activé. + Menu contextuel avec options. Balayez pour naviguer parmi les éléments. + Ouvrir le menu de la barre d’outils + Barre d’outils avec l’état du lecteur de carte. Le menu est ouvert. Appuyez deux fois pour interagir. + Barre d’outils avec l’état du lecteur de carte. Appuyez deux fois pour interagir. + Menu désactivé + Menu activé + Lecteur de carte non connecté. Appuyer deux fois pour le connecter + Lecteur de carte connecté + Arrière-plan peu éclairé. Appuyez pour fermer le menu. + Icône en forme de coche Paiement effectué + Retirer cet élément du panier + Toutes les commandes en cours seront perdues. + Quitter le mode point de vente ? + Fermer + Arrière-plan peu éclairé. Appuyez pour ignorer la fenêtre de dialogue. + Appuyez deux fois pour ignorer la fenêtre de dialogue + Fenêtre de dialogue Produits simples uniquement + Appuyer deux fois pour lire la suite + Bannière Produits simples uniquement + Faites la promotion de vos produits avec les publicités Blaze et augmentez vos ventes dès maintenant. + Dynamiser vos ventes + Accepter les paiements en mode nomade + Un code PIN incorrect a été saisi. Réessayer ou utiliser un autre moyen de paiement Menu Produit dans le panier %s, Prix %s Produit %s, Prix %s @@ -14,12 +74,11 @@ Language: fr Nouvelle commande OK + Créer une commande dans la gestion de la boutique - Pour accepter un paiement pour un produit non simple, quittez le PDV et créez une nouvelle commande depuis l’onglet Commandes. - Seuls les produits physiques simples peuvent être utilisés avec le PDV actuellement.\nD’autres types de produits, tels que les produits virtuels et variables, seront disponibles dans les mises à jour à venir. + Pour accepter un paiement pour un produit non simple, quittez le PDV et créez une nouvelle commande depuis l’onglet Commandes. Pourquoi ne puis-je pas voir mes produits ? Informations Fermer - Lire la suite + En savoir plus Seuls les produits physiques simples sont compatibles avec le PDV actuellement. D’autres types de produits, tels que les produits virtuels et variables, seront disponibles dans les mises à jour à venir. Affichage des produits simples uniquement Adresse du site @@ -39,8 +98,6 @@ Language: fr Le PDV ne prend actuellement en charge que les produits simples – \ncréez-en un pour commencer. Aucun produit pris en charge trouvé Aucun produit - Servir davantage de clients - Démarrage Obtenir de l’aide Se connecter à votre lecteur Photo supprimée @@ -135,8 +192,7 @@ Language: fr Pas de règles de quantité Audience Annuler - Quitter - Voulez-vous vraiment quitter le PDV ? + Quitter Quitter le PDV Retirer %s du panier Validation de la commande @@ -419,20 +475,16 @@ Language: fr Description Changer l’image Appliquer - Début + Date de début %1$s jours - Fixer la durée Les impressions reflètent la fréquence à laquelle votre publicité apparaît pour les clients potentiels.\n\n\n Il est impossible de donner les chiffres exacts en raison de la fluctuation du trafic en ligne et des comportements des internautes, nous visons à faire correspondre autant que possible les véritables impressions à votre nombre cible.\n\n\n Rappelez-vous, les impressions sont une affaire de visibilité, pas d’action entreprise par les internautes. Terminé Impressions Mettre à jour Modifier - Durée Estimation du nombre de personnes atteintes par jour %1$s par jour pendant %1$s jours - Dépense totale - Combien souhaiteriez-vous dépenser pour votre campagne promotionnelle ? Définir votre budget Tout %1$s jours à partir de %2$s @@ -634,7 +686,6 @@ Language: fr Augmenter les ventes de votre boutique avec Blaze Une erreur s’est produite lors de l’actualisation de la liste de campagnes. Veuillez réessayer plus tard. Sélectionner une source multimédia - Une erreur est survenue lors de la génération du nom et de la description du produit. Aucun texte détecté. Sélectionnez une autre photo de l’emballage ou entrez manuellement les détails du produit. Ajouter un produit Scanner le code-barres @@ -656,9 +707,6 @@ Language: fr Essayez de procéder à un paiement %s avec votre carte de débit ou de crédit.\nLe paiement sera remboursé lorsque vous aurez terminé. C’est facile, sûr et privé. Acceptez tous les types de paiements en personne directement\nsur votre téléphone. Aucun matériel supplémentaire nécessaire. - Budget - Clics - Impressions Refusée Terminée Active @@ -691,46 +739,23 @@ Language: fr RÉGLAGES Ajouter des détails manuellement avec l’adresse e-mail Nous n’avons pas pu traiter votre demande de connexion à l’application - Erreur lors de la copie du nom du produit dans le presse-papiers. - Nom du produit copié dans le presse-papiers. - Nom de produit généré par l’IA Erreur lors de la copie de la description du produit dans le presse-papiers. Description du produit copiée dans le presse-papiers. Le produit n’a pas pu être généré. Veuillez réessayer Effacer l’adresse et arrêter d’utiliser ce taux Définir un nouveau taux de taxation pour cette commande Ajout automatique du taux de taxation - Un problème est survenu lors de la génération du nom du produit. Veuillez réessayer. - Régénérer - L’écrire à ma place - Présentez-nous votre produit et dites-nous ce qui le rend unique ! - Laissez l’IA générer des titres captivants pour vous - Nom du produit Essayer de désactiver le filtre des avis non lus pour afficher tous les avis sur les produits Aucun avis non lu sur les produits - Ton sélectionné Convaincant Fleuri Formel Décontracté - Définissez le ton et la voix pour que la présentation de votre produit corresponde à votre marque. Ton et voix Détails - Description du produit Nom du produit - Vous pourrez toujours modifier les détails ci-après ultérieurement. Aperçu - Créer les détails du produit - Définir le ton et la voix - Ajoutez les fonctionnalités, les avantages ou les détails clés pour faciliter la recherche de votre produit en ligne. Par exemple : tissu doux, coutures solides, design unique - Mettez en avant ce qui rend votre produit unique et laissez l’IA s’occuper du reste. - À propos de votre produit - Proposer un nom - Par exemple : tissu doux, coutures solides, design unique. - Nom du produit - Vous pouvez aussi obtenir d’autres suggestions de noms en un clic. - Ajouter le nom de votre produit Alimenté par l’IA. <a href=\'guidelines\'><u>Lire la suite</u></a>. Ajouter un produit et les détails manuellement Ajouter manuellement @@ -1054,7 +1079,6 @@ Language: fr Accélérez les ventes grâce à des offres spéciales Voir votre boutique Restez à jour - Associez les paiements sur mobile Gérez plus de paramètres d’administration Général Réglages diff --git a/WooCommerce/src/main/res/values-he/strings.xml b/WooCommerce/src/main/res/values-he/strings.xml index a5691d5c71f..9c41288e6fc 100644 --- a/WooCommerce/src/main/res/values-he/strings.xml +++ b/WooCommerce/src/main/res/values-he/strings.xml @@ -1,11 +1,71 @@ + מפתח לא תקין: יש להסיר את התו \"_\" מההתחלה. + כבר השתמשת במפתח הזה בשדה מותאם אחר.\nהאפליקציה לא תומכת כרגע ביצירה של מפתחות כפולים. אם צריך, יש להשתמש ב\'ניהול WP\' כדי לשכפל מפתח. + להוסיף שדה מותאם + השדה המותאם נמחק + שמירת השינויים נכשלה, יש לנסות שוב + השינויים נשמרו + שומר שינויים + נראה שהחיבור לאינטרנט לא פעיל אצלך. יש לוודא שהחיבור ל-Wi-Fi הופעל. אם ברצונך להשתמש בנתונים הניידים, עליך לוודא שהאפשרות הופעלה בהגדרות המכשיר. + הסריקה נכשלה. יש לנסות שוב מאוחר יותר + ערך + מפתח + סוגי מוצרים אחרים, כמו מוצרים עם סוגים או מוצרים וירטואליים, יהיו זמינים בעדכונים בעתיד. + נכון לעכשיו, ניתן להשתמש ב-POS רק עבור מוצרים פיזיים פשוטים. + ביטול + משך זמן + הקמפיין יהיה פעיל עד שיושבת על ידיך. + יש לציין משך + עד ⁦%1$s⁩ + תזמון + הוצאה יומית + כמה ברצונך להוציא על הקמפיין וכמה זמן ברצונך להפעיל אותו? + %1$s ➔ %2$s + בעזרת Blaze, מיליונים יראו את המוצרים שלך והמכירות ישתפרו + רוצה לשפר את המכירות שלך? + אירעה שגיאה בעת טעינת שדות מותאמים + שדות מותאמים + רקע מעומעם. יש להקיש כדי לצאת מתיבת הדו-שיח. + ⁦%1$s⁩ בכל שבוע + להמשיך בפעולה עד שאעצור אותה + פעיל ברצף מ-⁦%1$s⁩ + הוצאה שבועית + חיוב שבועי של ⁦%1$s⁩, החל מ-⁦%2$s⁩ + שבועית + נשאר + סכום כולל + קליקים + נראה שהמכשיר שלך נמצא במצב \'חיסכון בסוללה\'. \nאנחנו לא יכולים לספק פרטים על החנות שלך כאשר המצב הזה פעיל + תפריט קופץ עם אפשרויות. יש להחליק כדי לנווט בין הפריטים. + לפתוח את התפריט של סרגל הכלים + סרגל כלים עם מצב של קורא כרטיסים. התפריט פתוח. יש להקיש הקשה כפולה לאינטראקציה. + סרגל כלים עם מצב של קורא כרטיסים. יש להקיש הקשה כפולה לאינטראקציה. + התפריט הושבת + התפריט הופעל + קורא הכרטיסים לא מחובר. יש להקיש הקשה כפולה כדי להתחבר + קורא הכרטיסים מחובר + רקע מעומעם. יש להקיש כדי לסגור את התפריט. + סמל אישור שמציין את הצלחת התשלום + להסיר את הפריט מעגלת הקניות + כל התקדמות שנרשמה בהזמנות תאבד. + האם לצאת ממצב \'נקודת מכירה\'? + לסגור + רקע מעומעם. יש להקיש כדי לצאת מתיבת הדו-שיח. + יש להקיש הקשה כפולה כדי לצאת מתיבת הדו-שיח + תיבת דו-שיח של מוצרים פשוטים בלבד + יש להקיש הקשה כפולה למידע נוסף + באנר של מוצרים פשוטים בלבד + לקדם מוצרים עם פרסומות של Blaze כדי להגדיל את המכירות שלך עכשיו. + לשפר את המכירות + לגבות תשלומים מכל מקום + הוזן קוד PIN שגוי. יש לנסות שוב או להשתמש באמצעי תשלום אחר תפריט מוצר בעגלת הקניות %s, מחיר %s מוצר %s, מחיר %s @@ -14,12 +74,11 @@ Language: he_IL הזמנה חדשה אישור + ליצור הזמנה בניהול החנות - כדי לגבות תשלום במוצר שאינו מוצר פשוט, יש לצאת מ-POS וליצור הזמנה חדשה מלשונית ההזמנות. - נכון לעכשיו, ניתן להשתמש ב-POS רק עבור מוצרים פיזיים פשוטים.\nסוגי מוצרים אחרים, כמו מוצרים עם סוגים או מוצרים וירטואליים, יהיו זמינים בעדכונים בעתיד. + כדי לגבות תשלום במוצר שאינו מוצר פשוט, יש לצאת מ-POS וליצור הזמנה חדשה מלשונית ההזמנות. למה המוצרים שלי לא מופיעים? מידע לסגור - למידע נוסף + למידע\u00A0נוסף רק מוצרים פיזיים פשוטים תואמים ל-POS כרגע. סוגי מוצרים אחרים, כמו מוצרים עם סוגים או מוצרים וירטואליים, יהיו זמינים בעדכונים בעתיד. מציג רק מוצרים פשוטים כתובת האתר @@ -39,8 +98,6 @@ Language: he_IL האפשרות POS נתמכת כרגע במוצרים פשוטים בלבד – \nיש ליצור אחד כזה כדי להתחיל. לא נמצאו מוצרים נתמכים אין מוצרים - זה הזמן להעניק ללקוחות שירות - מתחילים כאן לקבלת תמיכה לחבר את הקורא התמונה הוסרה @@ -135,8 +192,7 @@ Language: he_IL אין כללי כמות קהל ביטול - יציאה - האם ברצונך לצאת מ-POS? + יציאה לצאת מ-POS להסיר את %s מעגלת הקניות תשלום בקופה @@ -419,20 +475,16 @@ Language: he_IL תיאור האתר להחליף תמונה להחיל - מתחיל במועד + תאריך התחלה ⁦%1$s⁩ ימים - להגדיר מיקום הצפיות מייצגות את התדירות שבה הפרסומת שלך מוצגת ללקוחות פוטנציאליים.\n\n\n אומנם לא ניתן להבטיח שהמספרים יהיו מדויקים עקב שינויים בתעבורה המקוונת והתנהגות המשתמשים, אך אנחנו שואפים להתאים כמה שאפשר את מספר הצפיות במודעה שלך בפועל למספר היעד שהצבת.\n\n\n חשוב לזכור, הצפיות משקפות את הנראות של הפרסומת, לא את הפעולה שבוצעה על ידי הצופים. סיימתי צפיות לעדכן לערוך - משך זמן מספר האנשים שיראו את התוכן בכל יום ⁦%1$s⁩ בכל יום ל-⁦%1$s⁩ ימים - הוצאה כוללת - כמה ברצונך להוציא על קמפיין הקידום של המוצר שלך? להגדיר את תקציב שלך הכול ⁦%1$s⁩ ימים מ-⁦%2$s⁩ @@ -634,7 +686,6 @@ Language: he_IL להגביר מכירות בחנות שלך בעזרת Blaze שגיאה בעת ריענון רשימת הקמפיינים. נא לנסות שוב מאוחר יותר. לבחור מקור מדיה - שגיאה בעת יצירת שם ותיאור מוצר. לא זוהה טקסט. יש לבחור תמונת אריזה אחרת או להזין את פרטי המוצר באופן ידני. להוסיף מוצר לסרוק ברקוד @@ -656,9 +707,6 @@ Language: he_IL לנסות תשלום של %s באמצעות כרטיס החיוב או כרטיס האשראי.\nהתשלום יוחזר בסיום. התהליך קל, מאובטח ופרטי. לקבל את כל הסוגים של תשלומים באופן אישי, ישר\nבטלפון. ללא צורך בחומרה נוספת. - תקציב - קליקים - צפיות נדחה הושלם פעיל @@ -691,46 +739,23 @@ Language: he_IL הגדרות להוסיף פרטים ידנית באמצעות אימייל לא הצלחנו לעבד את בקשת הכניסה לאפליקציה - שגיאה בעת ההעתקה של שם המוצר ללוח. - שם המוצר הועתק ללוח. - שם המוצר נוצר על ידי בינה מלאכותית שגיאה בעת ההעתקה של תיאור המוצר ללוח. תיאור המוצר הועתק ללוח. יצירת המוצר נכשלה. יש לנסות שוב למחוק את הכתובת ולהפסיק להשתמש בשיעור המס הזה לקבוע שיעור מס חדש להזמנה זו הוספה אוטומטית של שיעור מס - אירעה בעיה ביצירה של שם המוצר. יש לנסות שוב. - ליצור מחדש - לכתוב עבורי - נשמח לשמוע מה המוצר שברצונך למכור ומה מייחד אותו! - הבינה המלאכותית יכולה ליצור עבורך שמות מעניינים - שם המוצר כדי להציג את כל הביקורות שהתקבלו למוצר, נסו להשבית את המסנן של פריטים שלא נקראו אין ביקורות שלא נקראו למוצרים - טון הדיבור שנבחר משכנע מתחכם רשמי יומיומי - כדאי להגדיר את הנימה ואת טון הדיבור כדי לוודא שהתצוגה של המוצר מתאימה למותג שלך. נימה וטון דיבור פרטים - תיאור המוצר שם המוצר - תמיד אפשר לשנות את הפרטים שלמטה מאוחר יותר. תצוגה מקדימה - ליצור את פרטי המוצר - להגדיר את הנימה וטון הדיבור - יש להוסיף מאפיינים מרכזיים, יתרונות או פרטים שיעזרו למחפשים למצוא את המוצר שלך באינטרנט. למשל: בד רך, תפירה חזקה, עיצוב ייחודי - ניתן להדגיש את המאפיינים שמייחדים את המוצר שלך ולהשתמש בקסמיה של הבינה המלאכותית כדי ליצור שמות. - פרטים לגבי המוצר - להציע שם - למשל: בד רך, תפירה חזקה, עיצוב ייחודי. - שם המוצר - לחלופין, ניתן להרחיב את הבחירה על ידי הקשה על הצעות לשמות נוספים. - לציין את שם המוצר מופעל באמצעות בינה מלאכותית. <a href=\'guidelines\'><u>למידע נוסף</u></a>. להוסיף מוצר ואת הפרטים באופן ידני להוסיף ידנית @@ -1054,7 +1079,6 @@ Language: he_IL לשפר את שיעור המכירה עם מבצעים מיוחדים הצגת החנות שלך להישאר בעניינים - להצטרף לתשלומים מהנייד לנהל אפשרויות נוספות באזור הניהול כללי הגדרות diff --git a/WooCommerce/src/main/res/values-id/strings.xml b/WooCommerce/src/main/res/values-id/strings.xml index f65de263220..b91c73ee2d6 100644 --- a/WooCommerce/src/main/res/values-id/strings.xml +++ b/WooCommerce/src/main/res/values-id/strings.xml @@ -1,11 +1,71 @@ + Kunci tidak valid: hapus karakter \"_\" dari awal. + Kunci ini sudah digunakan untuk kolom kustom yang lain. \nSaat ini aplikasi tidak mendukung pembuatan kunci duplikat. Bila perlu, gunakan wp-admin untuk membuat duplikat kunci. + Tambahkan kolom khusus + Perubahan tersimpan + Menyimpan perubahan + Tampaknya Anda tidak terhubung ke internet. Pastikan Wi-Fi Anda aktif. Jika Anda menggunakan data seluler, aktifkan di pengaturan perangkat Anda. + Pemindaian gagal. Coba lagi nanti + Kolom Kustom telah dihapus + Gagal menyimpan perubahan, coba lagi + Nilai + Kunci + Jenis produk lain, seperti variabel dan virtual, akan tersedia dalam pembaruan mendatang. + Saat ini, hanya produk fisik sederhana yang dapat menggunakan POS. + Batal + Durasi + Kampanye akan terus berjalan sampai Anda menghentikannya. + Tentukan durasi + menjadi %1$s + Jadwal + Pengeluaran per hari + Berapa banyak dana yang ingin Anda belanjakan untuk kampanye, dan berapa lama durasinya? + %1$s ➔ %2$s + Ingin meningkatkan penjualan Anda? + Perlihatkan produk Anda kepada jutaan orang dengan Blaze dan tingkatkan penjualan Anda + Error saat memuat kolom khusus + Kolom Khusus + Latar belakang diredupkan. Ketuk untuk menutup dialog. + %1$s per minggu + Jalankan terus sampai saya nonaktifkan + %1$s per minggu, mulai dari %2$s + Per minggu + Tersisa + Total + Klik-tayang + Sepertinya perangkat Anda dalam mode Penghemat Baterai. \nKami tidak dapat memberikan informasi toko selama mode ini aktif. + Berjalan sejak %1$s + pengeluaran per minggu + Menu popup yang memuat sejumlah opsi. Geser untuk mengakses aneka item. + Buka menu bilah alat + Bilah alat dengan status pembaca kartu. Menu terbuka. Ketuk dua kali untuk berinteraksi. + Bilah alat dengan status pembaca kartu. Ketuk dua kali untuk berinteraksi. + Menu Dinonaktifkan + Menu Diaktifkan + Pembaca kartu tidak terhubung. Ketuk dua kali untuk menghubungkan + Pembaca kartu terhubung + Latar belakang diredupkan. Ketuk untuk menutup menu. + Ikon centang untuk pembayaran berhasil + Hapus item ini dari keranjang + Semua pesanan yang sedang aktif akan hilang. + Keluar dari mode Point of Sale? + Tutup + Latar belakang diredupkan. Ketuk untuk menutup dialog. + Ketuk dua kali untuk menutup dialog + Dialog khusus produk sederhana + Ketuk dua kali untuk melihat selengkapnya + Banner khusus produk sederhana + Promosikan produk Anda dengan Iklan Blaze dan dongkrak penjualan sekarang. + Tingkatkan penjualan Anda + Terima pembayaran di mana saja + PIN yang dimasukkan salah. Coba lagi, atau gunakan metode pembayaran lainnya Menu Produk di keranjang %s, Harga %s Produk %s, Harga %s @@ -14,14 +74,13 @@ Language: id Pesanan baru Oke + Buat pesanan di manajemen toko - Untuk memproses pembayaran atas produk non-sederhana, keluar dari POS, kemudian buat pesanan baru dari tab pesanan. Mengapa produk saya tidak muncul? Info Tutup - Baca selengkapnya Menunjukkan produk sederhana saja - Saat ini, hanya produk fisik sederhana yang dapat menggunakan POS.\nJenis produk lain, seperti variabel dan virtual, akan tersedia dalam pembaruan mendatang. Saat ini, hanya produk fisik sederhana yang kompatibel dengan POS. Jenis produk lain, seperti variabel dan virtual, akan tersedia dalam pembaruan mendatang. + Untuk memproses pembayaran atas produk non-sederhana, keluar dari POS, kemudian buat pesanan baru dari tab pesanan. + Pelajari\u00A0selengkapnya Alamat Situs Tambahkan kampanye berbayar Tingkatkan penjualan dan tarik lebih banyak pengunjung dengan Google Ads. @@ -39,8 +98,6 @@ Language: id Coba lagi Produk yang didukung tidak ditemukan Tidak ada produk - Mari layani pelanggan - Memulai Dapatkan Dukungan Hubungkan ke pembaca Foto dihapus @@ -135,11 +192,10 @@ Language: id Pajak Audiens Batal - Keluar - Anda yakin ingin keluar dari POS? Keluar dari POS Checkout Hapus %s dari keranjang + Keluar Status Pembaca Tidak Diketahui Checkout Pembaca Terhubung @@ -419,23 +475,19 @@ Language: id Slogan Ganti gambar Terapkan - Mulai %1$s hari - Tentukan durasi - Tayangan menunjukkan frekuensi iklan ditampilkan kepada calon pembeli.\n\n\n Meskipun jumlah persisnya tidak dapat dijamin karena fluktuasi lalu lintas online dan perilaku pengguna, kami berupaya agar tayangan aktual iklan Anda sedekat mungkin dengan jumlah yang Anda targetkan.\n\n\n Jangan lupa, tayangan berhubungan dengan visibilitas, bukan tindakan yang dilakukan pembaca. Selesai Tayangan Perbarui Edit - Durasi Estimasi orang yang dijangkau per hari %1$s harian selama %1$s hari - Total pengeluaran - Berapa banyak dana yang ingin Anda keluarkan untuk kampanye promosi produk? Tentukan anggaran Semua %1$s hari sejak %2$s + Tayangan menunjukkan frekuensi iklan ditampilkan kepada calon pembeli.\n\n\n Meskipun jumlah persisnya tidak dapat dijamin karena fluktuasi lalu lintas online dan perilaku pengguna, kami berupaya agar tayangan aktual iklan Anda sedekat mungkin dengan jumlah yang Anda targetkan.\n\n\n Jangan lupa, tayangan berhubungan dengan visibilitas, bukan tindakan yang dilakukan pembaca. + Tanggal mulai Jangan Tampilkan Lagi Ingatkan Saya Nanti Boleh minta waktunya sebentar? Bantu kami memperbaiki fitur berbantuan AI dengan feedback cepat. @@ -635,7 +687,6 @@ Language: id Tidak ada teks yang terdeteksi. Pilih foto kemasan lainnya atau masukkan detail produk secara manual. Tingkatkan penjualan toko Anda dengan Blaze Terjadi error ketika menyegarkan daftar kampanye. Silakan coba lagi nanti. - Terjadi error ketika membuat nama & deskripsi produk. Tambahkan produk Pindai barcode Ciutkan/perluas kartu produk @@ -656,9 +707,6 @@ Language: id Coba lakukan pembayaran %s dengan kartu debit atau kredit Anda.\nDana akan dikembalikan saat selesai. Mudah, aman, privasi terjaga. Terima semua jenis pembayaran fisik, langsung\ndari ponsel. Tidak perlu alat tambahan. - Anggaran - Klik - Tayangan Ditolak Selesai Aktif @@ -691,45 +739,23 @@ Language: id PENGATURAN Tambahkan detail secara manual dengan email Kami tidak dapat memproses permintaan login aplikasi Anda - Error saat menyalin nama produk ke clipboard. - Nama produk disalin ke clipboard. - Nama produk dibuat oleh AI Error saat menyalin deskripsi produk ke clipboard. Deskripsi produk disalin ke clipboard. Pembuatan produk tidak berhasil. Coba lagi Hapus alamat dan hentikan penggunaan tarif pajak ini Tentukan tarif pajak baru untuk pesanan ini Tarif pajak ditambahkan otomatis - Terjadi masalah saat membuat nama produk. Coba lagi. - Minta tuliskan - Ceritakan tentang produk dan keunikannya. - AI akan membuatkan Anda judul yang menarik - Buat ulang Coba nonaktifkan penyaring belum dibaca untuk melihat semua ulasan produk - Nama produk Tidak ada ulasan produk belum dibaca - Nada dipilih Persuasif Berbunga-bunga Formal Kasual - Tentukan nada dan suara agar presentasi produk sesuai dengan citra yang ingin diangkat. Nada dan suara Rincian - Deskripsi produk Nama produk - Anda bisa mengubah detail berikut kapan saja. Pratinjau - Buat Info Produk - Tentukan nada dan suara - Tambahkan fitur utama, keunggulan, atau informasi produk agar mudah ditemukan di internet. Misalnya, kain lembut, jahitan awet, desain unik - Tonjolkan keunikan produk dan biar AI yang urus selanjutnya. - Tentang produk - Sarankan nama - Misalnya: Kain lembut, jahitan awet, desain unik. - Nama produk - Masukkan nama produk Berbasis AI. <a href=\'guidelines\'><u>Baca selengkapnya</u></a>. Tambahkan produk dan informasi secara manual Tambahkan secara manual @@ -738,7 +764,6 @@ Language: id Tambahkan produk Hanya ulasan belum dibaca Edit Pengaturan Tarif Pajak - Atau, ketuk untuk mendapatkan saran nama lainnya. Hal ini tidak akan memengaruhi pesanan online Tambahkan tarif ini ke semua pesanan yang dibuat Sunting Tarif Pajak @@ -1054,7 +1079,6 @@ Language: id Tingkatkan penjualan dengan penawaran khusus Lihat toko Anda Pantau selalu - Aktifkan pembayaran dengan perangkat seluler Kelola lebih lanjut di admin Umum Pengaturan diff --git a/WooCommerce/src/main/res/values-it/strings.xml b/WooCommerce/src/main/res/values-it/strings.xml index 2cb2b38d986..6b847bc1fc5 100644 --- a/WooCommerce/src/main/res/values-it/strings.xml +++ b/WooCommerce/src/main/res/values-it/strings.xml @@ -1,11 +1,71 @@ + Chiave non valida: rimuovere il carattere \"_\" dall\'inizio. + Questa chiave è già utilizzata in un altro campo personalizzato.\nAttualmente l\'applicazione non supporta la creazione di chiavi duplicate. Usare wp-admin per duplicare una chiave, se necessario. + Aggiungi campi personalizzati + Campo personalizzato eliminato + Impossibile salvare le modifiche, riprovare + Modifiche salvate + Salvataggio delle modifiche in corso + Sembra non ci sia connessione a Internet. Assicurati che il Wi-Fi sia acceso. Se stai utilizzando i dati mobili, accertati che siano abilitati nelle impostazioni del dispositivo. + Scansione non riuscita. Riprova più tardi + Valore + Chiave + Altri tipi di prodotti, come quelli variabili e quelli virtuali, saranno disponibili in seguito a futuri aggiornamenti. + Al momento solo i prodotti fisici semplici possono essere utilizzati con POS. + Annulla + Durata + La campagna proseguirà finché non la interromperai. + Specifica la durata + a %1$s + Pianifica + Spesa giornaliera + Quanto vorresti pagare per la campagna e quanto tempo desideri che duri? + %1$s ➔ %2$s + Con Blaze i tuoi prodotti saranno visti da milioni di persone e le vendite aumenteranno + Stai pensando di aumentare le tue vendite? + Errore durante il caricamento dei campi personalizzati + Campi personalizzati + Sfondo oscurato. Tocca per ignorare la finestra di dialogo. + %1$s settimanali + Esegui fino a nuova interruzione + %1$s settimanali a partire dal giorno %2$s + Settimanale + Rimanente + Totale + Click-through + Il tuo dispositivo è in modalità Risparmio batteria. \nNon possiamo fornirti le informazioni sul tuo negozio quando questa impostazione è attivata + In corso dal giorno %1$s + spesa settimanale + Menu popup con opzioni. Scorri per navigare tra gli elementi. + Apri il menu della barra degli strumenti + Barra degli strumenti con lo stato del lettore delle carte Il menu è aperto. Tocca due volte per interagire. + Barra degli strumenti con lo stato del lettore delle carte Tocca due volte per interagire. + Il menu è disabilitato + Il menu è abilitato + Il lettore carte non è connesso. Tocca due volte per connettere + Lettore di carte connesso + Sfondo oscurato. Tocca per chiudere il menu. + Icona di pagamento avvenuto con successo + Rimuovi questo elemento dal carrello + Gli ordini in corso andranno persi. + Uscire dalla modalità punto vendita? + Chiudi + Sfondo oscurato. Tocca per ignorare il dialogo. + Doppio tocco per ignorare il dialogo + Dialogo solo prodotti semplici + Tocca due volte per scoprire di più + Banner solo prodotti semplici + Promuovi i tuoi prodotti con Blaze Ads e aumenta subito le tue vendite. + Aumenta le tue vendite + Ricevi pagamenti ovunque ti trovi + È stato inserito un PIN errato. Riprova o usa altri mezzi di pagamento Menu Prodotto nel carrello %s, Prezzo %s Prodotto %s, Prezzo %s @@ -14,14 +74,13 @@ Language: it Nuovo ordine OK + Crea un ordine nella gestione del negozio - Per ricevere il pagamento di un prodotto non semplice, esci da POS e crea un nuovo ordire dalla scheda degli ordini. - Al momento solo i prodotti fisici semplici possono essere utilizzati con POS.\nAltri tipi di prodotti, come quelli variabili e quelli virtuali, saranno disponibili in seguito a futuri aggiornamenti. Perché non riesco a visualizzare i miei prodotti? Informazioni Chiudi - Scopri di più Al momento solo i prodotti fisici semplici sono compatibili con POS. Altri tipi di prodotti, come quelli variabili e quelli virtuali, saranno disponibili in seguito a futuri aggiornamenti. Visualizzazione dei soli prodotti semplici + Per ricevere il pagamento di un prodotto non semplice, esci da POS e crea un nuovo ordire dalla scheda degli ordini. + Scopri di più Indirizzo del sito Google per WooCommerce Aggiungi campagna a pagamento @@ -31,16 +90,14 @@ Language: it La tua nuova campagna è stata creata. Grandi notizie per le tue vendite. Tutto pronto! Impossibile creare l\'ordine - Riprova Icona che indica un errore Vuoi fare un altro tentativo? Errore durante il caricamento dei prodotti Il punto vendita al momento supporta solo prodotti semplici Il punto vendita al momento supporta solo prodotti semplici: \ncreane uno per iniziare. + Riprova Nessun prodotto supportato trovato Nessun prodotto - Serviamo alcuni clienti - Iniziare Richiedi assistenza Connetti al tuo lettore Foto rimossa @@ -110,8 +167,8 @@ Language: it Nome, riepilogo e descrizione Puoi modificare o rigenerare i dettagli del prodotto prima di salvarli. Programmi - Campagne Google Nessun programma in questo periodo + Campagne Google Connettiti ora Carrello Genera dettagli del prodotto @@ -122,24 +179,23 @@ Language: it Lascia generare a noi i dettagli del prodotto Ricevi pagamenti con carta Totale - Imposte Subtotale Pagamento avvenuto correttamente Pagamento non riuscito. Riprova. Icona della carta Prodotti - %d elemento - Cancella Aumenta le vendite e genera più traffico con Google Ads Google per WooCommerce Nessuna regola sulla quantità + %d elemento + Cancella + Imposte Pubblico Annulla - Esci - Desideri uscire da POS? Esci da POS - Rimuovi %s dal carrello Pagamento + Rimuovi %s dal carrello + Esci Stato del lettore sconosciuto Pagamento Lettore connesso @@ -170,13 +226,13 @@ Language: it Hai ancora bisogno di aiuto? Contattaci Impossibile caricare il report sull\'utilizzo dei coupon Nessun coupon utilizzato in questo periodo - Visualizza tutti i codici promozionali Usi - Codici promozionali Magazzino Visualizza tutti i messaggi Impossibile caricare i prodotti più venduti N/D + Visualizza tutti i codici promozionali + Codici promozionali Resto dovuto Contanti ricevuti Codici promozionali più attivi @@ -208,8 +264,8 @@ Language: it Nascondi %s Completata Feedback - Assicurati di disporre della versione più recente di WooCommerce sul tuo sito e di aver attivato Analisi WooCommerce. Non possiamo visualizzare il tuo\n analisi del negozio + Assicurati di disporre della versione più recente di WooCommerce sul tuo sito e di aver attivato Analisi WooCommerce. Visualizza tutte le attività L\'analisi delle sessioni si basa su un numero unico di visitatori non disponibile per intervalli di date personalizzati Dati della sessione non disponibili @@ -222,11 +278,11 @@ Language: it Annulla Esci comunque Sembra che tu non abbia ancora approvato la connessione dell\'app. Desideri uscire? - Seleziona un\'immagine con una dimensione minima di 400x400 pixel Immagine non valida Sembra che il nome utente o la password inseriti non corrispondano. Controlla di nuovo le credenziali e riprova. Se i dati non vengono ancora caricati, contatta il nostro team di supporto. Nessun problema di connessione + Seleziona un\'immagine con una dimensione minima di 400x400 pixel Torna alla schermata precedente Riprova la connessione Connessione al tuo sito @@ -257,9 +313,9 @@ Language: it Pacchetti venduti Campagne Blaze Prodotti più venduti - Vuoi davvero eliminare le modifiche apportate a questo prodotto? Stai per annullare le modifiche a %s La funzione statistiche non supporta la visualizzazione dei dati sui visitatori e sulle conversioni per intervalli di date arbitrari.\n\nTuttavia, puoi toccare un valore sul grafico per visualizzare i visitatori e le conversioni per quell\'intervallo specifico. + Vuoi davvero eliminare le modifiche apportate a questo prodotto? Dati sui visitatori e sulle conversioni non disponibili Gestisci il tuo abbonamento Abbonamenti @@ -280,7 +336,6 @@ Language: it Suggerimenti Inserisci un dominio Scegli un dominio - Visualizza tutte le analisi del negozio Annuale Mensile Settimanale @@ -290,6 +345,7 @@ Language: it Collega un altro negozio Creare un altro negozio? Nome del negozio + Visualizza tutte le analisi del negozio Attendi… Aggiornamento dello stato delle scorte Si è verificato un problema. Riprova. @@ -320,17 +376,17 @@ Language: it Errore nel cestinare l\'ordine Ordine cestinato Sembra che ci sia un problema con il tuo sito.\n\nContatta il tuo provider di hosting per ulteriore assistenza. + Sembra non ci sia connessione a Internet.\n\nAssicurati che il Wi-Fi sia acceso. Se stai utilizzando i dati mobili, accertati che siano abilitati nelle impostazioni del dispositivo. Sembra esserci un problema con la connessione Jetpack.\n\nMa non preoccuparti, il nostro team di supporto è qui per aiutarti. Contattaci e saremo lieti di assisterti. Sembra che non sia possibile lavorare correttamente con la risposta del sito.\n\nMa non preoccuparti, il nostro team di supporto è qui per aiutarti. Contattaci e saremo lieti di assisterti. Sembra che il tuo sito stia impiegando troppo tempo per rispondere.\n\nContatta il tuo provider di hosting per ulteriore assistenza. - Sembra non ci sia connessione a Internet.\n\nAssicurati che il Wi-Fi sia acceso. Se stai utilizzando i dati mobili, accertati che siano abilitati nelle impostazioni del dispositivo. - Prodotto non selezionato Continua a leggere Contatta il supporto - Recupero degli ordini del sito - Connessione ai server di WordPress.com Connessione Internet + Prodotto non selezionato Aggiungi statistiche sugli intervalli di date personalizzate + Recupero degli ordini del sito + Connessione ai server di WordPress.com Non è stata trovata alcuna posizione.\nRiprova. Visualizzazioni della pagina delle sessioni Tipo di dispositivo @@ -396,46 +452,42 @@ Language: it URL del prodotto Parametri URL URL di destinazione - Inserisci manualmente - Ricerca non riuscita.\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. + Inserisci manualmente + Ricerca non riuscita.\nRiprova 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 + Il caricamento dei metodi di pagamento non è riuscito, riprova cliccando qui! + Caricamento dei metodi di pagamento Impossibile salvare la ricevuta Impossibile scaricare la ricevuta Impossibile individuare un\'applicazione con cui la ricevuta possa essere condivisa Purtroppo non siamo riusciti a caricare una ricevuta per questo ordine - Suggerito dall\'IA %d caratteri rimanenti Descrizione Slogan Cambia immagine Applica - Inizia %1$s giorni - Imposta durata Le impressioni riflettono la frequenza con cui il tuo annuncio appare ai potenziali clienti.\n\n\n Anche se i numeri esatti non possono essere garantiti a causa delle fluttuazioni del traffico online e del comportamento degli utenti, il nostro obiettivo è far avvicinare il più possibile le impressioni effettive del tuo annuncio al numero di destinatari.\n\n\n Ricorda, le impressioni riguardano la visibilità, non l\'azione intrapresa dai visitatori. Fatto Impressioni Aggiorna Modifica - Durata Persone stimate raggiunte al giorno %1$s giornaliero per %1$s giorni - Spesa totale - Quanto vorresti spendere per la tua campagna promozionale? Imposta il tuo budget Tutti + Suggerito dall\'IA %1$s giorni da %2$s + Data iniziale Non mostrare più Ricordamelo più tardi Hai un minuto? Aiutaci a migliorare le nostre funzionalità assistite dall\'intelligenza artificiale con un rapido feedback. @@ -448,27 +500,26 @@ Language: it Budget Dettagli Acquista ora - Modifica annuncio Anteprima + Modifica annuncio Disabilitato Selezione prodotto Seleziona prodotto %s <b>Pubblica</b>: guarda come inizia la tua promozione e tieni traccia del suo successo. - <b>Breve riassunto:</b> invia il tuo annuncio per un controllo veloce da parte del moderatore. <b>Imposta il tuo budget:</b> decidi i costi e la durata della tua campagna. <b>Personalizza il targeting:</b> seleziona il pubblico in base alla posizione o agli interessi e visualizza la portata potenziale. <b>Scegli un prodotto:</b> scegli cosa promuovere con Blaze. + <b>Breve riassunto:</b> invia il tuo annuncio per un controllo veloce da parte del moderatore. Gestisci magazzino Magazzino non gestito - Scopri come funziona Blaze Avvia la tua campagna - I tuoi annunci su milioni di siti web nelle reti WordPress.com e Tumblr. Raggiungi un ampio pubblico - \"Il nostro strumento presenta il tuo prodotto dove gli acquirenti interessati possono trovarlo\". Raggiungere tutto il mondo non è mai stato così facile + I tuoi annunci su milioni di siti web nelle reti WordPress.com e Tumblr. + \"Il nostro strumento presenta il tuo prodotto dove gli acquirenti interessati possono trovarlo\". + Scopri come funziona Blaze Lancia annunci in pochi minuti, senza bisogno di esperienza o di grandi budget, a partire da soli $ 5 al giorno. Avvio rapido, grande impatto - Il nostro strumento è stato progettato per consentire ai venditori di impostare annunci semplici e veloci per incrementare al massimo il traffico. Promuovi Tutto pronto per promuovere Mostra i tuoi prodotti a milioni di persone @@ -479,6 +530,7 @@ Language: it Il codice dovrebbe essere nel formato XXXX-XXXX-XXXX-XXXX Inserisci codice Codice promozionale + Il nostro strumento è stato progettato per consentire ai venditori di impostare annunci semplici e veloci per incrementare al massimo il traffico. Impossibile caricare i temi. Configurazione completata Aggiornamento quantità annullato @@ -550,45 +602,45 @@ Language: it Messaggio di ringraziamento Nota: per attivare questa impostazione, l\'abbonamento non deve avere un periodo di prova o una data di rinnovo sincronizzata. Attiva questa opzione per addebitare la spedizione solo una volta sull\'ordine iniziale. - Attivata Spedizione singola Documenti e altri file sul dispositivo + Attivata ✨Crea messaggio di ringraziamento Addebita imposte I fondi disponibili vengono depositati automaticamente, ogni %s. I fondi disponibili vengono depositati automaticamente, ogni giorno. I fondi saranno disponibili dopo un periodo di attesa di %d giorni. - Seleziona una variante - Scegli variante \" %1$s \" -> %2$s - seleziona una variante %1$s elementi selezionati %1$s elemento selezionato Seleziona %1$s + Seleziona una variante + seleziona una variante più di %1$s elementi più di %1$s elemento meno di %1$s elementi tra %1$s e %2$s elementi %d elementi %d elemento + Scegli variante Cambia la quantità del prodotto da %1$.2f a %2$.2f - Salva configurazione Configurazione Prodotto %s Configura + Prodotto in abbonamento variabile + Prodotto in abbonamento semplice + Salva configurazione Facoltativamente, la commissione di iscrizione verrà addebitata immediatamente, anche se il prodotto ha una prova gratuita o le date di pagamento sono sincronizzate. Abbonamento a un prodotto con varianti - Prodotto in abbonamento variabile Un unico abbonamento al prodotto che attiva pagamenti ricorrenti - Prodotto in abbonamento semplice Un periodo di attesa opzionale prima dell\'addebito del primo pagamento ricorrente. Eventuali commissioni di iscrizione saranno comunque addebitate all\'inizio dell\'abbonamento. Il periodo di prova non può superare: 90 giorni, 52 settimane, 24 mesi o 5 anni. Periodo di prova dell\'abbonamento Scadenza dell\'abbonamento + PRODOTTO IMPORTI PERSONALIZZATI TOTALI PAGAMENTI NOTE ORDINE PRODOTTI - PRODOTTO CLIENTE Fornisci una chiave di sicurezza per continuare. Si è verificato un errore con l\'accesso alla chiave di sicurezza @@ -605,12 +657,12 @@ Language: it Stimato Comprimi/espandi il riepilogo dell\'acconto Scopri di più su quando riceverai i fondi - I fondi disponibili vengono depositati automaticamente, ogni mese il giorno %s. - I fondi saranno disponibili dopo un periodo di attesa di %d giorno. Fondi in sospeso Fondi disponibili - Imposte Prodotti + Imposte + I fondi disponibili vengono depositati automaticamente, ogni mese il giorno %s. + I fondi saranno disponibili dopo un periodo di attesa di %d giorno. Totali pagamenti Indirizzo e-mail o nome utente Impossibile creare un ordine con importo personalizzato @@ -634,7 +686,6 @@ Language: it Incrementa le vendite nel tuo negozio con Blaze Si è verificato un errore durante l\'aggiornamento dell\'elenco delle campagne. Riprova più tardi. Seleziona la fonte degli elementi multimediali - Si è verificato un errore durante la generazione del nome e della descrizione del prodotto. Nessun testo rilevato. Seleziona un\'altra foto del pacchetto o inserisci manualmente i dettagli del prodotto. Aggiungi prodotto Scansiona codice a barre @@ -656,27 +707,24 @@ Language: it Prova un pagamento di %s con la tua carta di debito o di credito:\nti verrà rimborsato quando hai terminato. È facile, sicuro e privato. Accetta tutti i tipi di pagamenti di persona direttamente\ndal tuo telefono. Non è necessario alcun hardware aggiuntivo. - Budget - Clic - Impressioni Rifiutata Completata Attiva In moderazione Crea la campagna Aumenta la visibilità e vendi i tuoi prodotti rapidamente. - Campagna Blaze Il simbolo del contactless è un marchio registrato di proprietà e utilizzato su concessione di EMVCo, LLC. 5. Dopo aver visualizzato il segno di spunta \"Fatto\", il negozio elaborerà il pagamento e la transazione verrà completata. + Campagna Blaze 4. Il cliente posiziona la carta orizzontalmente sulla parte superiore del telefono, sopra il simbolo del contactless. 3. Sempre tenendo il tuo telefono in mano, rivolgilo verso il cliente. 2. Tocca \"Ricevi pagamenti\" e scegli \"Tocca per pagare\". - 1. Crea un ordine Come funziona Scopri di più sui lettori di carte Per accettare pagamenti superiori a questo limite, potresti acquistare un lettore di carte che accetta l\'inserimento del PIN. Non supportiamo l\'inserimento del PIN con Tocca per pagare su Android. %1$s - In questo Paese, alcune carte richiedono il PIN per le transazioni superiori a %2$s. + 1. Crea un ordine Informazioni importanti Tocca per pagare ti consente di accettare tutti i tipi di pagamento contactless: dalle carte di credito e di debito fisiche ai portafogli digitali. Tutto senza il bisogno di acquistare un lettore di carte fisico. Che cos\'è Tocca per pagare? @@ -691,46 +739,23 @@ Language: it IMPOSTAZIONI Aggiungi dettagli manualmente utilizzando l\'e-mail Impossibile elaborare la richiesta di accesso all\'app - Errore durante la copia del nome del prodotto negli appunti. - Nome del prodotto copiato negli appunti. - Nome del prodotto generato dall\'IA Errore durante la copia della descrizione del prodotto negli appunti. Descrizione del prodotto copiata negli appunti. Generazione del prodotto non riuscita. Riprova Cancella l\'indirizzo e non utilizzare più questa aliquota Configura una nuova aliquota d\'imposta per questo ordine Aggiunta automatica dell\'aliquota d\'imposta - Si è verificato un problema durante la generazione del nome del prodotto. Riprova. - Rigenera - Scrivilo per me - Parlaci del tuo prodotto e di cosa lo rende unico. - Lascia che l\'IA generi titoli accattivanti per te. - Nome del prodotto Prova a disattivare il filtro delle recensioni non lette per visualizzarle tutte Nessuna recensione del prodotto non letta - Tono selezionato Convincente Note floreali Formale Casual - Imposta il tone of voice per dare forma a una presentazione del prodotto che sia in linea con il tuo brand. Tone of voice Dettagli - Descrizione del prodotto Nome del prodotto - Puoi sempre modificare i dettagli riportati di seguito in un secondo momento. Anteprima - Crea dettagli del prodotto - Imposta il tone of voice - Aggiungi funzionalità, vantaggi o dettagli chiave per consentire al tuo prodotto di essere trovato online. Ad esempio tessuto morbido, cuciture resistenti e design unico - Metti in evidenza ciò che rende unico il tuo prodotto e lascia che l\'IA faccia la magia. - Informazioni sul tuo prodotto - Suggerisci un nome - Ad esempio tessuto morbido, cuciture resistenti e design unico - Nome del prodotto - Oppure amplia le tue scelte toccando per ottenere ulteriori suggerimenti sui nomi. - Aggiungi nome del prodotto Con tecnologia IA. <a href=\'guidelines\'><u>Scopri di più</u></a>. Aggiungi un prodotto e i dettagli manualmente Aggiungi manualmente @@ -743,18 +768,18 @@ Language: it Aggiungi questa aliquota a tutti gli ordini creati Modifica le aliquote d\'imposta Modifica le aliquote d\'imposta nella pagina dell\'amministratore - Aggiungi le aliquote d\'imposta nella pagina dell\'amministratore. Qui verranno visualizzate solo le aliquote d\'imposta con le informazioni sulla posizione. Impossibile trovare le aliquote d\'imposta - Scopri altri fornitori di servizi di pagamento e \nscegline uno. Metodi di pagamento + Aggiungi le aliquote d\'imposta nella pagina dell\'amministratore. Qui verranno visualizzate solo le aliquote d\'imposta con le informazioni sulla posizione. + Scopri altri fornitori di servizi di pagamento e \nscegline uno. Immagini e video sul dispositivo Risolvi ora Completa la configurazione Imposta l\'aliquota d\'imposta - Attiva Imposta la nuova aliquota d\'imposta - WooPayments + Attiva Configura + WooPayments Modifica le aliquote d\'imposta nella pagina dell\'amministratore Questa azione modificherà l\'indirizzo del cliente nella località dell\'aliquota d\'imposta selezionata. Pulsante che apre la finestra di dialogo delle informazioni sulle aliquote d\'imposta @@ -802,8 +827,8 @@ Language: it Totale ordine Percentuale calcolata Importo calcolato - Nome del negozio La personalizzazione del nome del negozio può anche agevolarne l\'ottimizzazione per i motori di ricerca. + Nome del negozio Dai un nome al tuo negozio Abilita NFC Pacchetti di fornitura di piccole quantità (marcature richieste) @@ -831,18 +856,18 @@ Language: it Classe 4 - Pacchetto (solidi infiammabili) Classe 3 - Pacchetto (igienizzante per mani, alcol disinfettante, prodotti a base di etanolo, liquidi infiammabili, ecc.) Classe 1 - Pacchetto di propellente per razzi/fusibile di sicurezza - Pacchetto di etanolo conforme alle prescrizioni per il volo - (spedizioni di fragranze e di igienizzanti per mani autorizzati) OK + Pacchetto di etanolo conforme alle prescrizioni per il volo - (spedizioni di fragranze e di igienizzanti per mani autorizzati) I materiali potenzialmente pericolosi includono articoli come batterie, ghiaccio secco, liquidi infiammabili, aerosol, munizioni, fuochi d\'artificio, smalto per unghie, profumi, vernici, solventi e altro ancora. I materiali pericolosi devono essere spediti in pacchi separati. Contiene materiali pericolosi Inserisci il titolo del prodotto. La piattaforma di eCommerce che cresce con te Abbonamento variabile - Rimuovi codice promozionale Tutti amano un\'offerta Non hai ancora creato nessun codice promozionale. Crea un codice promozionale per applicarlo a questo ordine. Vai ai codici promozionali Seleziona un codice promozionale + Rimuovi codice promozionale Impossibile creare il codice promozionale Codice promozionale creato Crea @@ -855,8 +880,6 @@ Language: it Sconto fisso sul prodotto Sconto fisso sul carrello Sconto in percentuale - Tipo di codice promozionale - Prodotto fisso - Tipo di codice promozionale - Carrello fisso Tipo di codice promozionale - Sconto percentuale Crea codice promozionale Aggiungi codice promozionale @@ -870,6 +893,8 @@ Language: it Esegui un ordine di prova per assicurarti che il processo WooCommerce offra un\'esperienza cliente fluida Aggiungi dettagli manualmente Cerca clienti per + Tipo di codice promozionale - Prodotto fisso + Tipo di codice promozionale - Carrello fisso Altro motivo (specificare) Faccio parte di una team e la decisione va presa insieme a tutti i membri. Trovo che il prezzo del servizio sia un fattore significativo nella mia decisione. @@ -878,12 +903,12 @@ Language: it Aiutaci a capire le tue decisioni relative all\'abbonamento. Il tuo feedback è importante. Nessun indirizzo e-mail Nessun nome - Cerca un cliente esistente o Ultimo aggiornamento: %s (aggiornamenti ogni 30 minuti) Ultimo aggiornamento: %s - <a href=\'\'>Scopri di più</a> sull\'accettazione di pagamenti con Tocca per pagare su Android + Cerca un cliente esistente o Ricevi pagamenti Impossibile aggiungere prodotti senza un prezzo specificato + <a href=\'\'>Scopri di più</a> sull\'accettazione di pagamenti con Tocca per pagare su Android Impossibile aggiungere i prodotti che non sono stati pubblicati aggiungi il cliente Vai su Impostazioni @@ -949,19 +974,18 @@ Language: it Impossibile generare il messaggio per la condivisione. Riprova. Scopri di più sulla funzionalità dell\'IA Aggiungi un messaggio opzionale - Scrittura… Scrivi con l\'IA Promuovi i prodotti con Blaze Blaze Generatore di contenuti IA disponibile Promuovi con Blaze + Scrittura… Condividi prodotto Complimenti. Il nuovo negozio è ora più vicino. Primo prodotto creato 🎉 Il sistema ha terminato l\'app Woo mentre era in esecuzione in background. Puoi provare a usarla di nuovo. Il sistema ha terminato l\'app Woo mentre era in esecuzione in background. Puoi provare a usarla di nuovo. La scheda è stata rimossa troppo presto - Prodotto con varianti La nostra politica sui cookie spiega l\'uso che noi e altri facciamo dei cookie e come puoi gestirli. Informativa sui cookie Le tue informazioni ci aiutano a migliorare i nostri prodotti, il marketing e a personalizzare la tua esperienza su WooCommerce. @@ -973,6 +997,7 @@ Language: it Analytics Gestisci la privacy La privacy è di fondamentale importanza per noi e lo è sempre stata. Usiamo, archiviamo ed elaboriamo i dati personali per ottimizzare la nostra app (e la tua esperienza) in vari modi. Alcuni usi dei dati sono assolutamente necessari per un corretto funzionamento e altri puoi personalizzarli dalle Impostazioni. + Prodotto con varianti Per aiutarci a migliorare le prestazioni dell\'app e correggere bug occasionali, attiva il rapporto di arresto anomalo. Segnala arresti anomali Rapporti @@ -981,7 +1006,6 @@ Language: it Privacy Scopri di più sui dati che raccogliamo sul tuo negozio e sulle tue opzioni per il controllo di questa condivisione dei dati. Tracciabilità dell\'utilizzo - Ulteriori opzioni per la privacy sono disponibili per gli utenti di woocommerce.com. Controlla qui per saperne di più. Opinioni del web Ulteriori opzioni per la privacy Si è verificato un errore durante l\'aggiornamento delle impostazioni sulla privacy @@ -994,6 +1018,7 @@ Language: it Non è possibile aggiungere direttamente il prodotto variabile. Seleziona una variante specifica Scansione non riuscita. Riprova più tardi Impossibile trovare il prodotto con SKU %s. Impossibile aggiungere all\'ordine + Ulteriori opzioni per la privacy sono disponibili per gli utenti di woocommerce.com. Controlla qui per saperne di più. Scansione non riuscita. Riprova più tardi Scansiona codice a barre La spedizione in Paesi che seguono le regole doganali dell\'Unione europea (UE) ora richiede la descrizione chiara di ogni elemento. Ad esempio, se stai inviando capi di vestiario, devi indicare il tipo di abbigliamento (come camicie da uomo, gilet da ragazza, giacca da ragazzo) affinché la descrizione sia accettabile. In caso contrario, le spedizioni potrebbero subire dei ritardi o essere fermate alla dogana. @@ -1017,16 +1042,16 @@ Language: it Aggiungi i prodotti tramite scanner Ignora Scopri di più - Quando spedisci in Paesi che seguono le norme doganali dell\'Unione europea (UE), devi fornire una descrizione chiara e specifica per ogni elemento. In caso contrario, le spedizioni potrebbero subire dei ritardi o essere fermate alla dogana. Resta aggiornato e aumenta la sicurezza del negozio. Esplora Jetpack ora. Ricevi notifiche sugli ordini e altro ancora + Quando spedisci in Paesi che seguono le norme doganali dell\'Unione europea (UE), devi fornire una descrizione chiara e specifica per ogni elemento. In caso contrario, le spedizioni potrebbero subire dei ritardi o essere fermate alla dogana. Mostra o nascondi l\'elenco delle impostazioni del negozio Elenco delle impostazioni del negozio - Puoi ripristinarlo quando lo necessiti da Menu > Impostazioni > Negozio Nascondi l\'elenco delle impostazioni del negozio Nascondi l\'elenco delle impostazioni del negozio Visualizza ordine Il pagamento di Tocca per pagare di prova è stato rimborsato correttamente + Puoi ripristinarlo quando lo necessiti da Menu > Impostazioni > Negozio Rimborso non riuscito. Prova a eseguire manualmente il rimborso Rimborso del pagamento di prova in corso… Continuando, accetti i nostri <a href=\'termsOfService\'><u>Termini di servizio.</u></a> @@ -1054,7 +1079,6 @@ Language: it Incrementa le vendite con offerte speciali Visualizza il tuo negozio Rimani aggiornato - Unisciti ai pagamenti mobili Gestisci di più in Admin Generale Impostazioni @@ -1114,14 +1138,14 @@ Language: it Abbonamento Il lettore di carte accetta pagamenti contactless, chip e tramite strisciata con carte di debito e di credito. Accetta in modo sicuro pagamenti contactless direttamente dal tuo telefono. - Usa il telefono per accettare i pagamenti\ncon carta. Prova ora. - Condividi il feedback Impossibile accedere perché la creazione della password dell\'applicazione non è stata approvata. Sito in caricamento… + Condividi il feedback + Usa il telefono per accettare i pagamenti\ncon carta. Prova ora. + Caricamento in corso… Si è verificato un errore durante il recupero del sito web Riprova con la pagina Bacheca Accedi - Caricamento in corso… Terminato il %s Il tuo abbonamento è scaduto e hai un accesso limitato a tutte le funzionalità. %1$d giorni @@ -1136,13 +1160,11 @@ Language: it Errore durante il recupero dei dettagli del piano. Ora sei un abbonato %1$s. Hai accesso a tutte le nostre funzionalità fino al giorno %2$s. La tua prova gratuita è terminata e hai accesso limitato a tutte le funzionalità. Abbonati a %1$s ora. - Stai utilizzando il periodo di prova di %1$d giorni. La prova gratuita terminerà il %2$s. Esegui l\'upgrade per sbloccare nuove funzionalità e mantenere attivo il tuo negozio. Stato dell\'abbonamento Risoluzione dei problemi Attuale: %s Segnala il problema con l\'abbonamento Aggiorna ora - %1$s rimasti nella tua prova. Prova terminata La tua prova è terminata. Ops, si sono verificati alcuni errori imprevisti. @@ -1158,6 +1180,8 @@ Language: it Pubblica il mio negozio Per lanciare il tuo negozio, hai bisogno di eseguire l\'aggiornamento al nostro piano. <u>Aggiorna</u> Cerca domini + Stai utilizzando il periodo di prova di %1$d giorni. La prova gratuita terminerà il %2$s. Esegui l\'upgrade per sbloccare nuove funzionalità e mantenere attivo il tuo negozio. + %1$s rimasti nella tua prova. Accesso non riuscito con codice dello stato %1$s Impossibile accedere perché non possiamo identificare l\'URL di amministrazione del negozio Impossibile accedere perché non possiamo identificare l\'URL di accesso del negozio @@ -1205,31 +1229,31 @@ Language: it Recupero dello stato di Jetpack Si è verificato un problema. Riprova più tardi. Prova un pagamento + Registrazione del nome di dominio in corso… + Seleziona il Paese + Seleziona uno stato Ricevi pagamenti con carta\ncon il tuo telefono Tocca per pagare AZIONI Si è verificato un errore durante la registrazione del dominio - Seleziona uno stato - Seleziona il Paese - Registrazione del nome di dominio in corso… - Registra dominio - CAP - Stato (non disponibile) - Stato - Città - Indirizzo 2 - Indirizzo - Paese - Prefisso internazionale Telefono - Organizzazione (opzionale) + Prefisso internazionale + Paese + Indirizzo + Indirizzo 2 + Città + Stato + Stato (non disponibile) + CAP + Registra dominio Per agevolarti, abbiamo precompilato le tue informazioni di contatto\n di WordPress.com. Rileggi per verificare che le informazioni che desideri utilizzare per questo dominio siano corrette. - Informazioni di contatto per il dominio - Registra pubblicamente - Registra privatamente con protezione della privacy - Per %s, inserisci un valore valido - Registrando questo dominio accetti i nostri %1$sterme e le nostre condizioni%2$s + Organizzazione (opzionale) I proprietari del dominio devono condividere le informazioni di contatto in un database pubblico di tutti i domini.\n Con la Protezione della privacy, pubblichiamo le nostre informazioni al posto delle tue e ti inoltriamo privatamente qualsiasi comunicazione. + Registrando questo dominio accetti i nostri %1$sterme e le nostre condizioni%2$s + Per %s, inserisci un valore valido + Registra privatamente con protezione della privacy + Registra pubblicamente + Informazioni di contatto per il dominio Protezione della privacy Solo gli amministratori del negozio possono accedere alle impostazioni del dominio In alternativa, continua a usare il Link @@ -1237,10 +1261,10 @@ Language: it Inserisci la password del tuo account WordPress.com per connettere Jetpack Accedi con il tuo account WordPress.com per installare Jetpack Accedi con il tuo account WordPress.com per connettere Jetpack - Puoi trovare le impostazioni del dominio in Impostazioni -> Domini L\'indirizzo del tuo sito è in fase di configurazione. Potrebbero essere necessari fino a 30 minuti prima che il tuo dominio inizi a funzionare. Congratulazioni per il tuo acquisto Gratis per il primo anno + Puoi trovare le impostazioni del dominio in Impostazioni -> Domini Vuoi davvero uscire dal tuo account? Impossibile caricare i domini del sito %1$d/%2$d completato @@ -1288,19 +1312,19 @@ Language: it L\'operazione non richiederà molto tempo Preparazione del lettore integrato… Il lettore integrato è pronto - Lettore di carte Tocca per pagare Tasso di conversione Sessioni Nessuna sessione in questo periodo Confrontato con Dominio + Lettore di carte Dove si trovano le Password applicazione? Sembra che la funzione Password applicazione sia disabilitata nel tuo sito %1$s.\n Abilitalo all\'uso dell\'app WooCommerce. Apri la pagina di installazione - Si è verificato un errore durante l\'invio della risposta - Risposta inviata! Rispondi + Risposta inviata! + Si è verificato un errore durante l\'invio della risposta Seleziona tutto Aggiorna prezzo Aggiorna stato @@ -1311,17 +1335,17 @@ Language: it Sono già generate tutte le varianti. Nessuna variante generata Seleziona più elementi - Nessun dominio disponibile per questa ricerca Generazione delle varianti Questo creerà una nuova variazione per ogni possibile combinazione degli attributi di variazione (%1$d varianti). Generare tutte le varianti? Attualmente la creazione è supportata per un massimo di %1$d varianti. La generazione delle varianti per il prodotto creerà %2$d varianti. Limite di generazione superato Crea le variant per tutte le combinazioni degli attributi. - Genera tutte le varianti Crea una nuova variante. Impostare manualmente quali attributi appartengono al prodotto variabile. Aggiungi nuova variante Aggiungi variazione + Genera tutte le varianti + Nessun dominio disponibile per questa ricerca Esci senza connessione Continua connessione Prova a connetterti di nuovo per accedere al tuo negozio. @@ -1333,7 +1357,6 @@ Language: it Prova di nuovo l\'attivazione Prova di nuovo l\'installazione Ottieni assistenza - Riprova e contatta il supporto se l\'errore persiste. Si è verificato un errore durante la comunicazione con il sito. Non disponi dell\'autorizzazione per gestire i plugin in questo negozio Errore di autorizzazione della connessione a Jetpack @@ -1357,6 +1380,7 @@ Language: it Installazione di Jetpack Accedi a <b>%1$s</b> con le credenziali del tuo negozio per connetterti a Jetpack. Accedi a <b>%1$s</b> con le credenziali del tuo negozio per installare Jetpack. + Riprova e contatta il supporto se l\'errore persiste. Prepara le credenziali del negozio. Connetti il negozio a Jetpack per permetterne l\'accesso su questa app. Installa il plugin Jetpack gratuito per accedere al negozio su questa app. @@ -1369,8 +1393,8 @@ Language: it Aggiorna il lettore di carte simulato Collega Jetpack Collega negozio - Ecco dove gli utenti ti troveranno su Internet. Non preoccuparti. Puoi cambiarlo più tardi. Visitatori + Ecco dove gli utenti ti troveranno su Internet. Non preoccuparti. Puoi cambiarlo più tardi. In alternativa, accedi con la password Il lettore di carte simulato è stato disattivato Chiave del lettore simulato @@ -1407,12 +1431,12 @@ Language: it Prova con un altro indirizzo Intervallo di date personalizzato Personalizza - Cos\'è WordPress.com? Creazione di un nuovo account Scegli una password Il tuo indirizzo e-mail Crea il tuo sito \nin pochi minuti Toccando il pulsante Configura Jetpack, accetti i nostri <a href=\'terms\'>Termini di servizio</a> e la <a href=\'sync\'>condivisione delle informazioni</a> con WordPress.com. + Cos\'è WordPress.com? Attiva il lettore di carte simulato Contatta il proprietario del sito per un invito al sito come gestore del negozio o amministratore per utilizzare l\'app. Connessione a un sito WordPress.com @@ -1433,8 +1457,8 @@ Language: it Analisi del negozio non disponibile! Esegui l\'aggiornament all\'ultima versione di WooCommerce per visualizzare le analisi del tuo negozio. La rete non è disponibile.\nControlla la connessione dati o WiFi. Accedi all\'app WooCommerce - Recupero dei dati della connessione non riuscito… Verifica della connessione a Jetpack… + Recupero dei dati della connessione non riuscito… Impossibile verificare la connessione a Jetpack. Riprova. Il sito %1$s dispone attualmente di un piano WordPress.com che non supporta l\'installazione di plugin. Aggiorna il tuo piano per utilizzare WooCommerce. Sembra che l\'account non sia connesso a Jetpack di %1$s @@ -1452,44 +1476,43 @@ Language: it Prima volta in WooCommerce Si è verificato un errore, contatta il supporto Inserisci un indirizzo del sito - Richiedi un link di accesso via e-mail Non ricordi la password? + Richiedi un link di accesso via e-mail Abbiamo notato che non hai ancora completato la configurazione dei Pagamenti di persona. <a href=\'\'>Continua con la configurazione</a> - Pagamenti - OK! - Ora puoi accedere rapidamente e con facilità ai Pagamenti di persona e ad altre funzionalità + WC Admin + Accesso con l\'indirizzo del negozio + Altri siti Pagamenti dalla scheda Menu + Ora puoi accedere rapidamente e con facilità ai Pagamenti di persona e ad altre funzionalità + OK! + Pagamenti L\'e-mail non è utilizzata con un account WordPress.com. - Altri siti - Accesso con l\'indirizzo del negozio - WC Admin - Abbiamo appena inviato un link magico a - Controlla la tua e-mail su questo dispositivo! - Usa la password per accedere - Accedi con il link magico - Ti abbiamo appena inviato un link magico al tuo indirizzo e-mail. Tocca il link nell\'e-mail per accedere. Accedi con le credenziali del sito - Offri ai tuoi clienti consigli utili e pertinenti sui prodotti sommando upsell e cross-sell - Aumenta le tue vendite con i prodotti collegati - Inizia a vendere di persona in meno di 20 minuti con il nostro lettore di carte. - Errore durante l\'aggiornamento dell\'ordine #%1$d - Ordine #%1$d contrassegnato come completo - Contrassegna\ncompletato - Installa WooCommerce - %1$s non è un sito WooCommerce. - Passa a un negozio all\'altro - Gestisci i miei ordini - Crea o aggiorna i miei prodotti - Controlla le mie analisi - Tentativo di configurazione di un negozio - Sto solo dando un\'occhiata - Cosa ti porta in WooCommerce? - Suggerimento + Ti abbiamo appena inviato un link magico al tuo indirizzo e-mail. Tocca il link nell\'e-mail per accedere. + Accedi con il link magico + Usa la password per accedere + Controlla la tua e-mail su questo dispositivo! + Abbiamo appena inviato un link magico a Configura ora + Suggerimento + Cosa ti porta in WooCommerce? + Sto solo dando un\'occhiata + Tentativo di configurazione di un negozio + Controlla le mie analisi + Crea o aggiorna i miei prodotti + Gestisci i miei ordini + Passa a un negozio all\'altro + %1$s non è un sito WooCommerce. + Installa WooCommerce + Contrassegna\ncompletato + Ordine #%1$d contrassegnato come completo + Errore durante l\'aggiornamento dell\'ordine #%1$d + Inizia a vendere di persona in meno di 20 minuti con il nostro lettore di carte. + Aumenta le tue vendite con i prodotti collegati + Offri ai tuoi clienti consigli utili e pertinenti sui prodotti sommando upsell e cross-sell Iniziamo! Accedi con WordPress.com Contatta il supporto - Accedi con il tuo account WordPress.com Ricevi aiuto! Problemi con l\'accesso? COD @@ -1511,6 +1534,7 @@ Language: it Puoi gestirli rapidamente e facilmente Sappiamo che è essenziale per la tua attività Prima volta in WooCommerce + Accedi con il tuo account WordPress.com Nuovo ordine pari a $ 50 nel tuo negozio WooCommerce Hai un nuovo ordine. 🎉 i dettagli @@ -1519,10 +1543,10 @@ Language: it Condividi rapporto dello stato del sistema Copia il rapporto dello stato del sistema negli appunti Continua a cercare - Pagamento di persona per l\'ordine #%1$s per %2$s blog_id %3$s. Cambia fornitore dei pagamenti Rimborsato: %1$s In attesa di pagamento + Pagamento di persona per l\'ordine #%1$s per %2$s blog_id %3$s. Procedere con l\'installazione Cosa da sapere prima dell\'installazione Installa estensione @@ -1544,8 +1568,8 @@ Language: it bloccato Per modificare Dettagli di pagamento o i Prodotti, modifica lo stato in Pagamento in sospeso. Le parti di questo ordine non sono al momento modificabili - Cerca clienti Nessun cliente trovato + Cerca clienti Non ora Aggiungi estensione al negozio Cos\'è WooCommerce Shipping? @@ -1580,8 +1604,6 @@ Language: it I prezzi attuali sono misti Il prezzo attuale è %s Il prezzo verrà aggiornato per %d varianti - Misto - Nessuno Prezzo di vendita Prezzo standard Prezzo @@ -1589,6 +1611,8 @@ Language: it Aggiornamento in blocco OK Aggiornamento in blocco… + Misto + Nessuno Recupero delle varianti in corso… Ricerca delle categorie di prodotti non riuscita Caricamento delle categorie di prodotti non riuscito @@ -1603,11 +1627,10 @@ Language: it Ottieni WooCommerce Shipping Stampa etichette dal tuo telefono con WooCommerce Shipping. Hai bisogno di un\'etichetta di spedizione? - Cambia la quantità di prodotto da %1$d a %2$d Aggiorna al prezzo standard Aggiorna prezzo di vendita + Cambia la quantità di prodotto da %1$d a %2$d Non supportiamo l\'estensione WooCommerce Stripe in %1$s - Filtro Cancella la selezione Seleziona %d prodotto Seleziona %d prodotti @@ -1619,6 +1642,7 @@ Language: it Attiva questa opzione se il codice promozionale non deve essere applicato agli articoli in offerta. I codici promozionali per prodotto funzionano solo se l\'articolo non è in offerta. I codici promozionali per carrello funzionano solo se nel carrello sono presenti articoli non in offerta. Escludi elementi in offerta Attiva questa opzione se il codice promozionale non può essere usato insieme ad altri. + Filtro Solo utilizzo individuale Limite di utilizzo per utente Limite di utilizzo per X prodotti @@ -1693,12 +1717,12 @@ Language: it Prova altri mezzi per eseguire il rimborso Il rimborso è stato rifiutato per una ragione sconosciuta Siamo spiacenti, questo rimborso non può essere elaborato + Copia Rimborso avvenuto correttamente Elaborazione del rimborso Rimborsa il pagamento Rimborso non riuscito Preparazione dell\'esecuzione del rimborso del pagamento - Copia Cerca codici promozionali Impossibile generare il messaggio per la condivisione del codice promozionale Errore durante la condivisione del codice promozionale. @@ -1723,18 +1747,9 @@ Language: it Pagamento: %s Condividi il link di pagamento Importo - Importo - Ordini scontati - Prestazioni - Spesa massima di %s - Spesa minima di %s - Riepilogo codice promozionale Visualizza riepilogo codice promozionale - Abbiamo lavorato per rendere possibile la visualizzazione e la modifica dei coupon sul tuo dispositivo! Visualizza e modifica i codici promozionali Non è stato trovato alcun codice promozionale - %1$s senza %2$s - %1$s e %2$s tutto Scaduto Attivo @@ -1752,6 +1767,15 @@ Language: it \u2022 una recensione approvata \u2022 %drecensioni approvate %1$s (%2$s%%) + Importo + Ordini scontati + Prestazioni + Spesa massima di %s + Spesa minima di %s + Riepilogo codice promozionale + Abbiamo lavorato per rendere possibile la visualizzazione e la modifica dei coupon sul tuo dispositivo! + %1$s senza %2$s + %1$s e %2$s Abbiamo lavorato per permetterti di creare ordini dal tuo dispositivo. Puoi provare questa funzionalità toccando il pulsante \"+\" Torna presto per ulteriori suggerimenti e per la panoramica sulla crescita del tuo negozio Congratulazioni, hai letto tutto. @@ -1766,20 +1790,20 @@ Language: it Il servizio XML-RPC è disattivato su questo sito. Utilizza un\'e-mail non automatica per inviare un ticket di supporto Non supportiamo gli account Stripe registrati in %1$s - Non supportiamo l\'estensione WooCommerce Payments in %1$s Premi il pulsante di accensione del tuo lettore Una ricevuta è stata inviata a <strong>%s</strong> Percentuale (%) + Non supportiamo l\'estensione WooCommerce Payments in %1$s Rimuovi la commissione dall\'ordine Rimuovi la spedizione dall\'ordine Spedizione Aggiungi spedizione Aggiungi spedizione Nome - Importo Tariffe Dettagli cliente Aggiungi l\'imposta + Importo Modifica una nota del cliente Modifica dettagli del cliente Modifica lo stato dell\'ordine @@ -1798,10 +1822,10 @@ Language: it WooCommerce Payments Gateway stripe WooCommerce I pagamenti di persona funzioneranno solo con uno dei seguenti plugin attivato. Per continuare, contatta un amministratore del sito per disattivare uno di questi plugin: - I pagamenti di persona funzioneranno solo con uno dei seguenti plugin attivato. Per continuare disattiva uno di questi plugin: Conflitto con i plugin di pagamento rilevato - Totale imposte oppure + I pagamenti di persona funzioneranno solo con uno dei seguenti plugin attivato. Per continuare disattiva uno di questi plugin: + Totale imposte Installa Jetpack Pagamenti di persona attualmente non disponibile Ordine creato @@ -1872,9 +1896,7 @@ Language: it Aggiungi un indirizzo di spedizione differente In magazzino %s in magazzino - Aggiungi prodotti Prodotti - Aggiungi dettagli del cliente Cliente Contrassegna come Pagato Questa azione creerà il tuo ordine e lo contrassegnerà come Pagato se hai ricevuto il pagamento al di fuori di WooCommerce @@ -1882,6 +1904,8 @@ Language: it Scegli il tuo metodo di pagamento Le imposte sono calcolate automaticamente in base all\'indirizzo del negozio. Imposta (%s%%) + Aggiungi dettagli del cliente + Aggiungi prodotti Ricevi il pagamento%s Modifica le imposte Importo personalizzato @@ -1934,19 +1958,19 @@ Language: it Filtra stato Stato Data di fine - Data di inizio Seleziona le date Intervallo personalizzato - Crea un ordine con informazioni minime Pagamento facile - Crea un nuovo ordine manuale Crea ordine Crea ordine Inserisci quantità Ricevi il pagamento Pagamento facile - Crea ordini dal tuo dispositivo! Dati analizzati + Data di inizio + Crea un ordine con informazioni minime + Crea un nuovo ordine manuale + Crea ordini dal tuo dispositivo! Fatto Connessione del tuo negozio Attivazione @@ -2030,8 +2054,8 @@ Language: it Rapporto dello stato del sistema Congratulazioni, ora puoi accettare pagamenti con carte di debito e di credito con WooCommerce Payments. Ricevi i pagamenti con un lettore di carte - L\'importo deve essere di almeno %1$s OK + L\'importo deve essere di almeno %1$s Nuova immagine dell\'icona della funzionalità Cambia negozio Aggiornamento del prodotto %1$s non riuscito @@ -2072,10 +2096,10 @@ Language: it Impossibile verificare automaticamente l\'indirizzo di spedizione: %s Impossibile verificare automaticamente l\'indirizzo di origine. Visualizza su Google Maps per assicurarti che l\'indirizzo sia corretto. Stiamo lavorando per rendere più semplice per te visualizzare i componenti aggiuntivi del prodotto dal dispositivo. Per ora, sarai in grado di visualizzare i componenti aggiuntivi per gli ordini. Puoi creare e modificare questi componenti aggiuntivi nella bacheca web. + Salva Visualizza i componenti aggiuntivi dal dispositivo. Se rinomini un componente aggiuntivo nella tua bacheca web, tieni presente che gli ordini precedenti non mostreranno più quel componente aggiuntivo all\'interno dell\'app. Visualizza componenti aggiuntivi - Salva Dettagli del caricamento (%d) Non è stato possibile caricare %d file Non è stato possibile caricare %d file @@ -2107,16 +2131,17 @@ Language: it Pagamenti di persona non è disponibile nella Modalità di prova. Disattivare la funzione per proseguire. Pagamenti di persona attualmente non disponibile Ci sono requisiti in sospeso sull\'account. Completa i requisiti tramite %1$s per continuare ad accettare Pagamenti di persona. - Il tuo account presenta requisiti in sospeso C\'è almeno un requisito scaduto sul tuo account. Completalo per riprendere a utilizzare Pagamenti di persona Pagamenti di persona attualmente non disponibile Potrai accettare Pagamenti di persona non appena avremo terminato la revisione del tuo account. + Il tuo account presenta requisiti in sospeso Pagamenti di persona attualmente non disponibile Siamo spiacenti, ma non possiamo supportare Pagamenti di persona per questo negozio. Ricarica dopo l\'aggiornamento Sul tuo negozio è installata una versione non aggiornata dell\'estensione WooCommerce Payments. Esegui l\'aggiornamento per accettare Pagamenti di persona. Aggiorna WooCommerce Payments Ci sei quasi! Completa l\'impostazione di WooCommerce Payments per iniziare ad accettare pagamenti di persona. + Pagamenti di persona Completa la configurazione di WooCommerce Payments nella pagina di amministrazione del negozio Ricarica dopo l\'attivazione L\'estensione WooCommerce Payments è installata sul tuo negozio, ma non è attivata. Attivala per accettare Pagamenti di persona. @@ -2127,14 +2152,13 @@ Language: it <a href=\'\'>Scopri di più</a> sull\'accettazione di pagamenti con il tuo dispositivo mobile e sull\'ordinamento dei lettori di carte Ti serve aiuto? <a href=\'\'>Contatta il supporto</a> Puoi comunque accettare pagamenti di persona in contanti attivando il metodo \"Pagamento alla consegna\" sul tuo negozio - Non supportiamo Pagamenti di persona con carta in %1$s Connessione all\'account in corso - Pagamenti di persona Verifica le dimensioni e il peso del pacco oppure prova a utilizzare un pacco diverso in Dettagli pacco Nessuna tariffa di spedizione disponibile Tutti i pacchi disponibili sono stati attivati Attivazione del pacco in corso Seleziona un pacco da attivare. + Non supportiamo Pagamenti di persona con carta in %1$s Campo obbligatorio Chiudi Variante creata @@ -2143,11 +2167,11 @@ Language: it Genera variante Ora che hai aggiunto gli attributi, puoi creare la tua prima variante. Attributi creati - %1$s%% completato Non è raccomandato l\'annullamento dell\'aggiornamento del software in corso Siamo spiacenti, impossibile elaborare questo pagamento Nessuna connessione al server Nessuna connessione a Internet + %1$s%% completato Spedisci nel pacchetto originale Aggiungi a un nuovo pacchetto Questo elemento è attualmente in %s. Dove vorresti spostarlo? @@ -2158,7 +2182,6 @@ Language: it Creazione del pacchetto non riuscita. Riprova. Creazione del pacchetto non riuscita: problema API sconosciuto. Creazione del pacchetto non riuscita: %1$s - Attendi… Creazione di un nuovo pacchetto Valore non valido. Campo richiesto. @@ -2172,10 +2195,11 @@ Language: it Confezione Scegli tipo di pacchetto Tipo di pacchetto - Configura il pacchetto che utilizzerai per spedire i tuoi prodotti. Lo salveremo per ordini futuri. Aggiungi un nuovo pacchetto Crea un nuovo pacchetto Le dimensioni del pacchetto devono essere superiori a zero. Aggiorna le dimensioni dell\'elemento nella sezione Spedizione della pagina del prodotto per continuare. + Attendi… + Configura il pacchetto che utilizzerai per spedire i tuoi prodotti. Lo salveremo per ordini futuri. Pacchetto originale Dimensioni dell\'elemento Elemento spedito singolarmente @@ -2188,11 +2212,11 @@ Language: it Il controllo dell\'aggiornamento della versione del software non è riuscito <a href=\'\'>Scopri di più</a> sull\'accettazione di pagamenti con dispositivi mobili e sulla possibilità di ordinare lettori di carte Attiva il bluetooth - Nessun lettore connesso Impossibile connettersi al lettore Connetti Sono stati trovati più lettori L\'ordine è già stato pagato + Nessun lettore connesso Grazie per l’acquisto! Fai clic sul link qui sotto per la ricevuta di pagamento.\n\n%s Errore durante il download del modulo doganale Stampa fattura doganale @@ -2208,12 +2232,11 @@ Language: it Aggiungi prodotto Attributi della variante Attiva il Bluetooth del dispositivo mobile - Errore durante il recupero dell\'ordine. Lo stato nell\'ordine dell\'app potrebbe essere obsoleto. La tua ricevuta da %s Aggiornamento dell\'ordine Aggiornamento dello stato dell\'app Il cliente ha scelto %1$s - I moduli dei clienti richiedono un numero di telefono a 10 cifre + Errore durante il recupero dell\'ordine. Lo stato nell\'ordine dell\'app potrebbe essere obsoleto. Modulo dei clienti completato Se riscontri problemi con la stampa dal tuo dispositivo, contatta l\'assistenza clienti per la tua stampante. Se la stampa non è disponibile, puoi sempre salvare la ricevuta come PDF e inviarla via e-mail per stamparla da un altro dispositivo. @@ -2226,6 +2249,7 @@ Language: it Per creare una variante, dovrai prima impostare i suoi attributi (ad esempio \"Colore\", \"Dimensione\") 1 variante %1$s varianti + I moduli dei clienti richiedono un numero di telefono a 10 cifre Tracciabilità USPS Aggiornamento del software del lettore Aggiornamento del software @@ -2236,7 +2260,6 @@ Language: it Aggiorna il software del lettore Batteria %s%% LETTORE CONNESSO - Connetti il lettore di carte Accendi il lettore di carte e posizionalo accanto al dispositivo mobile Assicurati che il lettore di carte sia carico Connetti il tuo lettore di carte @@ -2245,7 +2268,6 @@ Language: it Preparazione alla ricezione del pagamento Il valore dichiarato deve essere maggiore di zero Il peso deve essere maggiore di zero - Questo campo è richiesto Descrivi che tipo di restrizioni deve avere questo pacchetto. Descrivi che tipo di beni contiene questo pacchetto. Peso (%1$s per unità) @@ -2269,7 +2291,6 @@ Language: it Descrizione Contenuto del pacchetto Il numero di transizione interno è richiesto per la spedizione a %1$s. - Il numero di transizione interno è richiesto per gli articoli della spedizione valutati più di $ 2.500 per numero di tariffa Formato non valido Dettagli sulla restrizione Dettagli sui contenuti @@ -2277,6 +2298,9 @@ Language: it Tipo di contenuti Restituisci al mittente se non è possibile consegnare il pacchetto fino a %s + Connetti il lettore di carte + Questo campo è richiesto + Il numero di transizione interno è richiesto per gli articoli della spedizione valutati più di $ 2.500 per numero di tariffa Se hai abilitato questa impostazione, il cliente riceverà un\'e-mail di conferma una volta completato l\'ordine Rivedi l\'ordine 🎉 Ordine completato. @@ -2285,9 +2309,9 @@ Language: it Scopri di più sui ruoli e sulle autorizzazioni Quest\'app supporta solo i ruoli utente di Amministratore e Gestore negozio. Contatta il proprietario del negozio per aggiornare il tuo ruolo. Modifica e aggiungi nuovi prodotti da qualsiasi luogo + Salta Gestisci e modifica gli ordini in movimento Tieni traccia delle vendite e dei prodotti che performano meglio - Salta Prodotto esterno Prodotto raggruppato Prodotto variabile @@ -2296,9 +2320,6 @@ Language: it Prodotto fisico semplice Apri le impostazioni Apri le impostazioni - Il Bluetooth è disabilitato - La posizione è disabilitata - Autorizzazione della posizione richiesta mancante precisa Impossibile connettersi al lettore. Connessione al lettore Connetti al lettore @@ -2306,9 +2327,10 @@ Language: it Ricerca di lettori Conteggio articoli Crea nuova etichetta di spedizione - Prodotto virtuale semplice + Il Bluetooth è disabilitato + La posizione è disabilitata + Autorizzazione della posizione richiesta mancante precisa Desideri eliminare questa variante? - Generazione della variante Eliminazione del prodotto Invia ricevuta Stampa ricevuta @@ -2322,6 +2344,8 @@ Language: it Impossibile visualizzare in anteprima l\'etichetta di spedizione. Installa un\'app per la visualizzazione del PDF e riprova. Non siamo in grado di rilevare un sito WordPress all\'indirizzo inserito. Assicurati che WordPress sia installato e di disporre dell\'ultima versione disponibile. più righe di spedizione + Prodotto virtuale semplice + Generazione della variante Impossibile contrassegnare l\'ordine come completato Errore durante l\'acquisto delle etichette Attendi… @@ -2350,18 +2374,17 @@ Language: it Solo il proprietario del sito può gestire i metodi di pagamento dell\'etichetta di spedizione. Contatta il proprietario del negozio %1$s (%2$s) per gestire i metodi di pagamento. Aggiungi varianti Aggiungi variante - Crea la prima variante %s totali %s tariffe selezionate Idoneo per il requisito della firma gratuita Idoneo per il ritiro gratuito - Assicurazione (%s) - tracciabilità Includi %s Firma di un adulto richiesta (%s) Firma richiesta (%s) + Assicurazione (%s) + tracciabilità Il cliente ha pagato %1$s di %2$s per la spedizione - Quando acquisti le etichette di spedizione tramite WooCommerce, ottieni dal 5​% 25 al 40​% 25 di sconto rispetto alle tariffe degli uffici postali. + Crea la prima variante Cos\'è lo sconto di WooCommerce Services? Si è verificato un errore durante il caricamento delle opzioni di spedizione Corrieri e tariffe @@ -2379,6 +2402,7 @@ Language: it Aggiungi ogni nome dell\'opzione e premi Invio In alternativa, tocca per selezionare un\'opzione esistente Nome opzione + Quando acquisti le etichette di spedizione tramite WooCommerce, ottieni dal 5​% 25 al 40​% 25 di sconto rispetto alle tariffe degli uffici postali. Errore durante il salvataggio delle impostazioni Attendi… Salvataggio delle impostazioni @@ -2401,18 +2425,15 @@ Language: it Aggiungi attributo Attributi Modifica attributi - Peso totale dei pacchi: %1$s %2$s %1$d articoli in %2$d pacchetti Peso del pacchetto totale: %1$s %2$s Pacchetti personalizzati Impossibile recuperare i prodotti - Alcuni campi richiesti sono vuoti. Peso non valido Pacchetto selezionato Attendi… Caricamento dei pacchetti. Pacchetto %1$d - %d elementi Impossibile caricare le definizioni dei pacchetti Include il peso del pacchetto Peso del pacchetto totale: %1$s @@ -2425,16 +2446,17 @@ Language: it Abbiamo leggermente modificato l\'indirizzo inserito. Se è corretto, usa l\'indirizzo inserito per garantire una consegna accurata. Modifica indirizzo selezionato Usa l\'indirizzo selezionato + Alcuni campi richiesti sono vuoti. + %d elementi + Peso totale dei pacchi: %1$s %2$s Caricamento dei dati dell\'indirizzo Nuove funzionalità disponibili. - Trova sulla mappa Contatta cliente Strada non valida Numero civico mancante Indirizzo non trovato Impossibile verificare automaticamente l\'indirizzo di spedizione. Visualizzalo su Google Maps o prova a contattare il cliente per assicurarti che l\'indirizzo sia corretto. Convalida dell\'indirizzo non riuscita - Attendi… Convalida dell\'indirizzo in corso Impossibile caricare i dati dell\'indirizzo Usa l\'indirizzo inserito @@ -2445,6 +2467,8 @@ Language: it Telefono Società Nome + Attendi… + Trova sulla mappa App di Google Maps trovata Attendi… Siamo spiacenti, la rimozione delle immagini sulle varianti dei prodotti è supportata in WooCommerce 4.7 o versione superiore. @@ -2460,31 +2484,30 @@ Language: it Dettagli imballaggio Crea etichetta di spedizione Ulteriori informazioni - Evita la fila all\'ufficio postale stampando le etichette di spedizione a casa dal tuo dispositivo mobile a prezzi scontati. Risparmia tempo e denaro con WooCommerce Shipping WooCommerce Shipping Contrassegna ordine come completo - Scopri di più su come creare etichette dal dispositivo mobile Crea etichetta di spedizione - Ora puoi creare etichette di spedizione per tutti gli ordini fisici direttamente dal tuo dispositivo con il plugin WooCommerce Shipping gratuito. Tocca \"Crea etichetta di spedizione\" per provare la funzionalità beta. Crea etichette di spedizione dal tuo dispositivo. + Ora puoi creare etichette di spedizione per tutti gli ordini fisici direttamente dal tuo dispositivo con il plugin WooCommerce Shipping gratuito. Tocca \"Crea etichetta di spedizione\" per provare la funzionalità beta. + Evita la fila all\'ufficio postale stampando le etichette di spedizione a casa dal tuo dispositivo mobile a prezzi scontati. + Scopri di più su come creare etichette dal dispositivo mobile + Modifica Tariffe Importo di pagamento netto Pagato Scopri di più su come connettere Jetpack - Modifica Convalida Trascina e rilascia per riordinare le foto + Elimina Impostazioni download Inserisci un nome valido Inserisci URL file - Libreria multimediale WordPress Verifica che l\'URL inserito sia valido Attendi… Caricamento file Errore durante il caricamento del file Aggiungi file scaricabile - Aggiungi file scaricabile da Includi i file scaricabili con gli acquisti Annulla Sì, modifica @@ -2493,7 +2516,6 @@ Language: it File Desideri rimuovere questo file? Prodotto scaricabile - Elimina Scadenza download Limite di download Inserisci il numero di giorni prima della scadenza di un link di download oppure lascia vuoto il campo se non scade mai @@ -2508,11 +2530,13 @@ Language: it Potrebbe essere necessario <b>configurare la stampa Wi-Fi direttamente sulla stampante stessa</b>. Assicurati che il firmware della stampante sia aggiornato e consulta la documentazione della stampante per le istruzioni. Puoi selezionare il <b> servizio di stampa predefinito</b> del dispositivo oppure installare l\'<b>app della stampante </b> (dovrebbe apparire come opzione consigliata) Assicurati che stampante e dispositivo siano collegati alla <b>stessa rete Wi-Fi</b> - Prova la nuova funzionalità di creazione di prodotti semplici, collegati e raggruppati mentre ci prepariamo al lancio + Libreria multimediale WordPress + Aggiungi file scaricabile da Incrementa le vendite con upsell e cross-sell Modifica prodotti Aggiungi prodotti Prodotti promossi nel carrello quando viene selezionato il prodotto corrente + Prova la nuova funzionalità di creazione di prodotti semplici, collegati e raggruppati mentre ci prepariamo al lancio Cross-sell Prodotti promossi al posto del prodotto attualmente visualizzato (ossia i prodotti più redditizi) Up-sell @@ -2520,7 +2544,6 @@ Language: it %1$s%2$s x %3$s Ottieni un link di accesso tramite e-mail Hmm, non riusciamo a trovare un account di WordPress.com collegato a questo indirizzo e-mail. - Prova a visualizzare i componenti aggiuntivi per gli ordini mentre ci prepariamo a lanciarli Creazione di prodotti Preferenze Errore durante lo spostamento nel cestino del prodotto @@ -2532,24 +2555,25 @@ Language: it L\'aggiunta delle opzioni come dimensioni e colore è attualmente disponibile solo sul web. Queste verranno visualizzate come opzioni sulla pagina del prodotto del tuo sito. Crea nuovi prodotti dall\'app! Prodotto non trovato - Se stai ancora riscontrando problemi di stampa dal tuo dispositivo, puoi <b>salvare la tua etichetta come PDF</b> e inviarla via e-mail per stamparla da un altro dispositivo. - Dopo aver selezionato <b>\"Stampa le etichette di spedizione\"</b>, potrebbe essere necessario selezionare e aggiungere una stampante se non hai stampato da questo dispositivo prima. Opzioni formato etichetta - Stampa dal dispositivo Etichetta (4 x 6 pollici) Lettera (8,5 x 11 pollici) Nota legale (8,5 x 14 pollici) Errore visualizzazione etichetta di spedizione - Non sai come stampare dal dispositivo mobile? Visualizza il layout dell\'etichetta e le opzioni delle dimensioni del foglio Stampa etichetta di spedizione Scegli dimensione carta Dimensione carta - Se hai già utilizzato l\'etichetta in un pacchetto, stamparla e utilizzarla di nuovo è una violazione dei nostri termini di servizio. Si è verificato un errore di stampa durante l\'acquisto dell\'etichetta, puoi stamparla nuovamente. Stiamo lavorando per rendere più semplice per te stampare le etichette di spedizione direttamente dal dispositivo. Per ora, se hai creato etichette di spedizione per questo ordine nell\'amministrazione del negozio con WooCommerce Shipping, puoi stamparle nei Dettagli dell\'ordine qui. Stampa etichette di spedizione dal dispositivo. + Stampa dal dispositivo + Se hai già utilizzato l\'etichetta in un pacchetto, stamparla e utilizzarla di nuovo è una violazione dei nostri termini di servizio. + Se stai ancora riscontrando problemi di stampa dal tuo dispositivo, puoi <b>salvare la tua etichetta come PDF</b> e inviarla via e-mail per stamparla da un altro dispositivo. + Dopo aver selezionato <b>\"Stampa le etichette di spedizione\"</b>, potrebbe essere necessario selezionare e aggiungere una stampante se non hai stampato da questo dispositivo prima. Stampa etichetta di spedizione + Prova a visualizzare i componenti aggiuntivi per gli ordini mentre ci prepariamo a lanciarli + Non sai come stampare dal dispositivo mobile? \u0022%1$s\u0022 Bozza prodotto salvata Errore durante il salvataggio della bozza del prodotto @@ -2597,12 +2621,12 @@ Language: it Accedi con un altro account Seleziona il negozio da connettere Continua con WordPress.com - Un prodotto con varianti come colore o misura %d prodotto selezionato %d prodotti selezionati Aggiungi i prodotti al gruppo Aggiungi prodotto Inserisci una password + Un prodotto con varianti come colore o misura Torna al negozio Contattaci qui Tieni presente che questo non è un ticket di supporto e non saremo in grado di rispondere a feedback individuali.\n\nTi serve aiuto? %1$s @@ -2627,8 +2651,8 @@ Language: it Nessun prezzo impostato Abilitata Devi impostare il prezzo di vendita se è pianificata una vendita - Ora puoi modificare i prodotti variabili, esterni e raggruppati, puoi cambiare il tipo di prodotto e aggiornare categorie e tag. %1$s ha lasciato una recensione + Ora puoi modificare i prodotti variabili, esterni e raggruppati, puoi cambiare il tipo di prodotto e aggiornare categorie e tag. Mi piace Potrebbe essere migliore Ti piace l\'app WooCommerce? @@ -2637,24 +2661,24 @@ Language: it Si è verificato un errore durante l\'aggiunta di tag Aggiunta di tag Il rimborso è in fase di elaborazione. Attendi… - Richiesta di rimborso inviata correttamente Rimborsa etichetta (-%1$s) Importo idoneo al rimborso Data di acquisto - Puoi richiedere un rimborso per un\'etichetta di spedizione non utilizzata per spedire un pacchetto. L\'elaborazione richiederà almeno 14 giorni. Richiedi un rimborso Rimborsa etichetta di spedizione + Puoi richiedere un rimborso per un\'etichetta di spedizione non utilizzata per spedire un pacchetto. L\'elaborazione richiederà almeno 14 giorni. + Richiesta di rimborso inviata correttamente Bene fisico Un breve estratto sul tuo prodotto Rendi i tuoi prodotti più semplici da trovare con i tag Organizza i tuoi prodotti in gruppi correlati + Disabilitata Aggiungi peso e dimensioni Aggiungi altri dettagli Organizza i tuoi prodotti in tag Aggiungi il tuo primo tag Tag Aggiungi tag - Disabilitata Prodotto virtuale Aggiungi altri dettagli %1$s prodotto @@ -2662,9 +2686,7 @@ Language: it %s prodotto Prodotti rimanenti %1$s \u2022 %2$s - Rimborso dell\'etichetta %1$s richiesto Traccia spedizione - %1$s\n%2$s Nascondi dettagli spedizione Mostra dettagli spedizione Carta di credito @@ -2674,6 +2696,8 @@ Language: it Spedisci a Spedisci da Pacco %d + %1$s\n%2$s + Rimborso dell\'etichetta %1$s richiesto SKU: %1$s %1$s (%2$s opzioni) Etichette di spedizione @@ -2694,12 +2718,12 @@ Language: it Nota sulla privacy per utenti in California Mantieni le modifiche Fino al giorno %1$s - Abbiamo aggiunto più funzionalità di modifica ai prodotti! Ora puoi aggiornare immagini, vedere anteprime e condividere i prodotti. Nuove opzioni di modifica disponibili - Modifica limitata disponibile + Abbiamo aggiunto più funzionalità di modifica ai prodotti! Ora puoi aggiornare immagini, vedere anteprime e condividere i prodotti. Prodotti %1$s x %2$s %1$s %2$s + Modifica limitata disponibile Esterno Semplice Pubblicato privatamente @@ -2789,11 +2813,11 @@ Language: it Larghezza Lunghezza Prodotti rimborsati - %1$s (%2$s x %3$d) %1$s tramite %2$s Desideri emettere un rimborso? Questo non può essere annullato. Prodotti rimborsati Rimborsi + %1$s (%2$s x %3$d) Iscriviti a WordPress.com Siamo spiacenti, non è stato possibile trovare risultati per \"%s\" Acquisisci recensioni del prodotto di alta qualità per il tuo negozio @@ -2814,33 +2838,32 @@ Language: it Aggiungi inventario Ricerca dei tuoi ordini in corso… Inserisci testo - Inserisci il titolo del prodotto - Prodotto salvato Errore durante l\'aggiornamento del prodotto Attendi… Descrivi il tuo prodotto Descrizione Modifica descrizione + Inserisci il titolo del prodotto + Prodotto salvato + Fatto Desideri eliminare queste modifiche? Aggiorna - Fatto Rimborso in corso, attendi… Rimborsa spedizione Seleziona quantità Rimborso spedizione Rimborso prodotti - %1$s x %2$s ciascuno %d elementi selezionati Non selezionare nessuno Seleziona tutto In attesa della conferma del rimborso… + %1$s x %2$s ciascuno Ridimensiona e comprimi le immagini per un caricamento più rapido Ottimizzazione delle immagini Scatta una foto Scegli da dispositivo Seleziona un metodo di caricamento Carica - Caricamento delle immagini in corso…%1$d di %2$d Caricamento delle immagini in corso… Impossibile accedere alla fotocamera Desideri rimuovere questa immagine? @@ -2855,6 +2878,7 @@ Language: it Aggiungi immagine In arrivo Rimuovi + Caricamento delle immagini in corso…%1$d di %2$d Non siamo stati in grado di accedere al tuo sito. Dovrai contattare il tuo fornitore di hosting per risolvere questo problema. Non siamo stati in grado di accedere al tuo sito perché abbiamo riscontrato un problema con il <b>certificato SSL</b>. Dovrai contattare il tuo fornitore di hosting per risolvere questo problema. Non siamo stati in grado di accedere al tuo sito perché viene richiesta l\'<b>autenticazione HTTP</b>. Dovrai contattare il tuo fornitore di hosting per risolvere questo problema. @@ -2863,8 +2887,8 @@ Language: it Accedi con le credenziali del sito. Accedi con le credenziali del sito %1$s Invia e-mail di verifica - Testa la nuova funzionalità di modifica del prodotto mentre ci prepariamo a lanciarla Modifica del prodotto + Testa la nuova funzionalità di modifica del prodotto mentre ci prepariamo a lanciarla Si è verificato un errore durante il recupero del tuo account. Puoi riprovare ora o chiudere e riprovare più tardi. Si è verificato un errore. Accedi per continuare Connessione al tuo sito in corso… @@ -2899,15 +2923,12 @@ Language: it Nessun prodotto corrispondente Ancora nessun prodotto %s in magazzino - \u2022 %d varianti in magazzino Immagine prodotto %1$s ha lasciato una recensione per %2$s Non approvato Errore durante il recupero della nuova recensione prodotto Errore durante il recupero delle recensioni prodotto - Si è verificato un problema con il rimborso. Riprova. - Il rimborso è stato emesso correttamente. - Il rimborso per %s è in fase di elaborazione. Attendi… + \u2022 %d varianti in magazzino Icona preventivo Rimborso manuale Dettagli di rimborso @@ -2925,6 +2946,9 @@ Language: it Rimborso %s Disponibile per il rimborso: %s Emetti rimborso + Si è verificato un problema con il rimborso. Riprova. + Il rimborso è stato emesso correttamente. + Il rimborso per %s è in fase di elaborazione. Attendi… %1$s tramite %2$s Statistiche migliorate Funzionalità beta @@ -2938,12 +2962,12 @@ Language: it Statistiche odierne Registrati Hai già Jetpack? %1$s - Tentativo di accesso con Jetpack in corso… aggiorna l\'app per continuare - Per usare quest\'app per %1$s dovrai avere il plugin Jetpack impostato e connesso a questo account. \n\nUna volta impostato, aggiorna l\'app Prova un altro negozio Database declassato, ricreazione delle tabelle e caricamento dei negozi in corso Caricamento dei negozi in corso + Tentativo di accesso con Jetpack in corso… + Per usare quest\'app per %1$s dovrai avere il plugin Jetpack impostato e connesso a questo account. \n\nUna volta impostato, aggiorna l\'app Nessun corriere trovato Inserisci un indirizzo di sito Web completo, come esempio.com. Ancora nessuna recensione! @@ -2954,12 +2978,11 @@ Language: it Impossibile recuperare le impostazioni: alcune API non sono disponibili per questa combinazione di ID app OAuth + account. Stiamo assumendo. Copia numero di tracciabilità - Verifica per WooCommerce… aggiorna l\'app + Verifica per WooCommerce… Nessun indirizzo specificato Ti serve aiuto per trovare l\'e-mail con cui ti sei collegato? Il sito web su questo indirizzo non è un sito WordPress. Per poterci collegare a esso, il sito deve avere WordPress installato. - Accedi con WordPress.com per collegarti a <b>%1$s</b> Zimbabwe Zambia Yemen @@ -3096,6 +3119,7 @@ Language: it Giamaica Costa d\'Avorio Italia + Accedi con WordPress.com per collegarti a <b>%1$s</b> Israele Isola di Man Irlanda @@ -3202,24 +3226,15 @@ Language: it Afghanistan Isole Åland Recensione - Corriere personalizzato Personalizza - Inserisci il nome di un corriere Inserisci un numero di tracciabilità - Seleziona un corriere Vuoi davvero eliminare questa tracciabilità? Impossibile aggiungere la tracciabilità Tracciabilità della spedizione aggiunta - Errore durante il recupero dei corrieri - Corriere della spedizione selezionato - Corrieri della spedizione Data di spedizione Inserisci link di tracciabilità - Inserisci il nome del corriere Inserisci numero di tracciabilità - Seleziona il corriere Link di tracciabilità (opzionale) - Nome del corriere Numero di tracciabilità Corriere Aggiungi tracciabilità @@ -3232,19 +3247,25 @@ Language: it Traccia spedizione Nell\'amministrazione del sito puoi trovare l\'e-mail che hai usato per collegarti a WordPress.com dalla %1$sBacheca di Jetpack%2$s sotto %3$sConnessioni > Connessione dell\'account%4$s Quale e-mail uso per accedere? - Hai bisogno di aiuto per trovare l\'e-mail richiesta? Jetpack è un plugin WordPress gratuito che collega il tuo negozio con gli strumenti necessari per assicurarti la migliore esperienza mobile, incluse le notifiche push e le statistiche Che cos\'è Jetpack? Visualizza i negozi collegati - Sembra che %1$s sia connesso a un account WordPress.com differente. Continua a modificare + Corriere personalizzato + Inserisci il nome di un corriere + Seleziona un corriere + Errore durante il recupero dei corrieri + Corriere della spedizione selezionato + Corrieri della spedizione + Inserisci il nome del corriere + Seleziona il corriere + Nome del corriere + Sembra che %1$s sia connesso a un account WordPress.com differente. + Hai bisogno di aiuto per trovare l\'e-mail richiesta? Accedi con i tuoi nome utente e password. Accedi utilizzando il tuo nome utente di WordPress.com invece del tuo indirizzo e-mail. Il sito a questo indirizzo non è un sito WordPress. Per poterci connettere, il sito deve utilizzare WordPress. Centro assistenza - Virtuale - Raggruppato - Variabile Permetti, ma invia notifica al cliente Permetti Non permettere @@ -3252,6 +3273,9 @@ Language: it Esaurito In magazzino Leggi altro + Raggruppato + Variabile + Virtuale Impossibile caricare le immagini Bozza Privato @@ -3297,11 +3321,11 @@ Language: it Prova ora OK Tocca per passare da un negozio all’altro - Seleziona negozio Esci da questo account Modifica lo stato dell\'ordine Fai clic per modificare lo stato dell\'ordine Applica + Seleziona negozio No, grazie Più tardi Valuta ora @@ -3315,11 +3339,11 @@ Language: it Aggiorna il negozio su WooCommerce 3.5 Impossibile eseguire la connessione a %s Rimuovi - Errore durante il contrassegno di tutte le recensioni come lette Contrassegna tutte come lette Messaggio Chiamata Chiama o manda un messaggio al cliente + Errore durante il contrassegno di tutte le recensioni come lette Errore durante l\'aggiornamento dello stato della recensione del prodotto Errore durante il caricamento dei dettagli della recensione del prodotto Cestino @@ -3332,16 +3356,16 @@ Language: it Gestisci notifiche Notifiche Desideri uscire dall\'account %s? - Recensione contrassegnata come %1$s Se disabilitata, la nota diventerà privata + Recensione contrassegnata come %1$s Errore durante il recupero dell\'ordine Indietro Avvisi delle recensioni del prodotto Avvisi dei nuovi ordini Al cliente - Verifica del sito in corso… Aggiorna istruzioni Ricerca + Verifica del sito in corso… Aggiorna e altre %d. %d nuove notifiche @@ -3373,9 +3397,9 @@ Language: it Rapporti di arresto anomalo Condividi Versione %s - Password HTTP - HTTP nome utente - Autorizzazione richiesta + Abbiamo effettuato troppi tentativi di invio di un codice di verifica SMS: prenditi una pausa e richiedine uno nuovo tra un minuto. + Non è presente alcun account WordPress.com corrispondente a questo account Google. + Accedi all\'account WordPress.com che hai utilizzato per connettere Jetpack. Link inviato Registrazione via e-mail Verifica del codice @@ -3384,32 +3408,9 @@ Language: it Accesso tramite link Accesso tramite indirizzo sito Accesso tramite indirizzo e-mail - Si è verificato un errore. - Fornisci un codice di autenticazione per continuare. - Controlla due volte la tua password per continuare. - Accesso interrotto - Attendi mentre stai effettuando l\'accesso. - Accesso in corso… - Tocca per continuare. - Accesso effettuato! - Si è verificato un errore di rete. Controlla la connessione e riprova. - Inserisci un sito web WordPress.com o un sito web WordPress.com ospitato personalmente connesso a Jetpack. - Non è possibile connettersi. Abbiamo ricevuto un messaggio di errore 403 durante il tentativo di accedere\n all\'endpoint XMLRPC del tuo sito. Per l\'app questo è necessario per comunicare con il tuo sito. Contatta il tuo host per risolvere\n questo problema. - Non è possibile connettersi. Il tuo host sta bloccando le richieste POST ma queste sono necessarie al corretto funzionamento dell\'app\n per comunicare con il tuo sito. Contatta il tuo fornitore del servizio di hosting per risolvere questo problema. - Non è possibile connettersi. Sul server mancano i necessari metodi XML-RPC - Controlla che l\'URL del sito sia valido - Si è verificato un errore - Password dimenticata? - Inserisci un indirizzo e-mail valido - Verifica della email - Accedi di nuovo per continuare. - Accedi all\'account WordPress.com che hai utilizzato per connettere Jetpack. - Impossibile recuperare il profilo - È stato rilevato un sito duplicato. - Il sito esiste già nell\'app, non lo puoi aggiungere. - Il nome utente o la password immessi non sono corretti - Tempo di risposta di Google troppo lungo. Potresti dover attendere fino a quando non disporrai di una connessione Internet più forte. + Non hai un account? %1$sRegistrati%2$s Registrazione con Google… + Tempo di risposta di Google troppo lungo. Potresti dover attendere fino a quando non disporrai di una connessione Internet più forte. Registrati con Google Registrati con l\'e-mail Registrandoti, accetti i nostri %1$sTermini di servizio%2$s. @@ -3419,20 +3420,54 @@ Language: it Si è verificato un errore durante l\'invio dell\'email. Puoi riprovare ora o chiudere e riprovare più tardi. Per creare il tuo nuovo account WordPress.com, inserisci l\'indirizzo e-mail. Si è verificato un errore durante il controllo dell\'indirizzo e-mail. - \nPuoi provare un account diverso? + Si è verificato un errore. + Fornisci un codice di autenticazione per continuare. + Controlla due volte la tua password per continuare. + Accesso interrotto + Attendi mentre stai effettuando l\'accesso. + Accesso in corso… + Tocca per continuare. + Accesso effettuato! Il login tramite Google non può essere inizializzato. - Abbiamo effettuato troppi tentativi di invio di un codice di verifica SMS: prenditi una pausa e richiedine uno nuovo tra un minuto. + Inserisci una password + \nPuoi provare un account diverso? Si sono verificati dei problemi con la connessione con l’account Google. - Non è presente alcun account WordPress.com corrispondente a questo account Google. Chiudi Accedi con Google. + Si è verificato un errore di rete. Controlla la connessione e riprova. Autenticato come Impossibile rilevare il client di posta elettronica della tua app - Non hai un account? %1$sRegistrati%2$s Inserisci un codice di verifica - Inserisci una password - Inserisci un nome utente + È stato rilevato un sito duplicato. + Il sito esiste già nell\'app, non lo puoi aggiungere. + Non è possibile connettersi. Abbiamo ricevuto un messaggio di errore 403 durante il tentativo di accedere\n all\'endpoint XMLRPC del tuo sito. Per l\'app questo è necessario per comunicare con il tuo sito. Contatta il tuo host per risolvere\n questo problema. + Non è possibile connettersi. Il tuo host sta bloccando le richieste POST ma queste sono necessarie al corretto funzionamento dell\'app\n per comunicare con il tuo sito. Contatta il tuo fornitore del servizio di hosting per risolvere questo problema. + Verifica della email + Non è possibile connettersi. Sul server mancano i necessari metodi XML-RPC + Impossibile recuperare il profilo + Accedi di nuovo per continuare. + Password dimenticata? + Il nome utente o la password immessi non sono corretti + Inserisci un indirizzo e-mail valido + Si è verificato un errore + Autorizzazione richiesta + Controlla che l\'URL del sito sia valido + Password HTTP + HTTP nome utente + Inserisci un sito web WordPress.com o un sito web WordPress.com ospitato personalmente connesso a Jetpack. + In alternativa: + Generale + \@%s + Accedi con il tuo nome utente. + Accedi inserendo l\'indirizzo del tuo sito. + Inviami invece un messaggio con un altro codice. + Abbiamo inviato un messaggio di testo al numero di telefono che termina in %s. Inserisci il codice di verifica contenuto nell\'SMS. + Per procedere con questo account Google, fornisci la password WordPress.com corrispondente. Verrà richiesta solo una volta. Accedi a WordPress.com per condividere il contenuto. + Immetti l\'indirizzo del tuo sito WordPress sul quale desideri condividere il contenuto. + Si è verificato un errore durante l\'apertura di un browser web di base. Scegli un\'altra applicazione: + Impossibile aprire il link + Inserisci un nome utente Accedi a WordPress.com per accedere all\'articolo. Si è verificato un errore durante l\'aggiunta del sito. Codice di errore: %s Verifica indirizzo sito @@ -3441,25 +3476,15 @@ Language: it Qual è l\'indirizzo del mio sito? Hai bisogno di aiuto per trovare l\'indirizzo del tuo sito? Indirizzo sito - Immetti l\'indirizzo del tuo sito WordPress sul quale desideri condividere il contenuto. \@%s Hai già effettuato l\'accesso a WordPress.com Continua - Connettiti a un sito Connetti un altro sito - Per procedere con questo account Google, fornisci la password WordPress.com corrispondente. Verrà richiesta solo una volta. Inserisci la tua password WordPress.com. - Attualmente non disponibile. Inserisci la tua password Richiesta e-mail di accesso Sembra che la password non sia corretta. Verifica attentamente le informazioni e riprova. Richiesta codice di verifica tramite SMS. - Inviami invece un messaggio con un altro codice. Inviami un codice. - Abbiamo inviato un messaggio di testo al numero di telefono che termina in %s. Inserisci il codice di verifica contenuto nell\'SMS. - Ci sei quasi! Inserisci il codice di verifica per WordPress.com dalla tua app di autenticazione. - Accedi con il tuo nome utente. - Accedi inserendo l\'indirizzo del tuo sito. - In alternativa: Apri mail Successivo Gestisci il tuo sito con Jetpack mentre sei in movimento: hai WordPress in tasca. @@ -3467,29 +3492,39 @@ Language: it Tieni il passo con i tuoi siti preferiti e partecipa a una conversazione ovunque e in qualsiasi momento. Guarda i lettori da tutto il mondo leggere e interagire con il tuo sito, in tempo reale. Pubblica dal parco. Lavora al blog sull\'autobus. Commenta dal bar. WordPress si sposta con te. - Accedi - Aiuto - Password - Nome utente - Oppure inserisci la tua password + Sei già connesso a un account WordPress.com, non puoi aggiungere un sito WordPress.com legato a un altro account. + Riprova + Uscire Invia link + Attualmente non disponibile. Inserisci la tua password + Sto entrando + Oppure inserisci la tua password + Indirizzo e-mail + Dettagli + Annulla Codice di verifica non valido Codice di verifica - Indirizzo e-mail + Aiuto + Rimuovi + Accedi + Nome utente + Password + Senza titolo + Impostazioni + Oggi + Annulla Supporto %s WooCommerce per Android opzione non controllata opzione controllata Politica sulla privacy di terze parti Politica sui cookie Politica sulla privacy - Creato con amore da Automattic. %1$s Utilizziamo altri strumenti di tracciamento, inclusi alcuni di terze parti. Leggi le informazioni sugli strumenti e come utilizzarli. Leggi la politica sulla privacy Questa informazione ci aiuta a migliorare i nostri prodotti, a rendere il marketing più mirato, a personalizzare la tua esperienza su WooCommerce e molto altro come descritto in dettaglio nostra politica sulla privacy Condividi informazioni con gli strumenti di analisi relative all\'uso dei servizi mentre sei connesso al tuo account WordPress Raccogli informazioni Impostazioni privacy - Impostazioni Stato dell\'ordine Rimborsato Cancellato @@ -3503,7 +3538,6 @@ Language: it Aggiungi Nota e-mail al cliente Errore durante la modifica dell\'ordine - Errore durante il recupero delle note Ordine contrassegnato come completo Contrassegna ordine come completo Aggiungi una nota dell\'ordine @@ -3512,7 +3546,6 @@ Language: it Mostra fatturazione Pagamento cancellato Note ordine - Privato Scrivi una nota dell\'ordine Immagine profilo personalizzata Nota fornita dai clienti @@ -3537,9 +3570,6 @@ Language: it Nessun ordine Visualizza ordini Visualizza ordine - Nessuna attività per questo periodo - Ordini totali: %s - Immagine dell\'errore Errore durante recupero dati Fatturato Ordini @@ -3552,17 +3582,11 @@ Language: it Nessun negozio WooCommerce Foto del profilo Negozio collegato - Leggi le %1$sistruzioni di configurazione%2$s. Questa app richiede che Jetpack sia connesso al tuo negozio. - \@%s - Immetti l\'indirizzo del negozio WooCommerce che desideri connettere. Accedi con l\'indirizzo e-mail dell\'account WordPress.com per gestire i negozi WooCommerce. - Sei già connesso a un account WordPress.com, non puoi aggiungere un sito WordPress.com legato a un altro account. - Impossibile aprire il link Nessuna app per SMS trovata Nessuna app per e-mail trovata Nessuna app per telefono trovata - Si è verificato un errore durante l\'apertura di un browser web di base. Scegli un\'altra applicazione: Impossibile aprire il link %1$s alle %2$s Più vecchie di un mese @@ -3571,22 +3595,15 @@ Language: it Ieri Oggi Prodotti - Rimuovi Quest\'anno Questo mese Questa settimana - Oggi Prodotto La rete non è disponibile. Controlla la connessione dati o WiFi. Offline u2014 utilizza i dati memorizzati nella cache Per saperne di più - Annulla - Senza titolo Continua - Annulla - Riprova Nascondi dettagli - Dettagli Sconto Subtotale Tasse @@ -3596,11 +3613,18 @@ Language: it %1$s%2$s Ordini Il mio negozio - Uscire - Sto entrando Tutto - Generale WooCommerce + Immagine dell\'errore + Creato con amore da Automattic. %1$s + Immetti l\'indirizzo del negozio WooCommerce che desideri connettere. + Privato + Connettiti a un sito + Nessuna attività per questo periodo + Ordini totali: %s + Errore durante il recupero delle note + Leggi le %1$sistruzioni di configurazione%2$s. + Ci sei quasi! Inserisci il codice di verifica per WordPress.com dalla tua app di autenticazione. @string/date_timeframe_custom @string/date_timeframe_today diff --git a/WooCommerce/src/main/res/values-ja/strings.xml b/WooCommerce/src/main/res/values-ja/strings.xml index a7b9980e701..b3e7660fac2 100644 --- a/WooCommerce/src/main/res/values-ja/strings.xml +++ b/WooCommerce/src/main/res/values-ja/strings.xml @@ -1,11 +1,71 @@ + 間違ったキー: 先頭の「_」文字を削除してください。 + このキーはすでに別のカスタムフィールドで使用されています。\n現在、アプリは複製キーの作成をサポートしていません。 必要に応じて、wp-admin を使用してキーを複製してください。 + カスタムフィールドを追加 + カスタムフィールドを削除しました + 変更を保存できませんでした。もう一度お試しください + 変更を保存しました + 変更を保存中 + インターネットに接続されていないようです。 Wi-Fi がオンになっていることをご確認ください。 モバイルデータを使用している場合、デバイスの設定で有効になっていることを確認してください。 + スキャンに失敗しました。 後でもう一度お試しください + + キー + バリエーションや仮想などの他の商品タイプは、今後のアップデートで利用可能になります。 + 現在、POS で使用できるのはシンプルな物理的商品のみです。 + キャンセル + 期間 + キャンペーンは停止するまで実行されます。 + 期間を特定 + %1$sまで + 予約 + 1日あたりの支出 + キャンペーンに費やす費用はいくらですか ? また、キャンペーンの期間はどの程度ですか ? + %1$s ➔ %2$s + Blaze で商品を何百万もの人に見てもらい、売上を伸ばしましょう + 売上を伸ばしたいとお考えですか ? + カスタムフィールドの読み込み中にエラーが発生しました + カスタムフィールド + 背景を暗くしました。 タップしてダイアログを閉じます。 + 毎週%1$s + 止めるまで実行します + %1$sから進行中 + 週の支出 + 毎週%1$s、%2$sに開始 + 毎週 + 残り + 合計 + クリックスルー + 端末がバッテリーセーバーモードになっているようです。 \nストアがアクティブな間はストア情報を提供できません + オプション付きのポップアップメニュー。 スワイプしてアイテム間を移動します。 + ツールバーメニューを開く + カードリーダーステータス付きのツールバー。 メニューが開いています。 ダブルタップして操作します。 + カードリーダーステータス付きのツールバー。 ダブルタップして操作します。 + メニューが無効です + メニューが有効です + カードリーダーが接続されていません。 ダブルタップして接続 + カードリーダーが接続されました + 背景を暗くしました。 タップしてメニューを閉じます。 + 支払い成功チェックマークアイコン + お買い物カゴからこのアイテムを削除 + 進行中の注文はすべて失われます。 + 販売時点管理モードを終了しますか ? + 閉じる + 背景を暗くしました。 タップしてダイアログを閉じます。 + ダブルタップしてダイアログを閉じる + シンプルな商品のみのダイアログ + ダブルタップして詳しく確認 + シンプルな商品のみのバナー + Blaze 広告で商品を宣伝し、今すぐ売上を増やしましょう。 + 売上を増やす + 外出先で支払いを受け取る + 誤った PIN が入力されました。 再度お試しいただくか、別の支払い方法をお使いください メニュー お買い物カゴ %s の商品、価格%s 商品 %s、価格%s @@ -14,8 +74,7 @@ Language: ja_JP 新しい注文 OK + ストア管理で注文を作成 - シンプルな商品以外の支払いを受け取るには、POS を終了して注文タブから新しい注文を作成します。 - 現在、POS で使用できるのはシンプルな物理的商品のみです。\nバリエーションや仮想などの他の商品タイプは、今後のアップデートで利用可能になります。 + シンプルな商品以外の支払いを受け取るには、POS を終了して注文タブから新しい注文を作成します。 商品が表示されないのはなぜですか ? 情報 閉じる @@ -39,8 +98,6 @@ Language: ja_JP 現在、POS はシンプルな商品のみに対応しています – \n作成して開始します。 サポート対象の商品が見つかりません 商品なし - お客様にサービスを提供しましょう - 開始 サポートを受ける リーダーを接続 写真が削除されました @@ -135,8 +192,7 @@ Language: ja_JP 数量ルールなし 読者 キャンセル - 終了 - POS を終了してもよいですか ? + 終了 POS を終了 %s をお買い物カゴから削除 購入手続き @@ -419,20 +475,16 @@ Language: ja_JP キャッチフレーズ 画像を変更 適用 - 開始 + 開始日 %1$s日間 - 期間を設定する インプレッションには、広告が潜在顧客に表示される頻度が反映されます。\n\n\n オンライントラフィックやユーザーの行動は変動するため正確な数を保証することはできないものの、広告の実際のインプレッションを目標の数に可能な限り近づけることを目指します。\n\n\n インプレッションは可視性のことであり、閲覧者が取ったアクションではありません。 完了 インプレッション 更新 編集 - 期間 1日あたりの推定リーチ数 1日 %1$s %1$s日間 - 総支出 - 商品プロモーションキャンペーンにいくら使いますか ? 予算を設定する すべて %2$sから%1$s日間 @@ -634,7 +686,6 @@ Language: ja_JP Blaze でストアの売上を増やす キャンペーンのリストを更新中にエラーが発生しました。 後でもう一度お試しください。 メディアソースを選択する - 商品名と説明文の生成中にエラーが発生しました。 テキストが検出されませんでした。 別のパッケージ写真を選択するか、商品の詳細を手動で入力してください。 商品を追加する バーコードをスキャンする @@ -656,9 +707,6 @@ Language: ja_JP デビットカードまたはクレジットカードで %s の支払いを試します。\n完了したら返金されます。 簡単、安全でプライバシーは保護されます。 あらゆる種類のオフラインでの支払いを\nスマートフォンで受け取れます。 追加のハードウェアは不要です。 - 予算 - クリック数 - インプレッション 却下 完了 有効 @@ -691,46 +739,23 @@ Language: ja_JP 設定 メールアドレスを使用して手動で詳細を追加 アプリログインのリクエストを処理できませんでした - 商品名をクリップボードにコピーする際にエラーが発生しました。 - 商品名がクリップボードにコピーされました。 - AI によって生成された商品名 商品説明をクリップボードにコピーする際にエラーが発生しました。 商品説明がクリップボードにコピーされました。 商品の生成に失敗しました。 もう一度お試しください 住所をクリアしてこのレートの使用を停止する この注文に新しい税率を設定する 税率の自動加算 - 商品名の生成中に問題が発生しました。 もう一度お試しください。 - 再生成 - ご記入ください - 商品の概要とその特徴的な点を教えてください。 - AI が魅力的なタイトルを生成します - 商品名 すべての商品レビューを表示するには、未読フィルターを無効にしてみてください 未読の商品レビューはありません - トーンが選択されました 説得力がある フォーマル カジュアル - トーンと声を設定してブランドに合わせた商品のプレゼンテーションを形成します。 トーンと声 詳細 - 商品説明 商品名 - 以下の情報は後からいつでも変更できます。 プレビュー - 商品情報を作成 - トーンと声を設定する - 主要な機能や利点、詳細を追加して商品をオンラインで見つけられるようにします。 例: やわらかな生地、丈夫な縫製、ユニークなデザインなど - 商品のユニークな点を強調し、AI に魔法をかけてもらいましょう。 - 商品について - 名前を提案 - 例: やわらかな生地、丈夫な縫製、ユニークなデザインなど。 - 商品名 - または、タップして選択肢を拡張し、さらに名前の候補を表示します。 - 商品名を追加する AI を活用しています。 <a href=\'guidelines\'><u>さらに詳しく</u></a>。 手動で商品と詳細を追加します 手動で追加する @@ -1054,7 +1079,6 @@ Language: ja_JP 特別オファーで売上を伸ばす ストアを表示 最新の状態に保つ - モバイル決済を利用する 管理画面でさらに管理する 一般 設定 diff --git a/WooCommerce/src/main/res/values-ko/strings.xml b/WooCommerce/src/main/res/values-ko/strings.xml index 67b0a987e4a..a18502da281 100644 --- a/WooCommerce/src/main/res/values-ko/strings.xml +++ b/WooCommerce/src/main/res/values-ko/strings.xml @@ -1,11 +1,71 @@ + 유효하지 않은 키: 처음의 \"_\" 문자를 제거하세요. + 이 키는 이미 다른 사용자 정의 필드에 사용되었습니다.\n이 앱은 현재 중복 키 생성을 지원하지 않습니다. 필요한 경우 wp-admin을 사용하여 키를 복제하세요. + 사용자 정의 필드 추가 + 사용자 정의 필드 삭제됨 + 변경 사항을 저장하지 못했습니다. 다시 시도하세요. + 변경 사항 저장됨 + 변경 사항 저장 중 + 인터넷에 연결되지 않은 것 같습니다. 와이파이가 켜졌는지 확인하세요. 모바일 데이터를 사용 중이라면 기기 설정에서 활성화되었는지 확인하세요. + 스캔하지 못했습니다. 나중에 다시 시도하세요. + + + 변형 상품, 가상 상품 등의 다른 상품 유형은 향후 업데이트를 통해 지원될 예정입니다. + 현재 POS에서는 단순 실물 상품만 사용할 수 있습니다. + 취소 + 지속기간 + 중지하실 때까지 캠페인이 실행됩니다. + 지속기간 지정 + 종료 %1$s + 일정 + 일일 지출 + 캠페인에 얼마를 지출하고 얼마 동안 캠페인을 실행하시겠어요? + %1$s ➔ %2$s + Blaze를 통해 수백만 명에게 상품을 보여주고 매출 증대 + 매출 증대에 대해 생각 중이신가요? + 사용자 정의 필드를 로드하는 중 오류 발생 + 사용자 정의 필드 + 어두워진 배경입니다. 눌러서 대화 상자 무시하기 + 주당 %1$s + 사용자가 중지할 때까지 실행 + %2$s부터 주당 %1$s + 주당 + 나머지 + 합계 + 클릭 통과 + 회원님의 기기가 배터리 세이버 모드인 것 같습니다. \n이 모드가 활성화된 상태에서는 스토어 정보를 제공해 드릴 수 없습니다. + %1$s부터 계속 진행 + 주당 지출 + 옵션이 포함된 팝업 메뉴입니다. 밀어서 아이템을 탐색할 수 있습니다. + 도구 모음 메뉴 열기 + 카드 리더 상태를 포함한 도구 모음입니다. 메뉴가 열립니다. 두 번 눌러 상호작용할 수 있습니다. + 카드 리더 상태를 포함한 도구 모음입니다. 두 번 눌러 상호작용할 수 있습니다. + 메뉴 비활성화됨 + 메뉴 활성화됨 + 카드 리더가 연결되어 있지 않습니다. 두 번 눌러 연결할 수 있습니다. + 카드 리더가 연결되었습니다. + 어두워진 배경입니다. 눌러서 메뉴를 닫을 수 있습니다. + 결제 성공 체크 표시 아이콘 + 장바구니에서 이 아이템 빼기 + 진행 중인 주문이 모두 손실됩니다. + 판매 지점 모드를 종료할까요? + 닫기 + 어두워진 배경입니다. 눌러서 대화 상자 무시하기 + 두 번 눌러 대화 상자 무시하기 + 단순 상품만 해당 대화 상자 + 두 번 눌러 자세히 알아보기 + 단순 상품만 해당 배너 + 지금 Blaze 광고를 통해 제품을 홍보하고 매출을 늘리세요. + 매출 증대 + 이동 중 결제 처리 + 올바르지 않은 PIN이 입력되었습니다. 다시 시도하거나 다른 결제 수단을 사용하세요. 메뉴 장바구니의 상품 %s, 가격 %s 상품 %s, 가격 %s @@ -14,14 +74,13 @@ Language: ko_KR 새 주문 확인 + 스토어 관리에서 주문 생성 - 단순 상품이 아닌 상품에 대한 결제를 받으려면 POS를 종료하고 주문 탭에서 새 주문을 생성하세요. - 현재 POS에서는 단순 실물 상품만 사용할 수 있습니다.\n변형 상품, 가상 상품 등의 다른 상품 유형은 향후 업데이트를 통해 지원될 예정입니다. 왜 내 상품이 보이지 않나요? 정보 닫기 - 더 알아보기 현재 POS에는 단순 실물 상품만 호환됩니다. 변형 상품, 가상 상품 등의 다른 상품 유형은 향후 업데이트를 통해 지원될 예정입니다. 단순 상품만 표시 + 단순 상품이 아닌 상품에 대한 결제를 받으려면 POS를 종료하고 주문 탭에서 새 주문을 생성하세요. + 자세히\u00A0알아보기 사이트 주소 우커머스용 Google 유료 캠페인 추가 @@ -39,8 +98,6 @@ Language: ko_KR 다시 시도 지원되는 상품을 찾을 수 없음 상품 없음 - 고객에게 서비스 제공하기 - 시작하기 고객 지원 리더 연결 사진 제거됨 @@ -135,11 +192,10 @@ Language: ko_KR 세금 잠재 고객 취소 - 종료 - POS를 종료하시겠어요? POS 종료 체크아웃 장바구니에서 %s 삭제 + 종료 리더 상태 알 수 없음 체크아웃 리더 연결됨 @@ -419,23 +475,19 @@ Language: ko_KR 태그라인 이미지 변경 적용 - 시작 %1$s일 - 기간 설정 완료 노출 수 업데이트 + 노출 수는 잠재적 고객에게 광고가 표시되는 빈도를 반영합니다.\n\n\n 온라인 트래픽과 사용자 행동의 유동성으로 인해 정확한 수는 보장할 수 없지만 광고의 실제 노출 수를 목표 수에 최대한 가깝게 일치시키는 것을 목표로 합니다.\n\n\n 노출 수는 독자의 행동이 아니라 가시성을 나타낸다는 점에 유의하세요. 편집 - 지속기간 일일 예상 도달 인원 - 노출 수는 잠재적 고객에게 광고가 표시되는 빈도를 반영합니다.\n\n\n 온라인 트래픽과 사용자 행동의 유동성으로 인해 정확한 수는 보장할 수 없지만 광고의 실제 노출 수를 목표 수에 최대한 가깝게 일치시키는 것을 목표로 합니다.\n\n\n 노출 수는 독자의 행동이 아니라 가시성을 나타낸다는 점에 유의하세요. 매일 %1$s %1$s일 동안 - 총 지출 - 상품 프로모션 캠페인에 얼마를 지출하시겠습니까? 예산 설정 모두 %2$s(으)로부터 %1$s일 + 시작 날짜 다시 표시하지 않음 나중에 다시 알림 잠깐 시간 좀 내주시겠어요? 빠른 피드백으로 AI 도우미 기능 개선을 도와주세요. @@ -634,7 +686,6 @@ Language: ko_KR 스토어에서 Blaze로 판매 증대 유도 캠페인 목록 새로 고침 중 오류가 발생했습니다. 나중에 다시 시도하세요. 미디어 소스 선택 - 상품 이름과 설명을 생성하는 동안 오류가 발생했습니다. 감지된 텍스트가 없습니다. 다른 포장 사진을 선택하거나 수동으로 상품 상세 정보를 입력하세요. 상품 추가 바코드 스캔 @@ -656,9 +707,6 @@ Language: ko_KR 직불카드 또는 신용카드로 %s 결제를 시도합니다.\n완료하면 결제가 환불됩니다. 쉽고 안전합니다. 모든 종류의 대면 결제를 \n스마트폰에서 허용합니다. 추가 하드웨어가 필요하지 않습니다. - 예산 - 클릭 수 - 노출 수 거부됨 완료됨 활성 @@ -691,46 +739,23 @@ Language: ko_KR 설정 이메일을 사용하여 수동으로 상세 정보 추가 앱 로그인 요청을 처리하지 못했습니다. - 클립보드에 상품 이름을 복사하는 중 오류가 발생했습니다. - 상품 이름이 클립보드에 복사되었습니다. - AI로 생성된 상품 이름 클립보드에 상품 설명을 복사하는 중 오류가 발생했습니다. 상품 설명이 클립보드에 복사되었습니다. 상품이 생성되지 않았습니다. 다시 시도하세요. 주소 정리 및 이 요율 사용 중지 이 주문에 대한 새 세율 설정 자동으로 세율 추가하기 - 상품 이름 생성 중 문제가 발생했습니다. 다시 시도하세요. - 재생성 - 작성 의뢰 - 상품이 무엇이고 어떤 점이 독특한지 말씀해 주세요! - AI에게 매력적인 제목 생성 맡기기 모든 상품 리뷰를 표시하려면 읽지 않음 필터를 비활성화해 보세요. - 상품 이름 읽지 않은 상품 리뷰 없음 - 선택된 어조 설득력 화려 정중 평상시 - 브랜드에 어울리는 상품 프레젠테이션을 위해 어조와 목소리를 설정하세요. 어조 및 목소리 상세 정보 - 상품 설명 상품 이름 - 아래의 상세 정보는 나중에 언제든지 변경하실 수 있습니다. 미리보기 - 상품 상세 정보 생성 - 어조 및 목소리 설정 - 온라인에서 상품을 찾는 데 도움이 되도록 주요 기능, 이점 또는 상세 정보를 추가하세요. 예: 부드러운 원단, 튼튼한 바느질, 독특한 디자인 - 어떤 부분이 상품을 독특하게 만드는지 강조하고 AI가 만들어 내는 놀라운 결과물을 확인하세요. - 상품 소개 - 이름 제안 - 예: 부드러운 원단, 튼튼한 바느질, 독특한 디자인 - 상품 이름 - 선택의 폭을 넓히려면 눌러서 더 많은 이름 제안을 볼 수 있습니다. - 상품 이름 추가 AI를 활용했습니다. <a href=\'guidelines\'><u>자세히 알아보세요</u></a>. 수동으로 상품 및 상세 정보 추가 수동으로 추가 @@ -1054,7 +1079,6 @@ Language: ko_KR 특별 행사로 매출 증대 스토어 보기 최신 상태로 유지 - 모바일 결제에 참여 관리자에서 자세히 관리 일반 설정 diff --git a/WooCommerce/src/main/res/values-night/colors_base.xml b/WooCommerce/src/main/res/values-night/colors_base.xml index 3b441e6c9d6..452dae09fa7 100644 --- a/WooCommerce/src/main/res/values-night/colors_base.xml +++ b/WooCommerce/src/main/res/values-night/colors_base.xml @@ -131,7 +131,6 @@ - @color/woo_white @color/woo_white @color/woo_purple_40 diff --git a/WooCommerce/src/main/res/values-nl/strings.xml b/WooCommerce/src/main/res/values-nl/strings.xml index 5c1e71aa4db..5bd47686a17 100644 --- a/WooCommerce/src/main/res/values-nl/strings.xml +++ b/WooCommerce/src/main/res/values-nl/strings.xml @@ -1,11 +1,71 @@ + Ongeldige sleutel: verwijder het teken \'_\' van het begin. + Deze sleutel wordt al gebruikt voor een ander aangepast veld.\nDe app ondersteunt momenteel niet het aanmaken van meerdere identieke sleutels. Gebruik wp-admin om een sleutel te kopiëren als dit nodig is. + Aangepaste velden toevoegen + Aangepast veld verwijderd + Het opslaan van de wijzigingen is mislukt, probeer het nog eens + Wijzigingen opgeslagen + Wijzigingen aan het opslaan + Het lijkt erop dat je niet bent verbonden met het internet. Zorg ervoor dat je wifi aan staat. Als je mobiele data gebruikt, zorg er dan voor dat deze is ingeschakeld in de instellingen van je apparaat. + Scan mislukt. Probeer het later opnieuw + Waarde + Sleutel + Andere producttypen, zoals variabel en virtueel, worden in toekomstige updates beschikbaar. + Alleen simpele fysieke producten zijn momenteel beschikbaar met POS. + Annuleren + Duur + De campagne loopt totdat je deze stopzet. + Stel de duur in + tot %1$s + Inplannen + Dagelijkse uitgaven + Hoeveel wil je uitgeven aan je campagne, en hoelang wil je dat deze loopt? + %1$s ➔ %2$s + Zorg dat je producten door miljoenen worden gezien met Blaze en geef je verkoop een boost + Wil je jouw verkoop een boost geven? + Fout bij het laden van aangepaste velden + Aangepaste velden + Achtergrond gedimd. Tik om het dialoogvenster te sluiten. + %1$s wekelijks + Uitvoeren tot ik dit zelf stopzet + Doorgaand vanaf %1$s + wekelijkse uitgaven + %1$s wekelijks vanaf %2$s + Wekelijks + Resterend + Totaal + Doorklikken + Het lijkt erop dat de energiebesparende modus op je apparaat aan staat. \nWe kunnen je winkelgegevens niet geven terwijl deze ingeschakeld is + Pop-upmenu met opties. Swipe om door de items te navigeren. + Taakbalkmenu openen + Taakbalk met status van kaartlezer. Het menu is geopend. Tik dubbel voor interacties. + Taakbalk met status van kaartlezer. Tik dubbel voor interacties. + Menu uitgeschakeld + Menu ingeschakeld + Kaartlezer niet verbonden. Tik dubbel om te verbinden + Kaartlezer verbonden + Achtergrond gedimd. Tik om het menu te sluiten. + Vinkje voor Betaling gelukt + Dit artikel uit winkelwagen verwijderen + Alle bestellingen die nog in behandeling zijn, zullen verloren gaan. + Verkooppuntmodus afsluiten? + Sluiten + Achtergrond gedimd. Tik om het dialoogvenster te sluiten. + Tik dubbel om het dialoogvenster te sluiten + Dialoogvenster voor Alleen simpele producten + Tik dubbel voor meer informatie + Banner voor Alleen simpele producten + Promoot je producten met Blaze Ads en verhoog nu je omzet. + Verhoog je omzet + Neem onderweg betalingen aan + Er is een onjuiste pincode ingevoerd. Probeer het opnieuw of probeer een andere betaalmethode Menu Product in winkelwagen %s, prijs %s Product %s, prijs %s @@ -14,12 +74,11 @@ Language: nl Nieuwe bestelling OK + Maak een bestelling aan in het winkelbeheer - Verlaat POS en maak een nieuwe bestelling aan vanuit het tabblad Bestellingen om betalingen te ontvangen voor een niet-simpel product. - Alleen simpele fysieke producten zijn momenteel beschikbaar met POS.\nAndere producttypen, zoals variabel en virtueel, worden in toekomstige updates beschikbaar. + Verlaat POS en maak een nieuwe bestelling aan vanuit het tabblad Bestellingen om betalingen te ontvangen voor een niet-simpel product. Waarom kan ik mijn producten niet zien? Informatie Sluiten - Meer informatie + Meer\u00A0informatie Alleen simpele fysieke producten zijn momenteel compatibel met POS. Andere producttypen, zoals variabel en virtueel, worden in toekomstige updates beschikbaar. Alleen simpele producten weergeven Websiteadres @@ -39,8 +98,6 @@ Language: nl POS ondersteunt momenteel alleen simpele producten – \nmaak er een aan om te beginnen. Geen ondersteunde producten gevonden Geen producten - Laten we wat klanten helpen - Aan de slag Vraag om ondersteuning Verbind je lezer Foto verwijderd @@ -135,8 +192,7 @@ Language: nl Geen regels voor hoeveelheid Bezoekers Annuleren - Afsluiten - Weet je zeker dat je POS wilt afsluiten? + Afsluiten POS afsluiten Verwijder %s uit winkelwagen Afrekenen @@ -419,20 +475,16 @@ Language: nl Slogan Afbeelding wijzigen Toepassen - Begint + Startdatum %1$s dagen - Tijdsduur instellen Impressies reflecteren hoe vaak je advertentie door potentiële klanten te zien is.\n\n\n Door wisselend online verkeer en gebruikersgedrag kunnen er geen exacte getallen gegarandeerd worden maar we proberen er voor te zorgen dat de daadwerkelijke impressies van je advertentie zoveel mogelijk overeenkomen met het doelaantal.\n\n\n Vergeet niet dat het bij impressies draait om zichtbaarheid en niet om de acties die worden ondernomen door de mensen die het zien. Gereed Impressies Updaten Bewerken - Duur Geschatte aantal mensen dat per dag bereikt wordt %1$s per dag gedurende %1$s dagen - Totaal uitgegeven - Hoeveel wil je uitgeven aan de promotiecampagne voor je product? Stel je budget in Alles %1$s dagen vanaf %2$s @@ -634,7 +686,6 @@ Language: nl Boost je verkoopcijfers met Blaze Er is een fout opgetreden bij het vernieuwen van de campagnelijst. Probeer later opnieuw. Mediabron selecteren - Er is een fout opgetreden bij de generatie van een productnaam en -beschrijving. Geen tekst gedetecteerd. Kies een andere foto van de verpakking of voer de productgegevens handmatig in. Product toevoegen Streepjescode scannen @@ -656,9 +707,6 @@ Language: nl Probeer een %s-betaling met je betaal- of creditcard.\nJe zal het terugbetaald krijgen zodra je klaar bent. Het is eenvoudig, veilig en privé. Accepteer alle typen fysieke betalingen, direct\nop je telefoon. Geen extra hardware nodig. - Budget - Klikken - Impressies Afgewezen Voltooid Actief @@ -691,46 +739,23 @@ Language: nl INSTELLINGEN Details handmatig toevoegen met behulp van e-mail We konden de loginaanvraag van je app niet verwerken - Fout bij kopiëren naar klembord. - Productnaam gekopieerd naar klembord. - Productnaam gegenereerd door AI Fout bij kopiëren productbeschrijving naar klembord. Productbeschrijving gekopieerd naar klembord. Productgeneratie mislukt. Probeer het nog eens Verwijder het adres en stop met het gebruiken van dit tarief Stel een nieuw belastingtarief voor deze bestelling Automatisch het belastingtarief toevoegen - Er is een probleem opgetreden tijdens het genereren van de productnaam. Probeer het opnieuw. - Regenereren - Schrijf het voor mij - Vertel ons wat jouw product is en wat dit uniek maakt! - Laat AI pakkende titels voor je genereren - Productnaam Probeer om het ‘ongelezen’ filter uit te schakelen om al je productbeoordelingen te bekijken Geen ongelezen productbeoordelingen - Toon geselecteerd Overtuigend Fleurig Formeel Casual - Stel de toon in om de presentatie van je product af te stemmen op je merk. Toon Details - Productbeschrijving Productnaam - Je kan de onderstaande gegevens later altijd wijzigen. Voorbeeld - Productgegevens aanmaken - Toon instellen - Voeg belangrijke functies, voordelen of details toe om je product beter online vindbaar te laten worden. Bijvoorbeeld Zachte stof, duurzaam stikwerk, uniek ontwerp - Markeer wat je product uniek maakt en laat AI het werk voor je doen. - Over je product - Stel een naam voor - Bijvoorbeeld: Zachte stof, duurzaam stikwerk, uniek ontwerp. - Productnaam - Of breid je keuzes uit door te tikken voor meer naamvoorstellen. - Voeg je productnaam toe Mogelijk gemaakt door AI. <a href=\'guidelines\'><u>Meer informatie</u></a>. Handmatig een product en de details toevoegen Handmatig toevoegen @@ -1054,7 +1079,6 @@ Language: nl Geef je verkopen een boost met speciale aanbiedingen Bekijk je winkel Blijf up-to-date - Start ook met mobiele betalingen Beheer meer bij beheer Algemeen Instellingen diff --git a/WooCommerce/src/main/res/values-pt-rBR/strings.xml b/WooCommerce/src/main/res/values-pt-rBR/strings.xml index 281f6c4bf18..0a564ca41d4 100644 --- a/WooCommerce/src/main/res/values-pt-rBR/strings.xml +++ b/WooCommerce/src/main/res/values-pt-rBR/strings.xml @@ -1,11 +1,71 @@ + Chave inválida: remova o caractere \"_\" do início. + Esta chave já está sendo utilizada por outro campo personalizado.\nO aplicativo atual não aceita criação de chaves duplicadas. Se necessário, use o Painel WP Admin para duplicar uma chave. + Adicionar campos personalizados + Campo personalizado excluído + Falha ao salvar alterações, tente novamente + Alterações salvas + Salvando alterações + Falha na verificação. Tente novamente mais tarde + Parece que você não está conectado à Internet. Verifique se seu Wi-Fi está ligado. Se você estiver usando dados móveis, verifique se a opção está ativada nas configurações do seu dispositivo. + Valor + Chave + Outros tipos de produtos, como variáveis e virtuais, serão disponibilizados em atualizações futuras. + Agora apenas produtos físicos simples podem ser usados nos pontos de venda. + Cancelar + Duração + A campanha continuará ativa até você interrompê-la. + Especificar a duração + até %1$s + Programação + Gasto diário + Quanto você gostaria de gastar na sua campanha e por quanto tempo quer mantê-la ativa? + %1$s ➔ %2$s + Exiba seus produtos para milhões de pessoas com o Blaze e aumente as vendas + Quer aumentar suas vendas? + Erro ao carregar campos personalizados + Campos personalizados + Plano de fundo esmaecido. Toque para ignorar a caixa de diálogo. + %1$s por semana + Executar até que eu interrompa + %1$s por semana começando em %2$s + Semanal + Restante + Total + Taxa de cliques + Parece que seu dispositivo está no modo de economia de bateria. \nNão é possível ver as informações da loja enquanto ele está ativo. + De %1$s em diante + gasto semanal + Menu pop-up com opções. Deslize para navegar pelos itens. + Menu desativado + Menu ativado + O leitor de cartão não está conectado. Toque duas vezes para conectar + Abra menu da barra de ferramentas + Barra de ferramentas com status do leitor do cartão. O menu está aberto. Toque duas vezes para interagir. + Barra de ferramentas com status do leitor do cartão. Toque duas vezes para interagir. + O leitor de cartão está conectado + Plano de fundo escurecido. Toque para fechar o menu. + Ícone de pagamento concluído + Remova esse item do carrinho + Pedidos em andamento serão perdidos. + Sair do modo de ponto de venda? + Plano de fundo escurecido. Toque para ignorar a caixa de diálogo. + Toque duas vezes para ignorar a caixa de diálogo + Caixa de diálogo apenas de produtos simples + Banner apenas de produtos simples + Impulsione suas vendas + Feche + Toque duas vezes para saber mais + Promova seus produtos e aumente as vendas agora mesmo com os anúncios do Blaze. + Aceite pagamentos onde estiver + Um PIN incorreto foi inserido. Tente novamente ou use outro meio de pagamento Menu Produto no carrinho %s. Preço: %s Produto %s. Preço: %s @@ -14,14 +74,13 @@ Language: pt_BR Novo pedido OK + Criar um pedido no gerenciamento da loja - Para processar o pagamento de um produto que não é simples, saia do ponto de venda e faça um novo pedido na guia de pedidos. - Agora apenas produtos físicos simples podem ser usados nos pontos de venda.\nOutros tipos de produtos, como variáveis e virtuais, serão disponibilizados em atualizações futuras. Por que não consigo ver meus produtos? Informação Fechar - Saiba mais Agora apenas produtos físicos simples são compatíveis nos pontos de venda. Outros tipos de produtos, como variáveis e virtuais, serão disponibilizados em atualizações futuras. Mostrando apenas produtos simples + Para processar o pagamento de um produto que não é simples, saia do ponto de venda e faça um novo pedido na guia de pedidos. + Saiba\u00A0mais Endereço do site Google para WooCommerce Adicionar campanha paga @@ -39,12 +98,10 @@ Language: pt_BR Tentar novamente Nenhum produto Nenhum produto compatível encontrado - Vamos atender a alguns clientes Obter suporte Conecte o seu leitor Imagem removida Escaneando imagem - Iniciando Texto da imagem adicionado às informações iniciais Cliques Impressões @@ -135,11 +192,10 @@ Language: pt_BR Impostos Público Cancelar - Sair - Tem certeza de que deseja sair do PDV? Sair do PDV Finalizar compra Remover %s do carrinho + Sair Status do leitor desconhecido Checkout Leitor conectado @@ -418,24 +474,20 @@ Language: pt_BR Descrição Mudar imagem Aplicar - Início %1$s dias - Definir duração As impressões demonstram a frequência com que seu anúncio aparece para possíveis clientes.\n\n\n Apesar de o número exato não poder ser garantido por conta das variações no tráfego online e no comportamento do usuário, nós nos esforçamos para corresponder ao máximo as impressões reais do seu anúncio à contagem desejada.\n\n\n E lembre-se: as impressões têm a ver com a visibilidade, e não com as ações dos visitantes. Concluído Impressões Atualizar Editar - Duração Estimativa de pessoas alcançadas por dia %1$s ao dia por %1$s dias - Total gasto - Quanto você gostaria de gastar na campanha de divulgação do seu produto? Defina seu orçamento Tudo %1$s dias a partir de %2$s Detalhamento + Data inicial Não mostrar novamente Lembrar depois Tem um minuto? Ajude a melhorar nossas funcionalidades assistidas por IA dando um feedback rápido. @@ -634,7 +686,6 @@ Language: pt_BR Impulsione as vendas na sua loja com o Blaze Ocorreu um erro ao atualizar a lista de campanhas. Tente novamente mais tarde. Selecionar fonte de mídia - Ocorreu um erro ao gerar o nome e a descrição do produto. Nenhum texto encontrado. Selecione outra foto de embalagem ou insira os detalhes do produto manualmente. Adicionar produto Escanear código de barras @@ -656,9 +707,6 @@ Language: pt_BR Experimente um pagamento no valor de %s com seu cartão de crédito ou débito.\nEle será reembolsado quando você concluir. É fácil, seguro e confidencial. Aceite todos os tipos de pagamentos presenciais, direto\nno seu telefone. Não é necessário hardware adicional. - Orçamento - Cliques - Impressões Rejeitada Concluída Ativa @@ -691,45 +739,23 @@ Language: pt_BR CONFIGURAÇÕES Adicionar detalhes manualmente usando o e-mail Não foi possível processar sua solicitação de login no app - Erro ao copiar o nome do produto para a área de transferência. - Nome do produto copiado para a área de transferência. - Nome do produto gerado por IA Erro ao copiar a descrição do produto para a área de transferência. Descrição do produto copiada para a área de transferência. Falha ao gerar o produto. Tente novamente Apagar endereço e parar de usar esta tarifa Definir um novo imposto para este pedido Inclusão automática de imposto - Ocorreu um problema ao gerar o nome do produto. Tente novamente. - Gerar novamente - Escreva por mim - Conte para nós qual o seu produto e o que o torna único. - Deixe a IA gerar títulos cativantes para você Tente desativar o filtro de não lidas para ver todas as avaliações de produto - Nome do produto Nenhuma avaliação de produto não lida - Tom selecionado Convincente Rebuscado Formal Casual - Defina o tom e a voz para moldar a apresentação do seu produto de forma que reflita a identidade da sua marca. Tom e voz Detalhes - Descrição do produto Nome do produto - Você pode alterar os detalhes abaixo a qualquer momento. Visualização - Criar detalhes do produto - Definir tom e voz - Adicione as principais funcionalidades, benefícios ou detalhes para que seu produto seja encontrado facilmente online. Por exemplo, tecido macio, costura resistente, design único - Destaque o diferencial do seu produto e espere a magia acontecer com a IA. - Sobre o seu produto - Sugerir um nome - Nome do produto - Se quiser outras opções, toque para ver mais sugestões de nome. - Adicionar o nome do produto Com tecnologia de IA. <a href=\'guidelines\'><u>Saiba mais.</u></a> Adicione um produto e os detalhes manualmente Adicionar manualmente @@ -738,7 +764,6 @@ Language: pt_BR Adicionar um produto Apenas avaliações não lidas Editar configuração de imposto - Por exemplo: tecido macio, costura resistente, design único. Isso não afetará pedidos online Adicionar esta tarifa a todos os pedidos criados Editar impostos @@ -1063,7 +1088,6 @@ Language: pt_BR Produtos agrupados Não agrupados Pacote - Habilite os pagamentos por dispositivo móvel Sem máximo Sem mínimo Grupo de diff --git a/WooCommerce/src/main/res/values-ru/strings.xml b/WooCommerce/src/main/res/values-ru/strings.xml index 02978a3b4a2..c139c1745b3 100644 --- a/WooCommerce/src/main/res/values-ru/strings.xml +++ b/WooCommerce/src/main/res/values-ru/strings.xml @@ -1,11 +1,71 @@ + Недопустимый ключ: удалите символ «_» в самом начале. + Этот ключ уже применяется в другом произвольном поле.\nВ настоящий момент приложение не поддерживает создание дубликатов ключей. При необходимости создать дубликат ключа воспользуйтесь wp-admin. + Добавить произвольные поля + Произвольное поле удалено + Не удалось сохранить изменения. Повторите попытку + Изменения сохранены + Сохранение изменений + Похоже, отсутствует подключение к Интернету. Убедитесь, что ваш Wi-Fi включён. Если вы используете мобильные данные, убедитесь, что они включены в настройках вашего устройства. + Сбой сканирования. Повторите попытку позже + Значение + Ключ + Другие типы товаров, в частности виртуальные и вариативные товары, станут доступны в ближайших обновлениях. + В данный момент в режиме POS поддерживаются только простые материальные товары. + Отмена + Продолжительность + Кампания будет идти до тех пор, пока вы её не остановите. + Указать продолжительность + по %1$s + Расписание + Ежедневные затраты + Сколько вы планируете потратить на кампанию и сколько времени она должна продлиться? + %1$s ➔ %2$s + При помощи Blaze демонстрируйте ваши товары миллионам потенциальных покупателей и повышайте продажи + Думаете о том, как повысить продажи? + Ошибка при загрузке произвольных полей + Произвольные поля + Затемнённый фон. Коснитесь, чтобы закрыть диалог. + %1$s в неделю + Выполнять до остановки мною + Запущена %1$s + еженедельные расходы + %1$s еженедельно начиная с %2$s + Еженедельно + Осталось + Итого + Переходы + По-видимому, ваше устройство находится в режиме экономии энергии. \nПока этот режим активен, сведения о магазине будут недоступны + Всплывающее меню с опциями. Смахивайте товары, чтобы переходить к следующим. + Открыть меню панели инструментов + Панель инструментов со статусом платёжного терминала. Меню открыто. Дважды коснитесь, чтобы начать работу. + Панель инструментов со статусом платёжного терминала. Дважды коснитесь, чтобы начать работу. + Меню отключено + Меню включено + Платёжный терминал не подключён. Дважды коснитесь, чтобы подключить + Платёжный терминал подключён + Затемнённый фон. Коснитесь, чтобы закрыть меню. + Значок «Оплачено» в виде флажка + Удалить товар из корзины + Все заказы, которые находятся в процессе выполнения, будут потеряны. + Выйти из режима «Пункт продажи»? + Закрыть + Затемнённый фон. Коснитесь, чтобы закрыть диалог. + Дважды коснитесь, чтобы закрыть диалог. + Диалог «Только простые товары» + Коснитесь, чтобы узнать подробнее. + Баннер «Только простые товары» + Продвигайте товары при помощи Blaze Ads и повышайте продажи. + Повышайте продажи + Принимайте платежи на ходу + Введён неверный PIN-код. Повторите попытку или попробуйте другое средство платежа Меню Товар в корзине %s, цена %s Товар %s, цена %s @@ -14,8 +74,7 @@ Language: ru Новый заказ ОК + Создать заказ в разделе «Управление магазином» - Чтобы принять платёж за товар, не относящийся к простым, выйдите из POS и создайте новый заказ в таблице заказов. - В данный момент POS поддерживает только простые материальные товары.\nДругие типы товаров, в частности виртуальные и вариативные товары, станут доступны в ближайших обновлениях. + Чтобы принять платёж за товар, не относящийся к простым, выйдите из режима POS и создайте новый заказ в таблице заказов. Почему я не вижу свои товары? Информация Закрыть @@ -39,8 +98,6 @@ Language: ru В данный момент POS поддерживает только простые товары — \nсначала создайте такой товар. Нет поддерживаемых товаров Товаров нет - Давайте обслужим нескольких клиентов - Начало работы Поддержка Подключите платёжный терминал Фотография удалена @@ -135,8 +192,7 @@ Language: ru Нет правил количества Аудитория Отмена - Выход - Вы уверены, что хотите закрыть POS? + Выход Закрыть POS Удалить %s из корзины Оформление заказа @@ -419,20 +475,16 @@ Language: ru Ключевая фраза Изменить изображение Применить - Начало + Дата начала %1$s дн. - Задать продолжительность Раздел «Показы» отражает частоту, с которой ваша реклама появляется на экранах потенциальных клиентов.\n\n\n Достичь этой цифры в точности будет невозможно из-за колебаний посещаемости и различного поведения пользователей, однако мы стремимся к тому, чтобы реальное число показов рекламы максимально приближалось к целевому показателю.\n\n\n Учитывайте, что показы влияют лишь на видимость рекламы, а не на действия читателей. Готово Показы Обновить Изменить - Продолжительность Приблизительный ежедневный охват пользователей %1$s ежедневно на %1$s дн. - Общие расходы - Сколько вы хотите потратить на кампанию по продвижению товара? Настройте бюджет Все %1$s дн. с %2$s @@ -634,7 +686,6 @@ Language: ru Повышайте продажи в своём магазине с помощью Blaze При обновлении списка кампаний произошла ошибка. Повторите попытку позже. Выберите источник медиафайлов - При создании названия и описания товара произошла ошибка. Текст не обнаружен. Выберите другую фотографию упаковки или введите сведения о товаре вручную. Добавить товар Сканировать штрихкод @@ -656,9 +707,6 @@ Language: ru Попробуйте оплатить %s банковской картой.\nПосле этого средства будут возвращены. Просто, безопасно и конфиденциально. Принимайте все виды очных платежей прямо\nна вашем телефоне. Дополнительное оборудование не требуется. - Бюджет - Нажатий - Показов Отклонено Завершено Активно @@ -691,46 +739,23 @@ Language: ru НАСТРОЙКИ Добавить сведения вручную, используя электронный адрес Не удалось обработать запрос на вход в приложение - Ошибка при копировании названия товара в буфер обмена - Название товара скопировано в буфер обмена. - Название товара создано при помощи ИИ Ошибка при копировании описания товара в буфер обмена. Описание товара скопировано в буфер обмена. При создании товара возникла ошибка. Повторите попытку Очистить адрес и больше не использовать эту ставку Установить другую налоговую ставку для этого заказа Автоматическое добавление налоговой ставки - Возникла проблема при создании названия товара. Повторите попытку. - Создать повторно - Напишите это за меня - Расскажите о своём товаре и его отличительных качествах! - С помощью ИИ вы можете создавать привлекательные заголовки - Название товара Попробуйте отключить фильтр «Непрочитанные», чтобы просмотреть все отзывы о товарах Нет непрочитанных отзывов о товарах - Выбранный тон Убедительный Цветистый Строгий Непринужденный - Задайте тон и стиль презентации, чтобы она соответствовала характеру вашего бренда. Тон и стиль Сведения - Описание товара Название товара - Приведённые ниже сведения можно будет отредактировать в любой момент. Предварительный просмотр - Создание сведений о товаре - Задайте тон и стиль - Добавьте ключевые характеристики, преимущества и подробные сведения, чтобы покупателям проще было найти ваш товар в Интернете. Например: мягкая ткань, прочные швы, уникальный дизайн - Укажите отличительные качества вашего товара, и ИИ сделает за вас всё остальное. - Информация о товаре - Предложите название товара - Например: мягкая ткань, прочные швы, уникальный дизайн - Название товара - Можно также расширить выбор: нажмите, чтобы увидеть больше вариантов. - Добавить название товара На основе ИИ. <a href=\'guidelines\'><u>Подробнее</u></a>. Добавьте товар и сведения вручную Добавить вручную @@ -1054,7 +1079,6 @@ Language: ru Увеличивайте продажи с помощью специальных предложений Просматривайте магазин Следите за новостями - Подключите мобильные платежи Управляйте магазином через консоль Общее Настройки diff --git a/WooCommerce/src/main/res/values-sv/strings.xml b/WooCommerce/src/main/res/values-sv/strings.xml index 2f669be4973..faac13ff50a 100644 --- a/WooCommerce/src/main/res/values-sv/strings.xml +++ b/WooCommerce/src/main/res/values-sv/strings.xml @@ -1,11 +1,71 @@ + Ogiltig nyckel: ta bort tecknet \"_\" från början. + Nyckeln används redan för ett annat anpassat fält.\nAppen har för närvarande inte stöd för att skapa dubbletter av nycklar. Använd WP-admin för att duplicera en nyckel om det behövs. + Lägg till anpassade fält + Anpassat fält borttaget + Skanning misslyckades. Försök igen senare + Ändringar sparade + Sparar ändringar + Misslyckades att spara ändringar, försök igen + Det verkar som att du inte är ansluten till internet. Kontrollera att ditt Wi-Fi är på. Se till att mobildata är aktiverat i dina enhetsinställningar om du använder detta. + Värde + Nyckel + Andra produkttyper, till exempel rörliga och virtuella, kommer att bli tillgängliga i framtida uppdateringar. + Avbryt + Varaktighet + Kampanj kommer köras tills du stoppar den. + Specificera varaktigheten + Endast enkla fysiska produkter kan användas med POS just nu. + till %1$s + Schema + Dagligt belopp + %1$s ➔ %2$s + Hur mycket vill du spendera på din kampanj, och hur länge ska den köras? + Visa upp dina produkter för miljontals människor med Blaze och öka din försäljning + Funderar du på hur du kan öka din försäljning? + Det gick inte att läsa in anpassade fält + Anpassade fält + Nedtonad bakgrund. Tryck för att avfärda dialogrutan. + %1$s per vecka + Kör tills jag stoppar den + %1$s per vecka, från och med %2$s + Per vecka + Återstående + Totalt + Klick + Det verkar som att din enhet är i strömsparläge. \nVI kan inte tillhandahålla din butiksinformation medan det är aktiverat + Pågående från %1$s + veckobelopp + Meny inaktiverad + Meny aktiverad + Kortläsare ansluten + Kortläsare inte ansluten. Dubbeltryck för att ansluta + Popup-meny med alternativ. Svep för att navigera bland objekt. + Öppna verktygsfältsmeny + Verktygsfält med kortläsarstatus. Dubbeltryck för att interagera. + Verktygsfält med kortläsarstatus. Menyn är öppen. Dubbeltryck för att interagera. + Nedtonad bakgrund. Tryck för att stänga menyn. + Bockmarkeringsikon för lyckad betalning + Ta bort den här varan från varukorgen + Dubbeltryck för att lära dig mer + Stäng + Öka din försäljning + Eventuella pågående beställningar kommer att gå förlorade. + Lämna Försäljningsplatsläge? + Nedtonad bakgrund. Tryck för att avfärda dialogrutan. + Dubbeltryck för att avfärda dialogrutan + Dialogruta – endast enkla produkter + Banner – endast enkla produkter + Marknadsför dina produkter med Blaze-annonser och öka din försäljning nu. + Ta emot betalningar i farten + En felaktig PIN-kod har angetts. Försök igen eller använd en annan betalningsmetod Meny Produkt i varukorg %s, pris %s Produkt %s, pris %s @@ -17,11 +77,10 @@ Language: sv_SE Varför kan jag inte se mina produkter? Info Stäng - Lär dig mer - För att ta betalt för en icke-enkel produkt, lämna POS och skapa en ny beställning från fliken Beställningar. - Endast enkla fysiska produkter kan användas med POS just nu.\nAndra produkttyper, till exempel rörliga och virtuella, kommer att bli tillgängliga i framtida uppdateringar. Endast enkla fysiska produkter är kompatibla med POS just nu. Andra produkttyper, till exempel rörliga och virtuella, kommer att bli tillgängliga i framtida uppdateringar. Visar endast enkla produkter + För att ta betalt för en icke-enkel produkt, lämna POS och skapa en ny beställning från fliken Beställningar. + Läs mer Webbplatsadress Lägg till betald kampanj Google för WooCommerce @@ -38,9 +97,7 @@ Language: sv_SE POS stöder för närvarande bara enkla produkter POS stöder för närvarande bara enkla produkter – \nskapa en för att komma igång. Inga produkter - Låt oss betjäna några kunder Inga produkter som stöds hittades - Startar upp Skaffa support Anslut din läsare Foto borttaget @@ -135,8 +192,7 @@ Language: sv_SE Momser Målgrupp Avbryt - Avsluta - Är du säker på att du vill avsluta POS? + Avsluta Avsluta POS Kassa Ta bort %s från varukorg @@ -418,24 +474,20 @@ Language: sv_SE Ändra bild Tillämpa %1$s dagar - Ställ in varaktighet Klar Uppdatera Redigera - Varaktighet Ställ in din budget i %1$s dagar - Totalt spenderat %1$s dagar från %2$s Alla %d tecken återstår - Startar %1$s dagligen - Hur mycket skulle du vilja spendera på din produktmarknadsföringskampanj? Föreslaget av AI + Visningar återspeglar hur ofta din annons visas för potentiella kunder.\n\n\n Exakta siffror kan inte garanteras på grund av fluktuerande onlinetrafik och användarbeteende, men vi strävar efter att annonsens faktiska antal visningar ska ligga så nära ditt målantal som möjligt.\n\n\n Kom ihåg att visningar handlar om synlighet, inte om åtgärder som vidtas av tittarna. Visningar Uppskattat antal personer som nås per dag - Visningar återspeglar hur ofta din annons visas för potentiella kunder.\n\n\n Exakta siffror kan inte garanteras på grund av fluktuerande onlinetrafik och användarbeteende, men vi strävar efter att annonsens faktiska antal visningar ska ligga så nära ditt målantal som möjligt.\n\n\n Kom ihåg att visningar handlar om synlighet, inte om åtgärder som vidtas av tittarna. + Startdatum Visa det inte igen Påminn mig senare Har du tid en minut? Hjälp oss att förbättra våra AI-assisterade funktioner genom lite snabb feedback. @@ -636,7 +688,6 @@ Language: sv_SE Få mer försäljning i din butik med Blaze Marknadsför din produkt på bara några minuter. Välj mediekälla - Ett fel uppstod vid generering av produktnamn och beskrivning. Ingen text upptäckt. Välj ett annat förpackningsfoto eller ange produktinformation manuellt. Lägg till produkt Det uppstod ett fel vid uppdateringen av listan över kampanjer. Försök igen senare. @@ -656,12 +707,9 @@ Language: sv_SE Prova att genomföra en %s-betalning med ditt betal- eller kreditkort.\nBetalningen kommer att återbetalas när du är klar. Det är enkelt, säkert och privat. Ta emot alla typer av personliga betalningar, direkt\ni din telefon. Ingen extra hårdvara behövs. - Budget - Klick Aktiv Under granskning Skapa kampanj - Visningar Avvisad Har slutförts Öka synligheten och få dina produkter sålda snabbt. @@ -691,52 +739,29 @@ Language: sv_SE INSTÄLLNINGAR Lägg till uppgifter manuellt via e-post Vi kunde inte behandla din begäran om inloggning i appen - Det gick inte att kopiera produktnamnet till urklipp. - Produktnamn kopierat till urklipp. - Produktnamn genererat av AI Det gick inte att kopiera produktbeskrivningen till urklipp. Produktbeskrivning kopierad till urklipp. Produktgenereringen misslyckades Försök igen - Återskapa - Skriv det åt mig - Berätta vad din produkt är och vad som gör den unik! - Låt AI generera fängslande rubriker åt dig Rensa adressen och sluta använda den här momssatsen Ange en ny momssats för den här beställningen Lägg till momssats automatiskt - Det uppstod ett problem när produktnamnet skulle genereras. Försök igen. Prova att inaktivera filtret för olästa produktrecensioner för att se alla dina produktrecensioner - Produktnamn Övertygande Formell Detaljer - Produktbeskrivning Produktnamn Inga olästa produktrecensioner - Ton vald Blommig Avslappnad - Ställ in ton och röst för att forma presentationen av din produkt så att den passar ditt varumärke. Ton och röst - Du kan alltid ändra detaljerna nedan senare. Förhandsgranska - Skapa produktdetaljer - Ställ in ton och röst - Lägg till viktiga funktioner, fördelar eller information som hjälper kunder att hitta din produkt online. Exempelvis mjukt tyg, slitstarka sömmar, unik design - Om din produkt - Föreslå ett namn - Produktnamn - Lägg till ditt produktnamn Drivs med AI. <a href=\'guidelines\'><u>Lär dig mer</u></a>. Lägg till en produkt och detaljerna manuellt Lägg till manuellt Skapa en produkt med AI Lägg till en produkt Endast olästa recensioner - Framhäv vad som gör din produkt unik och låt AI utöva sin magi. - Exempelvis mjukt tyg, slitstarka sömmar, unik design. - Alternativt kan du utöka dina alternativ genom att trycka för fler namnförslag. Snabbgenerera information åt dig Redigera momssatsinställning Betalningsmetoder @@ -1059,7 +1084,6 @@ Language: sv_SE %d produkter 1 produkt Paket - Skaffa mobila betalningsmöjligheter Hantera mer på admin Du kan redigera paketprodukter i webbadminpanelen. Paketprodukter diff --git a/WooCommerce/src/main/res/values-tr/strings.xml b/WooCommerce/src/main/res/values-tr/strings.xml index d2991db13c0..facd36b99b7 100644 --- a/WooCommerce/src/main/res/values-tr/strings.xml +++ b/WooCommerce/src/main/res/values-tr/strings.xml @@ -1,11 +1,71 @@ + Geçersiz anahtar: Lütfen en baştaki \"_\" karakterini kaldırın. + Bu anahtar zaten başka bir özel alan için kullanılıyor.\nUygulama şu anda yinelenen anahtar oluşturmayı desteklemiyor. Lütfen gerekirse bir anahtarı yinelemek için wp yöneticisini kullanın. + Özel alanlar ekle + Özel Alan silindi + Değişiklikler kaydedilemedi, lütfen yeniden deneyin + Değişiklikler kaydedildi + Değişiklikler kaydediliyor + Görünüşe göre internete bağlı değilsiniz. Wi-Fi ağınızın açık olduğundan emin olun. Mobil veri kullanıyorsanız cihazınızın ayarlarında etkinleştirildiğinden emin olun. + Tarama başarısız. Lütfen daha sonra tekrar deneyin + Değer + Anahtar + Değişken ve sanal gibi diğer ürün türleri gelecekteki güncellemelerde kullanılabilir olacak. + Şu anda POS ile yalnızca basit fiziksel ürünler kullanılabiliyor. + İptal Et + Süre + Kampanya siz durdurana kadar çalışır. + Süreyi belirle + %1$s öğesine + Zamanla + Günlük harcama + Kampanyanızda ne kadar harcama yapmak istersiniz ve bu ne kadar süre çalışmalı? + %1$s ➔ %2$s + Blaze sayesinde ürünlerinizin milyonlarca kişi tarafından görülmesini sağlayın ve satışlarınızı artırın + Satışlarınızı artırmayı mı düşünüyorsunuz? + Özel alanlar yüklenirken hata oluştu + Özel Alanlar + Soluk arkaplan. Pencereyi kapatmak için dokunun. + %1$s haftalık + Ben durdurana kadar çalıştır + %1$s tarihinden itibaren devam eden + haftalık harcama + %2$s tarihinden itibaren haftalık %1$s + Haftalık + Kalan + Toplam + Tıklanmalar + Görünüşe göre cihazınız Pil Koruyucu modunda. \nBu etkinken mağaza bilgilerinizi sağlayamıyoruz + Seçenekleri olan açılır pencere menüsü. Öğeler arasında gezinmek için kaydırın. + Araç çubuğu menüsünü aç + Kart okuyucu durumu olan araç çubuğu. Menü açık. Etkileşime girmek için iki kez dokunun. + Kart okuyucu durumu olan araç çubuğu. Etkileşime girmek için iki kez dokunun. + Menü Devre Dışı Bırakıldı + Menü Etkinleştirildi + Kart okuyucu bağlanmadı. Bağlanmak için çift dokunun + Kart okuyucu bağlandı + Soluk arkaplan. Menüyü kapatmak için dokunun. + Ödeme başarılı onay işareti simgesi + Bu ürünü sepetten çıkarın + Devam eden tüm siparişler kaybolacak. + Satış Noktası Modundan Çıkılsın Mı? + Kapat + Soluk arkaplan. Pencereyi kapatmak için dokunun. + Pencereyi kapatmak için iki kez dokunun + Yalnızca basit ürünler penceresi + Daha fazla bilgi edinmek için iki kez dokunun + Yalnızca basit ürünler başlığı + Blaze Reklamları ile hemen ürünlerinizi tanıtın ve satışlarınızı artırın. + Satışlarınızı güçlendirin + Hareket halindeyken ödeme alın + Yanlış PIN girildi. Yeniden deneyin veya başka bir ödeme yöntemi kullanın Menü Sepetteki ürün %s, Fiyatı %s Ürün %s, Fiyatı %s @@ -14,12 +74,11 @@ Language: tr Yeni sipariş Tamam + Mağaza yönetiminde sipariş oluşturun - Temel olmayan bir ürünün ödemesini almak için POS\'tan çıkın ve siparişler sekmesinden yeni sipariş oluşturun. - Şu anda POS ile yalnızca basit fiziksel ürünler kullanılabiliyor.\nDeğişken ve sanal gibi diğer ürün türleri gelecekteki güncellemelerde kullanılabilir olacak. + Temel olmayan bir ürünün ödemesini almak için POS\'tan çıkın ve siparişler sekmesinden yeni sipariş oluşturun. Ürünlerimi neden göremiyorum? Bilgi Kapat - Daha fazla bilgi edinin + Daha fazla\u00A0bilgi edinin Şu anda POS ile yalnızca basit fiziksel ürünler uyumlu. Değişken ve sanal gibi diğer ürün türleri gelecekteki güncellemelerde kullanılabilir olacak. Yalnızca basit ürünler gösteriliyor Site Adresi @@ -39,8 +98,6 @@ Language: tr POS şu anda yalnızca basit ürünleri destekliyor, \nbaşlamak için bir tane oluşturun. Desteklenen ürün bulunamadı Ürün yok - Birkaç müşteriye hizmet edelim - Başlatılıyor Destek Alın Okuyucunuzu bağlayın Fotoğraf kaldırıldı @@ -135,8 +192,7 @@ Language: tr Miktar Kuralı Yok Hedef Kitle İptal Et - Çıkış - POS\'tan çıkmak istediğinizden emin misiniz? + Çık POS\'tan çık %s ürününü sepetten çıkar Ödeme @@ -419,20 +475,16 @@ Language: tr Site sloganı Görseli değiştir Uygula - Başlangıç Tarihi + Başlangıç tarihi %1$s gün - Süre ayarlayın İzlenimler, reklamlarınızın olası müşterilere gözükme sıklığını yansıtır.\n\n\n Dalgalı çevrimiçi trafik ve kullanıcı davranışından dolayı kesin sayılardan emin olunamasa da reklamınızın asıl izlenimlerini hedef sayıya olabildiğince yakın bir şekilde eşleştirmeye çalışıyoruz.\n\n\n İzlenimlerin görüntüleyiciler tarafından gerçekleştirilen işlemler yerine görünürlük hakkında olduğunu unutmayın. Tamam İzlenimler Güncelle Düzenle - Süre Gün başına ulaşılan tahmini insan sayısı %1$s günlük %1$s gün için - Toplam harcama - Ürün tanıtım kampanyanıza ne kadar harcamak istiyorsunuz? Bütçenizi oluşturun Tümü %2$s tarihinden itibaren %1$s gün @@ -634,7 +686,6 @@ Language: tr Blaze ile mağazanızda daha fazla satış yapın Kampanya listesi yenilenirken bir hata oluştu. Lütfen daha sonra tekrar deneyin. Ortam Kaynağı Seçin - Ürün adı ve açıklaması oluşturulurken bir hata oluştu. Metin algılanmadı. Lütfen başka bir ambalaj fotoğrafı seçin veya ürün ayrıntılarını manuel olarak girin. Ürün ekleyin Barkodu tarayın @@ -656,9 +707,6 @@ Language: tr Banka veya kredi kartınızla bir %s ödemesi yapmayı deneyin.\nÖdeme, işiniz bittiğinde iade edilir. Kolay, güvenli ve gizlidir. Doğrudan telefonunuzdan her türde şahsen ödemeyi\nkabul edin. Ekstra donanım gerektirmez. - Bütçe - Tıklamalar - İzlenimler Reddedildi Tamamlandı Etkin @@ -691,46 +739,23 @@ Language: tr AYARLAR Ayrıntıları e-posta kullanarak el ile ekle Uygulamada oturum açma isteğinizi işleme koyamadık - Ürün adını panoya kopyalamada hata. - Ürün adı panoya kopyalandı. - Ürün adı Yapay Zeka tarafından oluşturuldu Ürün açıklamasını panoya kopyalamada hata. Ürün açıklaması panoya kopyalandı. Ürün oluşturma başarısız. Lütfen tekrar deneyin Adresi temizleyin ve bu oranı kullanmayı bırakın Bu sipariş için yeni bir vergi oranı belirleyin Vergi oranını otomatik olarak ekleme - Ürün adı oluşturulurken bir sorun oluştu. Lütfen tekrar deneyin. - Yeniden oluştur - Benim için yaz - Bize ürününüzden ve onu benzersiz kılanın ne olduğundan bahsedin! - Yapay zekanın sizin için etkileyici başlıklar oluşturmasına izin verin - Ürün adı Tüm ürün incelemelerinizi görmek için okunmamış filtresini devre dışı bırakmayı deneyin Okunmamış ürün incelemesi yok - Ton seçildi İkna edici Çiçekli Resmi Gelişigüzel - Ürününüzün sunumunu markanızla uyumlu biçimde şekillendirmek için tonu ve sesi belirleyin. Ton ve ses Ayrıntılar - Ürün açıklaması Ürün adı - Aşağıdaki ayrıntıları daha sonra her zaman değiştirebilirsiniz. Önizleme - Ürünler Ayrıntıları Oluştur - Ton ve sesi ayarla - Ürününüzün internette bulunmasına yardımcı olmak için temel özellikler, avantajlar veya ayrıntılar ekleyin. Örneğin, yumuşak kumaş, dayanıklı dikiş, benzersiz tasarım - Ürününüzü benzersiz kılan özellikleri vurgulayın ve yapay zekanın işini yapmasına izin verin. - Ürününüz hakkında - Bir ad öner - Örnek: Yumuşak kumaş, dayanıklı dikiş, benzersiz tasarım. - Ürün adı - Ya da daha fazla isim önerisi için dokunarak seçeneklerinizi genişletin. - Ürün adınızı ekleyin Yapay zeka destekli. <a href=\'guidelines\'><u>Daha fazla bilgi edinin</u></a>. Bir ürünü ve ayrıntılarını manuel olarak ekleyin El ile ekle @@ -1054,7 +1079,6 @@ Language: tr Özel tekliflerle satışları artırın Mağazanızı görüntüleme Güncel kalın - Mobil ödemelere katılın Yöneticide daha fazlasını yönetin Genel Ayarlar diff --git a/WooCommerce/src/main/res/values-zh-rCN/strings.xml b/WooCommerce/src/main/res/values-zh-rCN/strings.xml index a2a4dd9aa38..6775b095578 100644 --- a/WooCommerce/src/main/res/values-zh-rCN/strings.xml +++ b/WooCommerce/src/main/res/values-zh-rCN/strings.xml @@ -1,11 +1,71 @@ + 密钥无效:请删除开头的“_”字符。 + 此密钥已被用于另一个自定义字段。\n该应用程序暂不支持创建重复密钥。 如果需要,请使用 wp-admin 复制密钥。 + 添加自定义字段 + 自定义字段已删除 + 更改保存失败,请重试 + 更改已保存 + 正在保存更改 + 您似乎未连接到互联网。 请确保您的 Wi-Fi 已打开。 如果您要使用移动数据,请确保已在设备设置中将其启用。 + 扫描失败。 请稍后再试 + 字段值 + 密钥 + 其他产品类型(如可变产品和虚拟产品)将在今后的更新中得到支持。 + POS 目前仅可用于简单实体产品。 + 取消 + 时长 + 活动将持续进行,直到您手动将其停止。 + 指定时长 + 至 %1$s + 安排 + 每日支出 + 您希望在广告活动上投入多少资金?活动应持续多长时间? + %1$s ➔ %2$s + 通过 Blaze 让数百万人看到您的产品,提高您的销售额 + 想要提高您的销售额? + 加载自定义字段时出错 + 自定义字段 + 背景变暗。 轻点即可关闭对话框。 + 每周 %1$s + 在手动停止前保持运行 + 从 %1$s 起持续进行 + 每周支出 + 从 %2$s 起每周 %1$s + 每周 + 剩余 + 总计 + 点击率 + 您的设备似乎处于省电模式。 \n当您的商店处于活动状态时,我们无法提供其相关信息 + 带选项的弹出菜单。 轻扫即可浏览商品。 + 打开工具栏菜单 + 显示读卡器状态的工具栏。 菜单已打开。 轻点两下即可进行交互。 + 显示读卡器状态的工具栏。 轻点两下即可进行交互。 + 菜单已禁用 + 菜单已启用 + 读卡器未连接。 轻点两下即可连接 + 读卡器已连接 + 背景变暗。 轻点即可关闭菜单。 + 付款成功复选标记图标 + 将此商品从购物车中移除 + 任何进行中的订单都将丢失。 + 退出销售点模式? + 关闭 + 背景变暗。 轻点即可关闭对话框。 + 轻点两下即可关闭对话框 + 仅限简单产品的对话框 + 轻点两下即可了解更多信息 + 仅限简单产品的横幅 + 立即使用 Blaze Ads 推广您的产品并提高销售额。 + 提高您的销售额 + 随时随地接受付款 + 输入的 PIN 错误。 再次尝试,或使用另一种付款方式 菜单 购物车中的产品 %s,价格 %s 产品 %s,价格 %s @@ -14,8 +74,7 @@ Language: zh_CN 新订单 确定 + 在商店管理中创建订单 - 要接受非简单产品的付款,请退出 POS,然后在订单选项卡中创建新订单。 - POS 目前仅可用于简单实体产品。\n其他产品类型(如可变产品和虚拟产品)将在今后的更新中得到支持。 + 要接受非简单产品的付款,请退出 POS,然后在订单选项卡中创建新订单。 为何看不到我的产品? 信息 关闭 @@ -39,8 +98,6 @@ Language: zh_CN POS 目前仅支持简单产品 – \n创建一个以开始。 未找到受支持的产品 无任何产品 - 让我们为客户服务 - 开始 获取支持 连接您的读卡器 照片已删除 @@ -135,8 +192,7 @@ Language: zh_CN 无数量规则 受众 取消 - 退出 - 是否确定要退出 POS 机? + 退出 退出 POS 机 将 %s 从购物车中移除 结账 @@ -419,20 +475,16 @@ Language: zh_CN 标语 更换图片 应用 - 开始 + 开始日期 %1$s 天 - 设置时长 展示次数反映了您的广告出现在潜在客户面前的频率。\n\n\n 由于在线流量的波动和用户行为的差异,我们无法确保确切的数字,但我们将致力于使您广告的实际展示次数尽可能接近目标次数。\n\n\n 请记住,展示次数关乎可见性,而非查看者的操作。 完成 展示次数 更新 编辑 - 时长 每天的预计受众人数 %1$s 每天 共计 %1$s 天 - 总支出 - 您愿意在产品推广活动上花费多少资金? 设置您的预算 所有 从 %2$s 起 %1$s 天 @@ -634,7 +686,6 @@ Language: zh_CN 通过 Blaze 提高您商店的销售额 刷新广告活动列表时出错。 请稍后重试。 选择媒体来源 - 生成产品名称和描述时出错。 未检测到文本。 请选择其他包装照片或手动输入产品详细信息。 添加产品 扫描条形码 @@ -656,9 +707,6 @@ Language: zh_CN 尝试通过借记卡或信用卡完成一次 %s 付款。\n完成后将退款。 整个过程简单、安全且私密。 您可以直接在手机上\n接受所有类型的现场付款。 无需额外的硬件。 - 预算 - 点击次数 - 展示次数 已拒绝 已完成 已启用 @@ -691,46 +739,23 @@ Language: zh_CN 设置 使用电子邮件手动添加详情 我们无法处理您的应用程序登录请求 - 复制产品名称到剪贴板时出错。 - 产品名称已复制到剪贴板。 - AI 生成的产品名称 复制产品描述到剪贴板时出错。 产品描述已复制到剪贴板。 产品生成失败。 请重试 清除地址并停止使用该费率 为该订单设置新税率 自动添加税率 - 生成产品名称时出现问题。 请重试。 - 重新生成 - 为我写作 - 介绍您的产品及其独特之处! - 让 AI 为您生成有吸引力的标题 - 产品名称 尝试禁用未读过滤器,以查看所有产品评论 无未读产品评论 - 所选语调 自信 华丽 正式 随意 - 设置语调和声音,确保产品展现形式符合品牌调性。 语调和声音 详情 - 产品描述 产品名称 - 您可以随时更改下方的详细信息。 预览 - 创建产品详情 - 设置语调和声音 - 添加关键功能、优势或详情,提高产品在网上的曝光率。 例如,柔软的面料、结实的缝线、独特的设计 - 突出显示您产品的独特之处,让 AI 创造奇迹。 - 关于您的产品 - 提供名称建议 - 例如:柔软的面料、结实的缝线、独特的设计。 - 产品名称 - 或者可以轻点以获得更多名称建议,扩大您的选择范围。 - 添加产品名称 由 AI 提供支持。 <a href=\'guidelines\'><u>了解更多</u></a>。 手动添加产品和详情 手动添加 @@ -1054,7 +1079,6 @@ Language: zh_CN 使用特别优惠增加销售额 查看商店 保持更新 - 加入移动付款功能 在“管理员”上管理更多内容 常规 设置 diff --git a/WooCommerce/src/main/res/values-zh-rTW/strings.xml b/WooCommerce/src/main/res/values-zh-rTW/strings.xml index e88dc78a949..eec91024a13 100644 --- a/WooCommerce/src/main/res/values-zh-rTW/strings.xml +++ b/WooCommerce/src/main/res/values-zh-rTW/strings.xml @@ -1,11 +1,71 @@ + 無效的金鑰:請移除開頭的「_」字元。 + 此金鑰已用於其他自訂欄位。\n應用程式目前不支援建立重複的金鑰。 必要時請使用 wp-admin 複製金鑰。 + 新增自訂欄位 + 自訂欄位已刪除 + 無法儲存變更,請再試一次 + 變更已儲存 + 正在儲存變更 + 你目前沒有網際網路連線。 請確認 Wi-Fi 已開啟。 如果你使用行動數據,請確認已在裝置設定中啟用。 + 掃描失敗。 請稍後再試一次 + + 金鑰 + 待未來更新後,將可提供多款式與虛擬商品等其他商品種類的資訊。 + 目前只有簡單實體商品能使用 POS。 + 取消 + 時間長度 + 行銷活動會持續執行,直到你停止為止。 + 指定期間 + 至 %1$s + 排程 + 每日花費 + 你想要在行銷活動上花費多少費用,希望活動持續多長時間? + %1$s ➔ %2$s + 運用 Blaze 讓數百萬使用者看見你的商品,並強化銷售表現 + 想要強化銷售表現嗎? + 載入自訂欄位時發生錯誤 + 自訂欄位 + 背景已調暗。 點選以關閉對話方塊。 + 每週 %1$s + 持續執行,直到我停止為止 + 從 %1$s起持續進行 + 每週花費 + 從 %2$s起,每週 %1$s + 每週 + 剩餘 + 總計 + 點擊率 + 看來你的裝置現已開啟省電模式。 \n我們無法在該模式啟用時提供商店資訊 + 附選項的快顯選單。 滑動以導覽項目。 + 開啟工具列選單 + 顯示讀卡機狀態的工具列。 選單已開啟。 點選兩下以互動。 + 顯示讀卡機狀態的工具列。 點選兩下以互動。 + 選單已停用 + 選單已啟用 + 讀卡機未連結。 點選兩下以連結 + 讀卡機已連結 + 背景已調暗。 點選以關閉選單。 + 付款成功核取記號圖示 + 從購物車將此商品移除 + 任何進行中的訂單將會遺失。 + 要結束銷售時點情報系統模式嗎? + 關閉 + 背景已調暗。 點選以關閉對話方塊。 + 點選兩下以關閉對話框 + 僅限簡單商品的對話框 + 點選兩下以深入了解 + 僅限簡單商品的橫幅 + 運用 Blaze 廣告宣傳商品,立即提升銷售表現。 + 強化銷售表現 + 隨時隨地輕鬆收款 + 輸入的 PIN 碼不正確。 請再試一次,或使用其他付款方式 選單 購物車「%s」中的商品,價格為 %s 商品「%s」的價格為 %s @@ -14,8 +74,7 @@ Language: zh_TW 新訂單 確定 + 在商店管理建立訂單 - 若要針對簡單商品以外的品項收款,請將 POS 結束,然後從訂單分頁建立新訂單。 - 目前只有簡單實體商品能使用 POS。\n待未來更新後,將可提供多款式與虛擬商品等其他商品種類的資訊。 + 若要針對簡單商品以外的品項收款,請將 POS 結束,然後從訂單分頁建立新訂單。 為什麼我無法查看自己的商品? 資訊 關閉 @@ -39,8 +98,6 @@ Language: zh_TW POS 目前僅支援簡單商品 – \n建立商品後便可開始建立行銷活動。 找不到支援的商品 沒有商品 - 開始為客戶提供服務 - 開始 取得支援 連線至你的讀卡機 已移除相片 @@ -135,8 +192,7 @@ Language: zh_TW 沒有數量規則 受眾 取消 - 結束 - 你確定要結束 POS 嗎? + 結束 結束 POS 從購物車移除「%s」 結帳 @@ -419,20 +475,16 @@ Language: zh_TW 標語 變更圖片 套用 - 開始時間 + 開始日期 %1$s 天 - 設定期間 曝光數反映了廣告向潛在顧客顯示的頻率。\n\n\n 因為線上流量和使用者行為會波動,無法確定確切數字,但我們會致力讓廣告的實際曝光數盡可能符合你的目標次數。\n\n\n 切記,曝光數與能見度有關,而非觀眾採取的行動。 完成 曝光數 更新 編輯 - 時間長度 預估每天觸及的人數 每日 %1$s %1$s 天 - 總預算 - 你打算在商品推廣行銷活動上花費多少預算? 設定預算 全部 從 %2$s起共 %1$s 天 @@ -634,7 +686,6 @@ Language: zh_TW 使用 Blaze 為你的商店帶動更多銷售業績 There was an error refreshing the list of campaigns. 請稍後再試。 選取媒體來源 - An error occurred while generating product name & description. 未偵測到文字。 請選取另一張包裹照片或手動輸入產品詳細資訊。 新增商品 掃瞄條碼 @@ -656,9 +707,6 @@ Language: zh_TW 嘗試使用簽帳金融卡或信用卡支付 %s 款項。\n完成後就會退款。 簡單、安全且私密。 接受所有類型的親自收款,\n全都能在手機上完成。 無須使用額外硬體。 - 預算 - 點擊數 - 曝光數 已拒絕 已完成 已啟用 @@ -691,46 +739,23 @@ Language: zh_TW 設定 使用電子郵件手動新增詳情 我們無法處理你的應用程式登入要求 - 複製商品名稱到剪貼簿時發生錯誤。 - 商品名稱已複製到剪貼簿。 - AI 產生的商品名稱 複製商品說明到剪貼簿時發生錯誤。 商品說明已複製到剪貼簿。 商品產生失敗。 請再試一次 清除地址並停止使用此稅率 為此訂單設定新稅率 自動新增稅率 - 產生產品名稱時發生問題, 請再試一次。 - 重新產生 - 幫我撰寫 - 介紹你的產品與其獨特之處! - 讓 AI 為你產生吸引人的名稱 - 產品名稱 嘗試停用「未讀」篩選條件,便可看到所有產品評論 沒有未讀的產品評論 - 已選取語氣 具有說服力 誇飾華麗 正式 休閒 - 設定語氣和風格,讓產品反映品牌形象。 語氣和風格 詳細資料 - 產品說明 產品名稱 - 稍後也可隨時變更下方詳細資料。 預覽 - 建立產品詳細資料 - 設定語氣和風格 - 新增主要功能、優點或詳細資料,可以讓人更容易在網路上找到你的產品。 例如布料柔軟、強固縫線、獨特設計 - 列出產品獨特之處,AI 便能產生絕佳內容。 - 產品相關資訊 - 建議名稱 - 例如布料柔軟、強固縫線、獨特設計 - 產品名稱 - 或點選更多名稱建議,查看更多選項。 - 新增產品名稱 AI 技術支援。 <a href=\'guidelines\'><u>深入瞭解</u></a>。 手動新增產品和詳細資料 手動新增 @@ -1054,7 +1079,6 @@ Language: zh_TW 透過特價提高銷售額 檢視你的商店 掌握最新資訊 - 加入行動付款 在管理員上管理更多功能 一般 設定 diff --git a/WooCommerce/src/main/res/values/colors_base.xml b/WooCommerce/src/main/res/values/colors_base.xml index 26cf9727e7b..8589722e4fe 100644 --- a/WooCommerce/src/main/res/values/colors_base.xml +++ b/WooCommerce/src/main/res/values/colors_base.xml @@ -195,7 +195,6 @@ - @color/color_secondary @color/jetpack_green_40 @color/woo_purple_50 diff --git a/WooCommerce/src/main/res/values/strings.xml b/WooCommerce/src/main/res/values/strings.xml index 50eebb0073c..db805f6d5fa 100644 --- a/WooCommerce/src/main/res/values/strings.xml +++ b/WooCommerce/src/main/res/values/strings.xml @@ -1015,6 +1015,7 @@ Deleted shipment tracking Error deleting tracking Tracking deleted + Scanning failed. Please try again later If renaming an add-on in your web dashboard, please note that previous orders will no longer show that add-on within the app. View add-ons from your device! We are working on making it easier for you to see product add-ons from your device! For now, you’ll be able to see the add-ons for your orders. You can create and edit these add-ons in your web dashboard. @@ -2346,6 +2347,7 @@ Choose from device Take a photo WordPress media library + Choose an existing product photo Device Media Library Select Media Source WordPress media library @@ -3485,6 +3487,7 @@ --> Please log in to the WooCommerce app Your network is unavailable.\nCheck your data or wifi connection. + Looks like your device is in Battery Saver mode. \nWe can\'t provide your store information while it\'s active Store analytics not available! Please upgrade to the latest version of WooCommerce to view your store analytics. Today\'s store stats WooCommerce Stats Today @@ -3786,6 +3789,8 @@ --> Boost your sales Promote your products with Blaze Ads and increase your sales now. + Thinking about boosting your sales? + Get your products seen by millions with Blaze and boost your sales Set your budget - How much would you like to spend on your product promotion campaign? - Total spend + How much would you like to spend on your campaign, and how long should it run for? + weekly spend + Daily spend for %1$s days %1$s daily Estimated people reached per day - Duration + Schedule + Ongoing from %1$s Edit Update Impressions @@ -3923,11 +3933,17 @@ Impressions reflect the frequency with which your ad appears to potential customers.\n\n While exact numbers can\'t be assured due to fluctuating online traffic and user behavior, we aim to match your ad\'s actual impressions as closely as possible to your target count.\n\n Remember, impressions are about visibility, not action taken by viewers. - Set duration %1$s days - Starts + to %1$s + Start date + Run until I stop it + Specify the duration + Campaign will run until you stop it. + Duration Apply + Cancel Failed to estimate impressions. Retry? + %1$s weekly + Custom Fields + Error while loading custom fields + Saving changes + Changes saved + Saving changes failed, please try again + Custom Field deleted + View and edit Custom Fields + When saving changes to custom fields, they will take effect immediately. + Add custom fields + Key + Value + This key is already used for another custom field.\nThe app currently does not support creating duplicate keys. Please use wp-admin to duplicate a key if needed. + Invalid key: please remove the \"_\" character from the beginning. + Copy Key + Copy Value diff --git a/WooCommerce/src/main/res/values/styles_base.xml b/WooCommerce/src/main/res/values/styles_base.xml index f5610796f05..147aa52a894 100644 --- a/WooCommerce/src/main/res/values/styles_base.xml +++ b/WooCommerce/src/main/res/values/styles_base.xml @@ -533,16 +533,6 @@ theme across the entire app. Overridden versions should be added to the styles.x ?colorPrimary - - - - -