diff --git a/app/src/extra/java/org/wikipedia/donate/GooglePayActivity.kt b/app/src/extra/java/org/wikipedia/donate/GooglePayActivity.kt index c6ecdec0ec4..ba3e58303a8 100644 --- a/app/src/extra/java/org/wikipedia/donate/GooglePayActivity.kt +++ b/app/src/extra/java/org/wikipedia/donate/GooglePayActivity.kt @@ -46,7 +46,7 @@ class GooglePayActivity : BaseActivity() { private var shouldWatchText = true private var typedManually = false - private val transactionFee get() = max(getAmountFloat(binding.donateAmountText.text.toString()) * GooglePayComponent.TRANSACTION_FEE_PERCENTAGE, viewModel.transactionFee) + private val transactionFee get() = max(DonateUtil.getAmountFloat(binding.donateAmountText.text.toString()) * GooglePayComponent.TRANSACTION_FEE_PERCENTAGE, viewModel.transactionFee) public override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -56,7 +56,7 @@ class GooglePayActivity : BaseActivity() { supportActionBar?.setDisplayHomeAsUpEnabled(true) title = "" - binding.donateAmountInput.prefixText = viewModel.currencySymbol + binding.donateAmountInput.prefixText = DonateUtil.currencySymbol paymentsClient = GooglePayComponent.createPaymentsClient(this) @@ -87,7 +87,7 @@ class GooglePayActivity : BaseActivity() { ) CampaignCollection.addDonationResult( amount = viewModel.finalAmount, - currency = viewModel.currencyCode, + currency = DonateUtil.currencyCode, recurring = binding.checkBoxRecurring.isChecked ) setResult(RESULT_OK) @@ -109,7 +109,7 @@ class GooglePayActivity : BaseActivity() { return@setOnClickListener } - var totalAmount = getAmountFloat(amountText) + var totalAmount = DonateUtil.getAmountFloat(amountText) if (binding.checkBoxTransactionFee.isChecked) { totalAmount += transactionFee } @@ -135,7 +135,7 @@ class GooglePayActivity : BaseActivity() { } val buttonToHighlight = binding.amountPresetsContainer.children.firstOrNull { child -> if (child is MaterialButton) { - val amount = getAmountFloat(text.toString()) + val amount = DonateUtil.getAmountFloat(text.toString()) child.tag == amount } else { false @@ -166,18 +166,20 @@ class GooglePayActivity : BaseActivity() { } private fun validateInput(text: String): Boolean { - val amount = getAmountFloat(text) + val amount = DonateUtil.getAmountFloat(text) val min = viewModel.minimumAmount val max = viewModel.maximumAmount updateTransactionFee() if (amount <= 0f || amount < min) { - binding.donateAmountInput.error = getString(R.string.donate_gpay_minimum_amount, viewModel.currencyFormat.format(min)) + binding.donateAmountInput.error = getString(R.string.donate_gpay_minimum_amount, + DonateUtil.currencyFormat.format(min)) DonorExperienceEvent.submit("submission_error", "gpay", "error_reason: min_amount") return false } else if (max > 0f && amount > max) { - binding.donateAmountInput.error = getString(R.string.donate_gpay_maximum_amount, viewModel.currencyFormat.format(max)) + binding.donateAmountInput.error = getString(R.string.donate_gpay_maximum_amount, + DonateUtil.currencyFormat.format(max)) DonorExperienceEvent.submit("submission_error", "gpay", "error_reason: max_amount") return false } else { @@ -219,15 +221,23 @@ class GooglePayActivity : BaseActivity() { .build()) val viewIds = mutableListOf() - val presets = donationConfig.currencyAmountPresets[viewModel.currencyCode] - presets?.forEach { amount -> + val presets = donationConfig.currencyAmountPresets[DonateUtil.currencyCode]?.toMutableSet() + if (viewModel.filledAmount > 0f) { + presets?.add(viewModel.filledAmount) + } + var filledAmountButton: MaterialButton? = null + presets?.sorted()?.forEach { amount -> val viewId = View.generateViewId() viewIds.add(viewId) val button = MaterialButton(this) - button.text = viewModel.currencyFormat.format(amount) + button.text = DonateUtil.currencyFormat.format(amount) button.id = viewId button.tag = amount + if (amount == viewModel.filledAmount) { + filledAmountButton = button + } binding.amountPresetsContainer.addView(button) + button.setOnClickListener { setButtonHighlighted(it) setAmountText(it.tag as Float) @@ -235,7 +245,14 @@ class GooglePayActivity : BaseActivity() { } } binding.amountPresetsFlow.referencedIds = viewIds.toIntArray() - setButtonHighlighted() + setFilledAmountToText() + setButtonHighlighted(filledAmountButton) + } + + private fun setFilledAmountToText() { + if (viewModel.filledAmount > 0f) { + setAmountText(viewModel.filledAmount) + } } private fun setButtonHighlighted(button: View? = null) { @@ -254,17 +271,7 @@ class GooglePayActivity : BaseActivity() { private fun updateTransactionFee() { binding.checkBoxTransactionFee.text = getString(R.string.donate_gpay_check_transaction_fee, - viewModel.currencyFormat.format(transactionFee)) - } - - private fun getAmountFloat(text: String): Float { - var result: Float? - result = text.toFloatOrNull() - if (result == null) { - val text2 = if (text.contains(".")) text.replace(".", ",") else text.replace(",", ".") - result = text2.toFloatOrNull() - } - return result ?: 0f + DonateUtil.currencyFormat.format(transactionFee)) } private fun setAmountText(amount: Float) { @@ -306,11 +313,13 @@ class GooglePayActivity : BaseActivity() { companion object { private const val LOAD_PAYMENT_DATA_REQUEST_CODE = 42 private const val CAMPAIGN_ID_APP_MENU = "appmenu" + const val FILLED_AMOUNT = "filledAmount" - fun newIntent(context: Context, campaignId: String? = null, donateUrl: String? = null): Intent { + fun newIntent(context: Context, campaignId: String? = null, donateUrl: String? = null, filledAmount: Float = 0f): Intent { return Intent(context, GooglePayActivity::class.java) .putExtra(DonateDialog.ARG_CAMPAIGN_ID, campaignId) .putExtra(DonateDialog.ARG_DONATE_URL, donateUrl) + .putExtra(FILLED_AMOUNT, filledAmount) } } } diff --git a/app/src/extra/java/org/wikipedia/donate/GooglePayComponent.kt b/app/src/extra/java/org/wikipedia/donate/GooglePayComponent.kt index afdf48cd179..8a2229da2b3 100644 --- a/app/src/extra/java/org/wikipedia/donate/GooglePayComponent.kt +++ b/app/src/extra/java/org/wikipedia/donate/GooglePayComponent.kt @@ -78,8 +78,8 @@ internal object GooglePayComponent { return available } - fun getDonateActivityIntent(activity: Activity, campaignId: String? = null, donateUrl: String? = null): Intent { - return GooglePayActivity.newIntent(activity, campaignId, donateUrl) + fun getDonateActivityIntent(activity: Activity, campaignId: String? = null, donateUrl: String? = null, filledAmount: Float = 0f): Intent { + return GooglePayActivity.newIntent(activity, campaignId, donateUrl, filledAmount) } fun getPaymentDataRequestJson( diff --git a/app/src/extra/java/org/wikipedia/donate/GooglePayViewModel.kt b/app/src/extra/java/org/wikipedia/donate/GooglePayViewModel.kt index 3b3fc1475f4..86e3f4808b3 100644 --- a/app/src/extra/java/org/wikipedia/donate/GooglePayViewModel.kt +++ b/app/src/extra/java/org/wikipedia/donate/GooglePayViewModel.kt @@ -1,5 +1,6 @@ package org.wikipedia.donate +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.google.android.gms.wallet.PaymentData @@ -19,44 +20,37 @@ import org.wikipedia.dataclient.donate.CampaignCollection import org.wikipedia.dataclient.donate.DonationConfig import org.wikipedia.dataclient.donate.DonationConfigHelper import org.wikipedia.settings.Prefs -import org.wikipedia.util.GeoUtil import org.wikipedia.util.Resource import org.wikipedia.util.log.L -import java.text.NumberFormat import java.time.Instant -import java.util.Locale import java.util.concurrent.TimeUnit import kotlin.math.abs -class GooglePayViewModel : ViewModel() { +class GooglePayViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { + val filledAmount = savedStateHandle.get(GooglePayActivity.FILLED_AMOUNT) ?: 0f val uiState = MutableStateFlow(Resource()) private var donationConfig: DonationConfig? = null - private val currentCountryCode get() = GeoUtil.geoIPCountry.orEmpty() - val currencyFormat: NumberFormat = NumberFormat.getCurrencyInstance(Locale.Builder() - .setLocale(Locale.getDefault()).setRegion(currentCountryCode).build()) - val currencyCode get() = currencyFormat.currency?.currencyCode ?: GooglePayComponent.CURRENCY_FALLBACK - val currencySymbol get() = currencyFormat.currency?.symbol ?: "$" - val decimalFormat = GooglePayComponent.getDecimalFormat(currencyCode) + val decimalFormat = GooglePayComponent.getDecimalFormat(DonateUtil.currencyCode) - val transactionFee get() = donationConfig?.currencyTransactionFees?.get(currencyCode) + val transactionFee get() = donationConfig?.currencyTransactionFees?.get(DonateUtil.currencyCode) ?: donationConfig?.currencyTransactionFees?.get("default") ?: 0f - val minimumAmount get() = donationConfig?.currencyMinimumDonation?.get(currencyCode) ?: 0f + val minimumAmount get() = donationConfig?.currencyMinimumDonation?.get(DonateUtil.currencyCode) ?: 0f val maximumAmount: Float get() { - var max = donationConfig?.currencyMaximumDonation?.get(currencyCode) ?: 0f + var max = donationConfig?.currencyMaximumDonation?.get(DonateUtil.currencyCode) ?: 0f if (max == 0f) { val defaultMin = donationConfig?.currencyMinimumDonation?.get(GooglePayComponent.CURRENCY_FALLBACK) ?: 0f if (defaultMin > 0f) { - max = (donationConfig?.currencyMinimumDonation?.get(currencyCode) ?: 0f) / defaultMin * + max = (donationConfig?.currencyMinimumDonation?.get(DonateUtil.currencyCode) ?: 0f) / defaultMin * (donationConfig?.currencyMaximumDonation?.get(GooglePayComponent.CURRENCY_FALLBACK) ?: 0f) } } return max } - val emailOptInRequired get() = donationConfig?.countryCodeEmailOptInRequired.orEmpty().contains(currentCountryCode) + val emailOptInRequired get() = donationConfig?.countryCodeEmailOptInRequired.orEmpty().contains(DonateUtil.currentCountryCode) var disclaimerInformationSharing: String? = null var disclaimerMonthlyCancel: String? = null @@ -64,7 +58,7 @@ class GooglePayViewModel : ViewModel() { var finalAmount = 0f init { - currencyFormat.minimumFractionDigits = 0 + DonateUtil.currencyFormat.minimumFractionDigits = 0 load() } @@ -75,8 +69,7 @@ class GooglePayViewModel : ViewModel() { uiState.value = Resource.Loading() val donationConfigCall = async { DonationConfigHelper.getConfig() } - val donationMessagesCall = async { ServiceFactory.get(WikipediaApp.instance.wikiSite, - DonationConfigHelper.DONATE_WIKI_URL, Service::class.java).getMessages( + val donationMessagesCall = async { ServiceFactory[WikipediaApp.instance.wikiSite, DonationConfigHelper.DONATE_WIKI_URL, Service::class.java].getMessages( listOf(MSG_DISCLAIMER_INFORMATION_SHARING, MSG_DISCLAIMER_MONTHLY_CANCEL).joinToString("|"), null, WikipediaApp.instance.appOrSystemLanguageCode) } @@ -94,7 +87,7 @@ class GooglePayViewModel : ViewModel() { val paymentMethodsCall = async { ServiceFactory.get(WikiSite(GooglePayComponent.PAYMENTS_API_URL)) - .getPaymentMethods(currentCountryCode) + .getPaymentMethods(DonateUtil.currentCountryCode) } paymentMethodsCall.await().response?.let { response -> Prefs.paymentMethodsLastQueryTime = now @@ -107,8 +100,8 @@ class GooglePayViewModel : ViewModel() { if (Prefs.paymentMethodsMerchantId.isEmpty() || Prefs.paymentMethodsGatewayId.isEmpty() || - !donationConfig!!.countryCodeGooglePayEnabled.contains(currentCountryCode) || - !donationConfig!!.currencyAmountPresets.containsKey(currencyCode)) { + !donationConfig!!.countryCodeGooglePayEnabled.contains(DonateUtil.currentCountryCode) || + !donationConfig!!.currencyAmountPresets.containsKey(DonateUtil.currencyCode)) { uiState.value = NoPaymentMethod() } else { uiState.value = Resource.Success(donationConfig!!) @@ -118,7 +111,7 @@ class GooglePayViewModel : ViewModel() { fun getPaymentDataRequest(): PaymentDataRequest { return PaymentDataRequest.fromJson(GooglePayComponent.getPaymentDataRequestJson(finalAmount, - currencyCode, + DonateUtil.currencyCode, Prefs.paymentMethodsMerchantId, Prefs.paymentMethodsGatewayId ).toString()) @@ -149,7 +142,7 @@ class GooglePayViewModel : ViewModel() { // The backend expects the final amount in the canonical decimal format, instead of // any localized format, e.g. comma as decimal separator. - val decimalFormatCanonical = GooglePayComponent.getDecimalFormat(currencyCode, true) + val decimalFormatCanonical = GooglePayComponent.getDecimalFormat(DonateUtil.currencyCode, true) val response = ServiceFactory.get(WikiSite(GooglePayComponent.PAYMENTS_API_URL)) .submitPayment( @@ -157,9 +150,9 @@ class GooglePayViewModel : ViewModel() { BuildConfig.VERSION_NAME, CampaignCollection.getFormattedCampaignId(campaignId), billingObj.optString("locality", ""), - currentCountryCode, - currencyCode, - billingObj.optString("countryCode", currentCountryCode), + DonateUtil.currentCountryCode, + DonateUtil.currencyCode, + billingObj.optString("countryCode", DonateUtil.currentCountryCode), paymentDataObj.optString("email", ""), billingObj.optString("name", ""), WikipediaApp.instance.appOrSystemLanguageCode, diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 31bede1cce2..ccfa3828f1a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -376,6 +376,10 @@ android:name=".readinglist.recommended.RecommendedReadingListSettingsActivity" android:windowSoftInputMode="adjustResize" /> + + Unit + +) { + val inlineElementId = "element" + val text = text + val annotatedString = buildAnnotatedString { + when (position) { + InlinePosition.START -> { + appendInlineContent(inlineElementId, "[$inlineElementId]") + append(text) + } + InlinePosition.END -> { + append(text) + appendInlineContent(inlineElementId, "[$inlineElementId]") + } + } + } + val inlineContent = mapOf( + Pair(inlineElementId, InlineTextContent(placeholder = placeholder, children = content)) + ) + Text( + text = annotatedString, + inlineContent = inlineContent, + style = style + ) +} + +enum class InlinePosition { + START, END +} diff --git a/app/src/main/java/org/wikipedia/donate/DonateDialog.kt b/app/src/main/java/org/wikipedia/donate/DonateDialog.kt index c438dbf0807..f60a51687e8 100644 --- a/app/src/main/java/org/wikipedia/donate/DonateDialog.kt +++ b/app/src/main/java/org/wikipedia/donate/DonateDialog.kt @@ -18,6 +18,7 @@ import org.wikipedia.WikipediaApp import org.wikipedia.activity.BaseActivity import org.wikipedia.analytics.eventplatform.DonorExperienceEvent import org.wikipedia.databinding.DialogDonateBinding +import org.wikipedia.donate.donationreminder.DonationReminderHelper import org.wikipedia.page.ExtendedBottomSheetDialogFragment import org.wikipedia.settings.Prefs import org.wikipedia.util.CustomTabsUtil @@ -47,27 +48,37 @@ class DonateDialog : ExtendedBottomSheetDialogFragment() { lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.CREATED) { - viewModel.uiState.collect { - when (it) { - is Resource.Loading -> { - binding.progressBar.isVisible = true - binding.contentsContainer.isVisible = false - } - is Resource.Error -> { - binding.progressBar.isVisible = false - FeedbackUtil.showMessage(this@DonateDialog, it.throwable.localizedMessage.orEmpty()) - } - is Resource.Success -> { - // if Google Pay is not available, then bounce right out to external workflow. - if (!it.data) { - onDonateClicked() - return@collect + launch { + viewModel.uiState.collect { + when (it) { + is Resource.Loading -> { + binding.progressBar.isVisible = true + binding.contentsContainer.isVisible = false + } + + is Resource.Error -> { + binding.progressBar.isVisible = false + FeedbackUtil.showMessage( + this@DonateDialog, + it.throwable.localizedMessage.orEmpty() + ) + } + + is Resource.Success -> { + // if Google Pay is not available, then bounce right out to external workflow. + if (!it.data) { + onDonateClicked() + return@collect + } + binding.progressBar.isVisible = false + binding.contentsContainer.isVisible = true } - binding.progressBar.isVisible = false - binding.contentsContainer.isVisible = true } } } + if (arguments?.getBoolean(ARG_FROM_DONATION_REMINDER) == true) { + setupDirectGooglePayButton() + } } } @@ -93,15 +104,53 @@ class DonateDialog : ExtendedBottomSheetDialogFragment() { } } + private fun setupDirectGooglePayButton() { + val donateAmount = Prefs.donationReminderConfig.donateAmount + val donateAmountText = + DonateUtil.currencyFormat.format(Prefs.donationReminderConfig.donateAmount) + val donateButtonText = getString(R.string.donation_reminders_gpay_text, donateAmountText) + binding.donateGooglePayButton.text = donateButtonText + binding.donateGooglePayButton.setOnClickListener { + DonorExperienceEvent.logDonationReminderAction( + activeInterface = "reminder_milestone", + action = "gpay_click", + campaignId = DonationReminderHelper.CAMPAIGN_ID + ) + (requireActivity() as? BaseActivity)?.launchDonateActivity( + GooglePayComponent.getDonateActivityIntent(requireActivity(), filledAmount = donateAmount, campaignId = DonationReminderHelper.CAMPAIGN_ID)) + } + binding.donateGooglePayDifferentAmountButton.isVisible = true + binding.donateGooglePayDifferentAmountButton.setOnClickListener { + DonorExperienceEvent.logDonationReminderAction( + activeInterface = "reminder_milestone", + action = "other_gpay_click", + campaignId = DonationReminderHelper.CAMPAIGN_ID + ) + (requireActivity() as? BaseActivity)?.launchDonateActivity( + GooglePayComponent.getDonateActivityIntent(requireActivity(), campaignId = DonationReminderHelper.CAMPAIGN_ID)) + } + binding.donateOtherButton.setOnClickListener { + DonorExperienceEvent.logDonationReminderAction( + activeInterface = "reminder_milestone", + action = "other_method_click", + campaignId = DonationReminderHelper.CAMPAIGN_ID + ) + onDonateClicked() + } + binding.gPayHeaderContainer.isVisible = false + } + companion object { const val ARG_CAMPAIGN_ID = "campaignId" const val ARG_DONATE_URL = "donateUrl" + const val ARG_FROM_DONATION_REMINDER = "fromDonationReminder" - fun newInstance(campaignId: String? = null, donateUrl: String? = null): DonateDialog { + fun newInstance(campaignId: String? = null, donateUrl: String? = null, fromDonationReminder: Boolean = false): DonateDialog { return DonateDialog().apply { arguments = bundleOf( ARG_CAMPAIGN_ID to campaignId, - ARG_DONATE_URL to donateUrl + ARG_DONATE_URL to donateUrl, + ARG_FROM_DONATION_REMINDER to fromDonationReminder ) } } diff --git a/app/src/main/java/org/wikipedia/donate/DonateUtil.kt b/app/src/main/java/org/wikipedia/donate/DonateUtil.kt new file mode 100644 index 00000000000..610c3bd0a54 --- /dev/null +++ b/app/src/main/java/org/wikipedia/donate/DonateUtil.kt @@ -0,0 +1,27 @@ +package org.wikipedia.donate + +import org.wikipedia.util.GeoUtil +import java.text.NumberFormat +import java.util.Locale + +object DonateUtil { + val currentCountryCode get() = GeoUtil.geoIPCountry.orEmpty() + val currencyFormat: NumberFormat + get() = NumberFormat.getCurrencyInstance(Locale.Builder() + .setLocale(Locale.getDefault()).setRegion(currentCountryCode).build()).apply { + minimumFractionDigits = 0 + } + + val currencyCode get() = currencyFormat.currency?.currencyCode ?: GooglePayComponent.CURRENCY_FALLBACK + val currencySymbol get() = currencyFormat.currency?.symbol ?: "$" + + fun getAmountFloat(text: String): Float { + var result: Float? + result = text.toFloatOrNull() + if (result == null) { + val text2 = if (text.contains(".")) text.replace(".", ",") else text.replace(",", ".") + result = text2.toFloatOrNull() + } + return result ?: 0f + } +} diff --git a/app/src/main/java/org/wikipedia/donate/donationreminder/DonationReminderActivity.kt b/app/src/main/java/org/wikipedia/donate/donationreminder/DonationReminderActivity.kt new file mode 100644 index 00000000000..212466ebf6f --- /dev/null +++ b/app/src/main/java/org/wikipedia/donate/donationreminder/DonationReminderActivity.kt @@ -0,0 +1,72 @@ +package org.wikipedia.donate.donationreminder + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.core.net.toUri +import org.wikipedia.R +import org.wikipedia.activity.BaseActivity +import org.wikipedia.analytics.eventplatform.DonorExperienceEvent +import org.wikipedia.compose.components.error.WikiErrorClickEvents +import org.wikipedia.compose.theme.BaseTheme +import org.wikipedia.readinglist.recommended.RecommendedReadingListOnboardingActivity.Companion.EXTRA_FROM_SETTINGS +import org.wikipedia.util.DeviceUtil +import org.wikipedia.util.UriUtil + +class DonationReminderActivity : BaseActivity() { + private val viewModel: DonationReminderViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + DeviceUtil.setEdgeToEdge(this) + setContent { + BaseTheme { + DonationReminderScreen( + viewModel = viewModel, + onBackButtonClick = { + onBackPressed() + }, + onConfirmBtnClick = { message -> + DonationReminderHelper.shouldShowSettingSnackbar = true + finish() + }, + onAboutThisExperimentClick = { + UriUtil.visitInExternalBrowser(this, getString(R.string.donation_reminders_experiment_url).toUri()) + val activeInterface = if (viewModel.isFromSettings) "global_setting" else "reminder_config" + DonorExperienceEvent.logDonationReminderAction( + activeInterface = activeInterface, + action = "reminder_about_click" + ) + }, + wikiErrorClickEvents = WikiErrorClickEvents( + backClickListener = { + finish() + }, + retryClickListener = { + viewModel.loadData() + } + ) + ) + } + } + sendAnalysis() + } + + private fun sendAnalysis() { + if (!viewModel.isFromSettings) { + DonorExperienceEvent.logDonationReminderAction( + activeInterface = "reminder_config", + action = "impression" + ) + } + } + + companion object { + fun newIntent(context: Context, isFromSettings: Boolean = false): Intent { + return Intent(context, DonationReminderActivity::class.java) + .putExtra(EXTRA_FROM_SETTINGS, isFromSettings) + } + } +} diff --git a/app/src/main/java/org/wikipedia/donate/donationreminder/DonationReminderCardView.kt b/app/src/main/java/org/wikipedia/donate/donationreminder/DonationReminderCardView.kt new file mode 100644 index 00000000000..f07eafeaf12 --- /dev/null +++ b/app/src/main/java/org/wikipedia/donate/donationreminder/DonationReminderCardView.kt @@ -0,0 +1,68 @@ +package org.wikipedia.donate.donationreminder + +import android.content.Context +import android.graphics.Typeface +import android.os.Build +import android.text.Spannable +import android.text.SpannableString +import android.text.style.ImageSpan +import android.util.AttributeSet +import android.view.LayoutInflater +import androidx.core.content.ContextCompat +import org.wikipedia.R +import org.wikipedia.databinding.ViewDonationReminderCardBinding +import org.wikipedia.util.DimenUtil +import org.wikipedia.util.ResourceUtil +import org.wikipedia.views.WikiCardView + +class DonationReminderCardView(context: Context, attrs: AttributeSet? = null) : WikiCardView(context, attrs) { + + val binding = ViewDonationReminderCardBinding.inflate(LayoutInflater.from(context), this, true) + + init { + strokeWidth = DimenUtil.roundedDpToPx(1f) + elevation = 0f + } + + fun setTitle(title: String) { + val titleWithReservedSpace = "$title %" // HACK: Reserve space for the icon + val spannableString = SpannableString(titleWithReservedSpace) + val iconDrawable = ContextCompat.getDrawable(context, R.drawable.ic_heart_24)!! + val iconSize = DimenUtil.dpToPx(20f).toInt() + iconDrawable.apply { + setTint(ResourceUtil.getThemedColor(context, R.attr.destructive_color)) + setBounds(0, 0, iconSize, iconSize) + } + spannableString.setSpan(ImageSpan(iconDrawable, ImageSpan.ALIGN_BOTTOM), titleWithReservedSpace.length - 1, titleWithReservedSpace.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + binding.messageTitleView.text = spannableString + } + + fun setMessage(text: String) { + binding.messageTextView.text = text + } + + fun setPositiveButton(text: String, listener: OnClickListener) { + binding.positiveButton.text = text + binding.positiveButton.setOnClickListener(listener) + } + + fun setNegativeButton(text: String, listener: OnClickListener) { + binding.negativeButton.text = text + binding.negativeButton.setOnClickListener(listener) + } + + fun setLabel(message: String?) { + if (message.isNullOrEmpty()) { + binding.messageLabel.visibility = GONE + return + } + val typeface = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + Typeface.create(Typeface.MONOSPACE, 500, false) + } else { + Typeface.create(Typeface.MONOSPACE, Typeface.BOLD) + } + binding.messageLabel.text = message + binding.messageLabel.typeface = typeface + binding.messageLabel.letterSpacing = 0.1f + } +} diff --git a/app/src/main/java/org/wikipedia/donate/donationreminder/DonationReminderHelper.kt b/app/src/main/java/org/wikipedia/donate/donationreminder/DonationReminderHelper.kt new file mode 100644 index 00000000000..bad75db65d0 --- /dev/null +++ b/app/src/main/java/org/wikipedia/donate/donationreminder/DonationReminderHelper.kt @@ -0,0 +1,259 @@ +package org.wikipedia.donate.donationreminder + +import android.app.Activity +import android.widget.ScrollView +import androidx.appcompat.app.AlertDialog +import androidx.core.view.isVisible +import kotlinx.serialization.Serializable +import org.wikipedia.R +import org.wikipedia.WikipediaApp +import org.wikipedia.analytics.eventplatform.DonorExperienceEvent +import org.wikipedia.auth.AccountUtil +import org.wikipedia.databinding.DialogFeedbackOptionsBinding +import org.wikipedia.donate.DonateUtil +import org.wikipedia.settings.Prefs +import org.wikipedia.util.FeedbackUtil +import org.wikipedia.util.GeoUtil +import org.wikipedia.util.ReleaseUtil +import java.time.LocalDate + +object DonationReminderHelper { + const val CAMPAIGN_ID = "appmenu_reminder" + const val MAX_INITIAL_REMINDER_PROMPTS = 5 + const val MAX_REMINDER_PROMPTS = 2 + private val validReadCountOnSeconds = if (ReleaseUtil.isDevRelease) 1 else 15 + private val enabledCountries = listOf( + "IT" + ) + + private val enabledLanguages = listOf( + "it", "en" + ) + + val currencyAmountPresets = mapOf( + "IT" to listOf(1f, 2f, 3f) + ) + + val defaultReadFrequencyOptions = listOf(5, 10, 15) + + // TODO: update the end date when before release to production for 30-day experiment + val isEnabled + get() = ReleaseUtil.isDevRelease || + (enabledCountries.contains(GeoUtil.geoIPCountry.orEmpty()) && + enabledLanguages.contains(WikipediaApp.Companion.instance.languageState.appLanguageCode) && + LocalDate.now() <= LocalDate.of(2025, 9, 26) && !AccountUtil.isLoggedIn) + + val hasActiveReminder get() = Prefs.donationReminderConfig.initialPromptActive || + (Prefs.donationReminderConfig.isEnabled && Prefs.donationReminderConfig.finalPromptActive) + + var shouldShowSettingSnackbar = false + + fun thankYouMessageForSettings(): String { + val context = WikipediaApp.instance + val donationAmount = + DonateUtil.currencyFormat.format(Prefs.donationReminderConfig.donateAmount) + val readFrequency = Prefs.donationReminderConfig.articleFrequency + val articleNumber = context.resources.getQuantityString(R.plurals.donation_reminders_text_articles, + readFrequency, readFrequency) + val message = context.getString(R.string.donation_reminders_snacbkbar_confirmation_label, donationAmount, articleNumber) + return message + } + + fun maybeShowSettingSnackbar(activity: Activity) { + if (shouldShowSettingSnackbar) { + FeedbackUtil.makeNavigationAwareSnackbar(activity, thankYouMessageForSettings()).show() + shouldShowSettingSnackbar = false + } + } + + fun increaseArticleVisitCount(timeSpentSec: Int) { + var config = Prefs.donationReminderConfig + if (timeSpentSec >= validReadCountOnSeconds && !config.finalPromptActive && config.setupTimestamp != 0L) { + Prefs.donationReminderConfig = config.copy( + articleVisit = config.articleVisit + 1 + ) + activateDonationReminder() + } + config = Prefs.donationReminderConfig + if (config.finalPromptActive && config.finalPromptCount == MAX_REMINDER_PROMPTS) { + // When user reaches the maximum reminder prompts, then turn off the final prompt + Prefs.donationReminderConfig = config.copy( + finalPromptActive = false + ) + } + } + + fun donationReminderDismissed(isInitialPrompt: Boolean) { + val config = Prefs.donationReminderConfig + Prefs.donationReminderConfig = if (isInitialPrompt) { + config.copy(initialPromptActive = false) + } else { + config.copy(finalPromptActive = false) + } + } + + fun maybeShowInitialDonationReminder(update: Boolean = false): Boolean { + if (!isEnabled) return false + return Prefs.donationReminderConfig.let { config -> + val daysOfLastSeen = (LocalDate.now().toEpochDay() - config.promptLastSeen) + if (config.setupTimestamp > 0L || !config.initialPromptActive || + config.initialPromptCount >= MAX_INITIAL_REMINDER_PROMPTS || + daysOfLastSeen <= 0 + ) { + return@let false + } + if (update) { + Prefs.donationReminderConfig = config.copy( + initialPromptCount = config.initialPromptCount + 1, + promptLastSeen = LocalDate.now().toEpochDay() + ) + } + return true + } + } + + fun maybeShowDonationReminder(update: Boolean = false): Boolean { + if (!isEnabled) return false + return Prefs.donationReminderConfig.let { config -> + val daysOfLastSeen = (LocalDate.now().toEpochDay() - config.promptLastSeen) + if (!config.isEnabled || config.setupTimestamp == 0L || !config.finalPromptActive || + config.finalPromptCount > MAX_REMINDER_PROMPTS || + daysOfLastSeen <= 0 + ) { + return@let false + } + + if (update) { + val finalPromptCount = config.finalPromptCount + 1 + Prefs.donationReminderConfig = config.copy( + finalPromptCount = finalPromptCount, + promptLastSeen = LocalDate.now().toEpochDay() + ) + } + return true + } + } + + private fun activateDonationReminder() { + Prefs.donationReminderConfig.let { config -> + if (config.articleVisit > 0 && config.articleFrequency > 0 && + config.articleVisit % config.articleFrequency == 0 && + !config.initialPromptActive) { + // When reaching the article frequency, activate the reminder and reset the count and visits + Prefs.donationReminderConfig = config.copy( + finalPromptActive = true, + finalPromptCount = 0, + articleVisit = 0 + ) + } + } + } + + fun maybeShowSurveyDialog(activity: Activity) { + if (!isEnabled) return + + val config = Prefs.donationReminderConfig + if (config.isSurveyShown) return + + // when user sets up the reminder + val hasSetupReminder = config.donateAmount > 0 && config.articleFrequency > 0 + if (hasSetupReminder) { + val userGroup = getUserGroup() + when (userGroup) { + "A" -> { + // Group A: Show survey on next article visit after setting up reminder + showFeedbackOptionsDialog(activity, "reminder_setup_next_article") + } + "B" -> { + // Group B: Show survey on the next article visit after seeing reminder impressions + if (config.finalPromptCount >= 1) { + showFeedbackOptionsDialog(activity, "reminder_impression_next_article") + } + } + } + return + } + + // User has not taken any action on the initial prompt + // Show survey on next article visit if this continues for continuous 5 times + if (config.initialPromptCount >= MAX_INITIAL_REMINDER_PROMPTS) { + showFeedbackOptionsDialog(activity, "impression_next_article") + return + } + } + + private fun getUserGroup(): String { + return if (Prefs.appInstallId.hashCode() % 2 == 0) "A" else "B" + } + + private fun showFeedbackOptionsDialog(activity: Activity, userGroup: String) { + val binding = DialogFeedbackOptionsBinding.inflate(activity.layoutInflater) + binding.titleText.text = activity.getString(R.string.donation_reminders_survey_dialog_title) + binding.messageText.text = activity.getString(R.string.donation_reminders_survey_dialog_message) + binding.feedbackInputContainer.isVisible = true + + val dialog = AlertDialog.Builder(activity) + .setView(binding.root) + .setCancelable(false) + .create() + + binding.cancelButton.setOnClickListener { + DonorExperienceEvent.logDonationReminderAction( + activeInterface = "reminder_feedback", + action = "feedback_close_click", + userGroup = userGroup + ) + dialog.dismiss() + } + binding.submitButton.setOnClickListener { + val selectedOption = getSelectedOption(binding) + val feedbackText = binding.feedbackInput.text.toString() + DonorExperienceEvent.logDonationReminderAction( + activeInterface = "reminder_feedback", + action = "feedback_submit_click", + feedbackSelect = selectedOption, + feedbackText = feedbackText, + userGroup = userGroup + ) + dialog.dismiss() + } + + binding.feedbackInput.setOnFocusChangeListener { _, hasFocus -> + if (hasFocus) { + binding.dialogContainer.postDelayed({ + if (!activity.isDestroyed) { + binding.dialogContainer.fullScroll(ScrollView.FOCUS_DOWN) + } + }, 200) + } + } + DonorExperienceEvent.logDonationReminderAction(activeInterface = "reminder_feedback", action = "impression", userGroup = userGroup) + dialog.show() + Prefs.donationReminderConfig = Prefs.donationReminderConfig.copy(isSurveyShown = true) + } + + private fun getSelectedOption(binding: DialogFeedbackOptionsBinding): Int? { + val selectedId = binding.feedbackRadioGroup.checkedRadioButtonId + return when (selectedId) { + R.id.optionSatisfied -> 1 + R.id.optionNeutral -> 2 + R.id.optionUnsatisfied -> 3 + else -> null + } + } +} + +@Serializable +data class DonationReminderConfig( + val isEnabled: Boolean = false, + val initialPromptCount: Int = 0, + val initialPromptActive: Boolean = true, + val finalPromptCount: Int = 0, + val finalPromptActive: Boolean = false, + val promptLastSeen: Long = 0, + val setupTimestamp: Long = 0, + val articleVisit: Int = 0, + val isSurveyShown: Boolean = false, + val articleFrequency: Int = 0, + val donateAmount: Float = 0f +) diff --git a/app/src/main/java/org/wikipedia/donate/donationreminder/DonationReminderScreen.kt b/app/src/main/java/org/wikipedia/donate/donationreminder/DonationReminderScreen.kt new file mode 100644 index 00000000000..397db165148 --- /dev/null +++ b/app/src/main/java/org/wikipedia/donate/donationreminder/DonationReminderScreen.kt @@ -0,0 +1,812 @@ +package org.wikipedia.donate.donationreminder + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.Info +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalContext +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.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import kotlinx.coroutines.launch +import org.wikipedia.R +import org.wikipedia.analytics.eventplatform.DonorExperienceEvent +import org.wikipedia.compose.components.AppButton +import org.wikipedia.compose.components.InlinePosition +import org.wikipedia.compose.components.TextWithInlineElement +import org.wikipedia.compose.components.WikiTopAppBar +import org.wikipedia.compose.components.error.WikiErrorClickEvents +import org.wikipedia.compose.components.error.WikiErrorView +import org.wikipedia.compose.extensions.noRippleClickable +import org.wikipedia.compose.theme.BaseTheme +import org.wikipedia.compose.theme.WikipediaTheme +import org.wikipedia.donate.DonateUtil +import org.wikipedia.theme.Theme + +@Composable +fun DonationReminderScreen( + modifier: Modifier = Modifier, + viewModel: DonationReminderViewModel, + wikiErrorClickEvents: WikiErrorClickEvents? = null, + onBackButtonClick: () -> Unit, + onConfirmBtnClick: (String) -> Unit, + onAboutThisExperimentClick: () -> Unit +) { + val uiState = viewModel.uiState.collectAsState().value + var isNavigatingToExternalUrl by remember { mutableStateOf(false) } + + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_PAUSE -> { + if (viewModel.isFromSettings && !isNavigatingToExternalUrl && viewModel.hasValueChanged()) { + viewModel.saveReminder() + val message = DonationReminderHelper.thankYouMessageForSettings() + onConfirmBtnClick(message) + } + } + Lifecycle.Event.ON_RESUME -> { + isNavigatingToExternalUrl = false + } + else -> {} + } + } + lifecycleOwner.lifecycle.addObserver(observer) + + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + LaunchedEffect(Unit) { + viewModel.loadData() + } + + Scaffold( + modifier = modifier, + topBar = { + WikiTopAppBar( + title = stringResource(R.string.donation_reminders_settings_title), + onNavigationClick = onBackButtonClick + ) + }, + containerColor = WikipediaTheme.colors.paperColor, + ) { paddingValues -> + if (uiState.isLoading) { + Box( + modifier = modifier + .fillMaxSize() + .padding(paddingValues) + ) { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + color = WikipediaTheme.colors.progressiveColor, + trackColor = WikipediaTheme.colors.borderColor + ) + } + return@Scaffold + } + + if (uiState.error != null) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center + ) { + WikiErrorView( + modifier = Modifier + .fillMaxWidth(), + caught = uiState.error, + errorClickEvents = wikiErrorClickEvents + ) + } + return@Scaffold + } + + DonationReminderContent( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize(), + viewModel = viewModel, + uiState = uiState, + onConfirmBtnClick = onConfirmBtnClick, + onAboutThisExperimentClick = { + isNavigatingToExternalUrl = true + onAboutThisExperimentClick() + } + ) + } +} + +@Composable +fun DonationReminderContent( + modifier: Modifier = Modifier, + viewModel: DonationReminderViewModel, + uiState: DonationReminderUiState, + onConfirmBtnClick: (String) -> Unit, + onAboutThisExperimentClick: () -> Unit +) { + val isDonationReminderEnabled = uiState.isDonationReminderEnabled + var showReadFrequencyCustomDialog by remember { mutableStateOf(false) } + var showDonationAmountCustomDialog by remember { mutableStateOf(false) } + var customDialogErrorMessage by remember { mutableStateOf("") } + val context = LocalContext.current + + Column( + modifier = modifier + ) { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .weight(1f) + .padding(16.dp) + ) { + DonationHeader() + if (viewModel.isFromSettings) { + DonationRemindersSwitch( + modifier = Modifier + .noRippleClickable { + viewModel.toggleDonationReminders(!isDonationReminderEnabled) + } + .padding(top = 24.dp), + isDonationRemindersEnabled = isDonationReminderEnabled, + onCheckedChange = { viewModel.toggleDonationReminders(it) } + ) + } + Spacer(modifier = Modifier.height(24.dp)) + if (uiState.isDonationReminderEnabled || !viewModel.isFromSettings) { + ReadFrequencyView( + option = uiState.readFrequency, + showReadFrequencyCustomDialog = showReadFrequencyCustomDialog, + customDialogErrorMessage = customDialogErrorMessage, + onOptionSelected = { option -> + when (option) { + is OptionItem.Preset -> { + val activeInterface = if (viewModel.isFromSettings) "global_setting" else "reminder_config" + DonorExperienceEvent.logDonationReminderAction( + activeInterface = activeInterface, + action = "freq_change_click" + ) + viewModel.updateReadFrequencyState(option.value) + } + + is OptionItem.Custom -> { + showReadFrequencyCustomDialog = true + } + } + }, + onDismissRequest = { + showReadFrequencyCustomDialog = false + customDialogErrorMessage = "" + }, + onDoneClick = { readFrequency -> + if (customDialogErrorMessage.isEmpty()) { + val activeInterface = if (viewModel.isFromSettings) "global_setting" else "reminder_config" + DonorExperienceEvent.logDonationReminderAction( + activeInterface = activeInterface, + action = "freq_change_click" + ) + viewModel.updateReadFrequencyState(readFrequency.toInt()) + showReadFrequencyCustomDialog = false + } + }, + onValueChange = { value -> + val minimumAmount = uiState.readFrequency.minimumAmount + val maximumAmount = uiState.readFrequency.maximumAmount + val amount = DonateUtil.getAmountFloat(value) + customDialogErrorMessage = when { + amount <= minimumAmount -> { + context.getString( + R.string.donation_reminders_settings_warning_min_amount, + uiState.readFrequency.displayFormatter(minimumAmount + 1) + ) + } + amount >= maximumAmount -> { + context.getString( + R.string.donation_reminders_settings_warning_max_amount, + uiState.readFrequency.displayFormatter(maximumAmount - 1) + ) + } + else -> "" + } + } + ) + Spacer(modifier = Modifier.height(24.dp)) + DonationAmountView( + option = uiState.donationAmount, + showDonationAmountCustomDialog = showDonationAmountCustomDialog, + currencySymbol = DonateUtil.currencySymbol, + customDialogErrorMessage = customDialogErrorMessage, + onDismissRequest = { + showDonationAmountCustomDialog = false + customDialogErrorMessage = "" + }, + onOptionSelected = { option -> + when (option) { + is OptionItem.Preset -> { + val activeInterface = if (viewModel.isFromSettings) "global_setting" else "reminder_config" + DonorExperienceEvent.logDonationReminderAction( + activeInterface = activeInterface, + action = "amount_change_click" + ) + viewModel.updateDonationAmountState(option.value) + } + + is OptionItem.Custom -> { + showDonationAmountCustomDialog = true + } + } + }, + onDoneClick = { amount -> + if (customDialogErrorMessage.isEmpty()) { + val activeInterface = if (viewModel.isFromSettings) "global_setting" else "reminder_config" + DonorExperienceEvent.logDonationReminderAction( + activeInterface = activeInterface, + action = "amount_change_click" + ) + viewModel.updateDonationAmountState(amount.toFloat()) + showDonationAmountCustomDialog = false + } + }, + onValueChange = { value -> + val amount = DonateUtil.getAmountFloat(value) + val minimumAmount = uiState.donationAmount.minimumAmount + val maximumAmount = uiState.donationAmount.maximumAmount + customDialogErrorMessage = when { + amount < minimumAmount -> { + context.getString( + R.string.donate_gpay_minimum_amount, + uiState.donationAmount.displayFormatter(minimumAmount) + ) + } + maximumAmount > 0 && amount >= maximumAmount -> { + context.getString( + R.string.donate_gpay_maximum_amount, + uiState.donationAmount.displayFormatter(maximumAmount) + ) + } + else -> "" + } + } + ) + } + } + + if (uiState.isDonationReminderEnabled || !viewModel.isFromSettings) { + if (!viewModel.isFromSettings) { + AppButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(top = 16.dp), + onClick = { + viewModel.toggleDonationReminders(true) + viewModel.saveReminder() + val message = DonationReminderHelper.thankYouMessageForSettings() + onConfirmBtnClick(message) + }, + content = { + Text( + stringResource(R.string.donation_reminders_settings_confirm_btn_label) + ) + } + ) + } + } + + TextButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp), + onClick = onAboutThisExperimentClick, + content = { + Text( + text = stringResource(R.string.donation_reminders_settings_about_experiment_btn_label), + color = WikipediaTheme.colors.progressiveColor + ) + } + ) + } +} + +@Composable +fun DonationAmountView( + option: SelectableOption, + currencySymbol: String, + showDonationAmountCustomDialog: Boolean, + customDialogErrorMessage: String, + onOptionSelected: (OptionItem) -> Unit, + onDismissRequest: () -> Unit, + onDoneClick: (String) -> Unit, + onValueChange: (String) -> Unit +) { + OptionSelector( + title = stringResource(R.string.donation_reminders_settings_amount_label), + headerIcon = R.drawable.credit_card_heart_24, + option = option, + onOptionSelected = onOptionSelected + ) + if (showDonationAmountCustomDialog) { + CustomInputDialog( + title = stringResource(R.string.donation_reminders_settings_amount_label), + decimalEnabled = true, + errorMessage = customDialogErrorMessage, + onDismissRequest = onDismissRequest, + prefix = { + Text( + text = currencySymbol, + style = MaterialTheme.typography.bodyLarge, + color = WikipediaTheme.colors.primaryColor + ) + }, + onDoneClick = onDoneClick, + onValueChange = onValueChange + ) + } +} + +@Composable +fun ReadFrequencyView( + option: SelectableOption, + showReadFrequencyCustomDialog: Boolean, + customDialogErrorMessage: String, + onOptionSelected: (OptionItem) -> Unit, + onDismissRequest: () -> Unit, + onDoneClick: (String) -> Unit, + onValueChange: (String) -> Unit +) { + OptionSelector( + title = stringResource(R.string.donation_reminders_settings_article_frequency_label), + headerIcon = R.drawable.newsstand_24dp, + option = option, + showInfo = true, + onOptionSelected = onOptionSelected + ) + if (showReadFrequencyCustomDialog) { + CustomInputDialog( + title = stringResource(R.string.donation_reminders_settings_article_frequency_label), + errorMessage = customDialogErrorMessage, + onDismissRequest = onDismissRequest, + suffix = { + Text( + text = stringResource(R.string.donation_reminders_settings_article_frequency_input_suffix_label), + style = MaterialTheme.typography.bodyLarge, + color = WikipediaTheme.colors.primaryColor + ) + }, + onDoneClick = onDoneClick, + onValueChange = onValueChange + ) + } +} + +@Composable +fun DonationHeader( + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + ) { + val rawString = stringResource(R.string.donation_reminders_settings_thank_you_message) + val formattedString = rawString.replace("%%", "%") + TextWithInlineElement( + text = formattedString, + position = InlinePosition.END, + placeholder = Placeholder( + width = 20.sp, + height = 20.sp, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center + ), + content = { + Icon( + modifier = Modifier + .size(20.dp) + .padding(start = 4.dp), + imageVector = Icons.Filled.Favorite, + contentDescription = null, + tint = WikipediaTheme.colors.destructiveColor + ) + } + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.donation_reminders_settings_donation_info), + style = MaterialTheme.typography.bodySmall, + color = WikipediaTheme.colors.placeholderColor + ) + + HorizontalDivider( + modifier = Modifier + .padding(top = 24.dp), + color = WikipediaTheme.colors.borderColor + ) + } +} + +@Composable +fun OptionSelector( + title: String, + option: SelectableOption, + @DrawableRes headerIcon: Int, + onOptionSelected: (OptionItem) -> Unit, + showInfo: Boolean = false, +) { + var isDropdownExpanded by remember { mutableStateOf(false) } + val displayValue by remember(option.selectedValue, option.displayFormatter) { + derivedStateOf { option.displayFormatter(option.selectedValue) } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.Top + ) { + Icon( + modifier = Modifier + .padding(top = 3.dp), + painter = painterResource(headerIcon), + tint = WikipediaTheme.colors.primaryColor, + contentDescription = null + ) + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + color = WikipediaTheme.colors.primaryColor, + ) + Spacer(modifier = Modifier.width(16.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + TextField( + modifier = Modifier + .width(210.dp) + .clickable { isDropdownExpanded = true }, + value = displayValue, + enabled = false, + onValueChange = {}, + textStyle = MaterialTheme.typography.bodyLarge, + colors = TextFieldDefaults.colors( + disabledContainerColor = WikipediaTheme.colors.backgroundColor, + disabledTextColor = WikipediaTheme.colors.primaryColor + ), + trailingIcon = { + Icon( + imageVector = Icons.Filled.ArrowDropDown, + tint = WikipediaTheme.colors.primaryColor, + contentDescription = null + ) + } + ) + + if (showInfo) { + InfoTooltip( + modifier = Modifier, + plainTooltipText = stringResource(R.string.donation_reminders_settings_tooltip_info_label) + ) + } + + DropdownMenu( + modifier = Modifier + .width(210.dp), + containerColor = WikipediaTheme.colors.backgroundColor, + expanded = isDropdownExpanded, + onDismissRequest = { isDropdownExpanded = false }, + content = { + option.options.forEach { option -> + DropdownMenuItem( + text = { + Text( + text = option.displayText, + style = MaterialTheme.typography.bodyLarge, + color = WikipediaTheme.colors.primaryColor + ) + }, + onClick = { + onOptionSelected(option) + isDropdownExpanded = false + } + ) + } + } + ) + } + } + } +} + +@Composable +private fun DonationRemindersSwitch( + isDonationRemindersEnabled: Boolean, + onCheckedChange: ((Boolean) -> Unit), + modifier: Modifier = Modifier +) { + + ListItem( + modifier = modifier + .clip(RoundedCornerShape(16.dp)), + colors = ListItemDefaults.colors( + containerColor = WikipediaTheme.colors.backgroundColor + ), + headlineContent = { + Text( + text = stringResource(R.string.donation_reminders_settings_title), + style = MaterialTheme.typography.bodyLarge, + color = WikipediaTheme.colors.primaryColor + ) + }, + trailingContent = { + Switch( + checked = isDonationRemindersEnabled, + onCheckedChange = { + onCheckedChange(it) + }, + colors = SwitchDefaults.colors( + uncheckedTrackColor = WikipediaTheme.colors.paperColor, + uncheckedThumbColor = MaterialTheme.colorScheme.outline, + uncheckedBorderColor = MaterialTheme.colorScheme.outline, + checkedTrackColor = WikipediaTheme.colors.progressiveColor, + checkedThumbColor = WikipediaTheme.colors.paperColor + ) + ) + } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun InfoTooltip( + modifier: Modifier = Modifier, + plainTooltipText: String +) { + val tooltipState = rememberTooltipState() + val scope = rememberCoroutineScope() + TooltipBox( + modifier = modifier, + positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), + tooltip = { + PlainTooltip( + modifier = Modifier + .padding(horizontal = 16.dp), + containerColor = WikipediaTheme.colors.primaryColor, + content = { + Text( + text = plainTooltipText, + style = MaterialTheme.typography.bodyMedium, + color = WikipediaTheme.colors.paperColor + ) + } + ) + }, + state = tooltipState, + content = { + Icon( + modifier = Modifier + .noRippleClickable(onClick = { + scope.launch { + tooltipState.show() + } + }), + painter = painterResource(R.drawable.ic_info_outline_black_24dp), + tint = WikipediaTheme.colors.primaryColor, + contentDescription = null + ) + } + ) +} + +@Composable +fun CustomInputDialog( + modifier: Modifier = Modifier, + title: String, + decimalEnabled: Boolean = false, + errorMessage: String = "", + onDoneClick: (String) -> Unit, + onDismissRequest: () -> Unit, + prefix: @Composable (() -> Unit)? = null, + suffix: @Composable (() -> Unit)? = null, + onValueChange: (String) -> Unit, +) { + var value by remember { mutableStateOf("") } + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + Dialog( + onDismissRequest = onDismissRequest, + content = { + Column( + modifier = modifier + .clip(RoundedCornerShape(28.dp)) + .background(WikipediaTheme.colors.paperColor) + .padding(24.dp), + ) { + Text( + modifier = Modifier + .fillMaxWidth(), + text = title, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.labelLarge, + color = WikipediaTheme.colors.primaryColor + ) + Spacer(modifier = Modifier.height(16.dp)) + OutlinedTextField( + modifier = Modifier + .focusRequester(focusRequester), + value = value, + singleLine = true, + onValueChange = { newValue -> + onValueChange(newValue) + value = newValue + }, + isError = errorMessage.isNotEmpty(), + prefix = prefix, + suffix = suffix, + textStyle = MaterialTheme.typography.bodyLarge, + keyboardOptions = KeyboardOptions( + keyboardType = if (decimalEnabled) KeyboardType.Number else KeyboardType.NumberPassword, + imeAction = ImeAction.Send + ), + keyboardActions = KeyboardActions( + onSend = { + if (value.isEmpty()) { + onValueChange("") + return@KeyboardActions + } + onDoneClick(value) + } + ), + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = WikipediaTheme.colors.primaryColor, + focusedBorderColor = MaterialTheme.colorScheme.outline, + unfocusedTextColor = MaterialTheme.colorScheme.onSurfaceVariant, + unfocusedBorderColor = MaterialTheme.colorScheme.outline, + cursorColor = WikipediaTheme.colors.primaryColor, + errorTextColor = WikipediaTheme.colors.primaryColor, + ), + supportingText = if (errorMessage.isNotEmpty()) { + { + Text( + text = errorMessage, + color = WikipediaTheme.colors.destructiveColor, + ) + } + } else null, + trailingIcon = if (errorMessage.isNotEmpty()) { + { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + tint = WikipediaTheme.colors.destructiveColor + ) + } + } else null + ) + Row( + modifier = Modifier + .padding(top = 8.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + TextButton( + onClick = { + if (value.isEmpty()) { + onValueChange("") + return@TextButton + } + onDoneClick(value) + }, + content = { + Text( + text = "Done", + color = WikipediaTheme.colors.progressiveColor + ) + } + ) + } + } + } + ) +} + +@Preview +@Composable +private fun CustomInputDialogPreview() { + BaseTheme( + currentTheme = Theme.LIGHT + ) { + CustomInputDialog( + title = "Remind me to donate", + onDoneClick = {}, + onDismissRequest = {}, + prefix = { + Text( + text = "$", + modifier = Modifier.padding(start = 16.dp) + ) + }, + suffix = { + Text( + modifier = Modifier + .padding(horizontal = 12.dp), + text = "articles" + ) + }, + onValueChange = {} + ) + } +} diff --git a/app/src/main/java/org/wikipedia/donate/donationreminder/DonationReminderViewModel.kt b/app/src/main/java/org/wikipedia/donate/donationreminder/DonationReminderViewModel.kt new file mode 100644 index 00000000000..4e614f0aa77 --- /dev/null +++ b/app/src/main/java/org/wikipedia/donate/donationreminder/DonationReminderViewModel.kt @@ -0,0 +1,195 @@ +package org.wikipedia.donate.donationreminder + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.wikipedia.R +import org.wikipedia.WikipediaApp +import org.wikipedia.analytics.eventplatform.DonorExperienceEvent +import org.wikipedia.dataclient.donate.DonationConfigHelper +import org.wikipedia.donate.DonateUtil +import org.wikipedia.donate.GooglePayComponent +import org.wikipedia.readinglist.recommended.RecommendedReadingListOnboardingActivity +import org.wikipedia.settings.Prefs +import org.wikipedia.util.log.L + +class DonationReminderViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { + private val maxArticleFrequencyLimit = 1000 + private val minArticleFrequencyLimit = 1 + val isFromSettings = savedStateHandle.get(RecommendedReadingListOnboardingActivity.EXTRA_FROM_SETTINGS) == true + + private val _uiState = MutableStateFlow(DonationReminderUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val formatRegex = Regex("\\.00$") + + fun loadData() { + viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + L.e(throwable) + _uiState.update { it.copy(isLoading = false, error = throwable) } + }) { + val readFrequencyOptions = createReadFrequencyOptions() + val donationAmountOptions = createDonationAmountOptions() + _uiState.update { + it.copy( + readFrequency = readFrequencyOptions, + donationAmount = donationAmountOptions, + isDonationReminderEnabled = Prefs.donationReminderConfig.isEnabled, + isLoading = false, + error = null + ) + } + } + } + + fun hasDefaultValues(): Boolean { + val currentValue = _uiState.value + return currentValue.donationAmount.selectedValue == currentValue.donationAmount.defaultValue && currentValue.readFrequency.selectedValue == currentValue.readFrequency.defaultValue + } + + fun hasValueChanged(): Boolean { + val currentValue = _uiState.value + return Prefs.donationReminderConfig.isEnabled && (currentValue.donationAmount.selectedValue != Prefs.donationReminderConfig.donateAmount || currentValue.readFrequency.selectedValue != Prefs.donationReminderConfig.articleFrequency) + } + + fun saveReminder() { + with(_uiState.value) { + val activeInterface = if (isFromSettings) "global_setting" else "reminder_config" + DonorExperienceEvent.logDonationReminderAction( + activeInterface = activeInterface, + action = "reminder_confirm_click", + defaultMilestone = hasDefaultValues(), + articleFrequency = readFrequency.selectedValue, + donateAmount = donationAmount.selectedValue + ) + Prefs.donationReminderConfig = Prefs.donationReminderConfig.copy( + donateAmount = donationAmount.selectedValue, + articleFrequency = readFrequency.selectedValue, + setupTimestamp = System.currentTimeMillis() + ) + } + } + + fun updateDonationAmountState(donationAmount: Float) { + _uiState.update { it.copy(donationAmount = it.donationAmount.copy(selectedValue = donationAmount)) } + } + + fun updateReadFrequencyState(readFrequency: Int) { + _uiState.update { it.copy(readFrequency = it.readFrequency.copy(selectedValue = readFrequency)) } + } + + fun toggleDonationReminders(enabled: Boolean) { + Prefs.donationReminderConfig = Prefs.donationReminderConfig.copy(isEnabled = enabled) + if (enabled) { + Prefs.donationReminderConfig = Prefs.donationReminderConfig.copy( + initialPromptActive = false, + finalPromptActive = false + ) + } else { + DonorExperienceEvent.logDonationReminderAction( + activeInterface = "global_setting", + action = "reminder_set_off" + ) + } + _uiState.update { it.copy(isDonationReminderEnabled = enabled) } + } + + private fun createReadFrequencyOptions(): SelectableOption { + val context = WikipediaApp.instance + val options = DonationReminderHelper.defaultReadFrequencyOptions + val optionItems = options.map { + OptionItem.Preset(it, context.resources.getQuantityString(R.plurals.donation_reminders_text_articles, + it, it)) + } + OptionItem.Custom() + + val selectedValue = if (Prefs.donationReminderConfig.articleFrequency <= 0) options.first() + else Prefs.donationReminderConfig.articleFrequency + + return SelectableOption( + selectedValue, + optionItems, + minimumAmount = minArticleFrequencyLimit, + maximumAmount = maxArticleFrequencyLimit, + defaultValue = options.first(), + displayFormatter = { + context.resources.getQuantityString(R.plurals.donation_reminders_text_articles, + it, it) + } + ) + } + + private suspend fun createDonationAmountOptions(): SelectableOption { + val donationConfig = DonationConfigHelper.getConfig() + val currencyCode = DonateUtil.currencyCode + val currentCountryCode = DonateUtil.currentCountryCode + val minimumAmount = donationConfig?.currencyMinimumDonation?.get(currencyCode) ?: 0f + + var maximumAmount = donationConfig?.currencyMaximumDonation?.get(currencyCode) ?: 0f + if (maximumAmount == 0f) { + val defaultMin = donationConfig?.currencyMinimumDonation?.get(GooglePayComponent.CURRENCY_FALLBACK) ?: 0f + if (defaultMin > 0f) { + maximumAmount = (donationConfig?.currencyMinimumDonation?.get(currencyCode) ?: 0f) / defaultMin * + (donationConfig?.currencyMaximumDonation?.get(GooglePayComponent.CURRENCY_FALLBACK) ?: 0f) + } + } + + val presets = DonationReminderHelper.currencyAmountPresets[currentCountryCode] ?: listOf(minimumAmount) + val options = presets.map { + OptionItem.Preset(it, DonateUtil.currencyFormat.format(it).replace(formatRegex, "")) + } + OptionItem.Custom() + + val selectedValue = if (Prefs.donationReminderConfig.donateAmount <= 0f) presets.first() + else Prefs.donationReminderConfig.donateAmount + + return SelectableOption( + selectedValue, + options, + minimumAmount = minimumAmount, + maximumAmount = maximumAmount, + defaultValue = presets.first(), + displayFormatter = { + DonateUtil.currencyFormat.format(it).replace(formatRegex, "") + } + ) + } +} + +data class DonationReminderUiState( + val isDonationReminderEnabled: Boolean = Prefs.donationReminderConfig.isEnabled, + val readFrequency: SelectableOption = SelectableOption( + selectedValue = Prefs.donationReminderConfig.articleFrequency, + options = emptyList(), + maximumAmount = 1000, + minimumAmount = 1, + defaultValue = 0 + ), + val donationAmount: SelectableOption = SelectableOption( + selectedValue = Prefs.donationReminderConfig.donateAmount, + options = emptyList(), + maximumAmount = 0f, + minimumAmount = 0f, + defaultValue = 0f + ), + val isLoading: Boolean = true, + val error: Throwable? = null +) + +sealed class OptionItem(val displayText: String) { + data class Preset(val value: T, val text: String) : OptionItem(text) + class Custom : OptionItem("Custom...") +} + +data class SelectableOption( + val selectedValue: T, + val options: List>, + val maximumAmount: T, + val minimumAmount: T, + val defaultValue: T, + val displayFormatter: (T) -> String = { it.toString() } +) diff --git a/app/src/main/java/org/wikipedia/edit/preview/EditPreviewFragment.kt b/app/src/main/java/org/wikipedia/edit/preview/EditPreviewFragment.kt index fb857516740..5b2e24bea53 100644 --- a/app/src/main/java/org/wikipedia/edit/preview/EditPreviewFragment.kt +++ b/app/src/main/java/org/wikipedia/edit/preview/EditPreviewFragment.kt @@ -58,6 +58,7 @@ class EditPreviewFragment : Fragment(), CommunicationBridgeListener, ReferenceDi override val toolbarMargin = 0 override val referencesGroup get() = references.referencesGroup override val selectedReferenceIndex get() = references.selectedIndex + override val messageCardHeight: Int = 0 override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { _binding = FragmentPreviewEditBinding.inflate(layoutInflater, container, false) diff --git a/app/src/main/java/org/wikipedia/page/PageFragment.kt b/app/src/main/java/org/wikipedia/page/PageFragment.kt index fe4cfa9d371..fa0a950e62a 100644 --- a/app/src/main/java/org/wikipedia/page/PageFragment.kt +++ b/app/src/main/java/org/wikipedia/page/PageFragment.kt @@ -73,6 +73,7 @@ import org.wikipedia.dataclient.okhttp.HttpStatusException import org.wikipedia.dataclient.okhttp.OkHttpWebViewClient import org.wikipedia.descriptions.DescriptionEditActivity import org.wikipedia.diff.ArticleEditDetailsActivity +import org.wikipedia.donate.donationreminder.DonationReminderHelper import org.wikipedia.edit.EditHandler import org.wikipedia.gallery.GalleryActivity import org.wikipedia.games.onthisday.OnThisDayGameMainMenuFragment @@ -178,6 +179,7 @@ class PageFragment : Fragment(), BackPressedHandler, CommunicationBridge.Communi override val isPreview get() = false override val referencesGroup get() = references?.referencesGroup override val selectedReferenceIndex get() = references?.selectedIndex ?: 0 + override val messageCardHeight get() = leadImagesHandler.getDonationReminderCardViewHeight() lateinit var sidePanelHandler: SidePanelHandler lateinit var shareHandler: ShareHandler @@ -254,6 +256,9 @@ class PageFragment : Fragment(), BackPressedHandler, CommunicationBridge.Communi if (shouldLoadFromBackstack(activity) || savedInstanceState != null) { reloadFromBackstack() } + + // adding this here, so that this call would always be before any donation reminder config updates + DonationReminderHelper.maybeShowSurveyDialog(requireActivity()) } override fun onSaveInstanceState(outState: Bundle) { @@ -307,6 +312,7 @@ class PageFragment : Fragment(), BackPressedHandler, CommunicationBridge.Communi } articleInteractionEvent?.resume() metricsPlatformArticleEventToolbarInteraction.resume() + DonationReminderHelper.maybeShowSettingSnackbar(requireActivity()) } override fun onConfigurationChanged(newConfig: Configuration) { @@ -604,6 +610,9 @@ class PageFragment : Fragment(), BackPressedHandler, CommunicationBridge.Communi MainScope().launch(CoroutineExceptionHandler { _, throwable -> L.e(throwable) }) { AppDatabase.instance.pageImagesDao().upsertForTimeSpent(it, timeSpentSec) } + + // Update the article visit for Donation Reminder + DonationReminderHelper.increaseArticleVisitCount(timeSpentSec) } } @@ -936,6 +945,7 @@ class PageFragment : Fragment(), BackPressedHandler, CommunicationBridge.Communi editHandler.setPage(model.page) webView.visibility = View.VISIBLE } + maybeShowAnnouncement() OnThisDayGameMainMenuFragment.maybeShowOnThisDayGameDialog(requireActivity(), InvokeSource.PAGE_ACTIVITY, model.title?.wikiSite ?: WikipediaApp.instance.wikiSite) diff --git a/app/src/main/java/org/wikipedia/page/leadimages/LeadImagesHandler.kt b/app/src/main/java/org/wikipedia/page/leadimages/LeadImagesHandler.kt index 6a23222b6c7..d32624702a9 100644 --- a/app/src/main/java/org/wikipedia/page/leadimages/LeadImagesHandler.kt +++ b/app/src/main/java/org/wikipedia/page/leadimages/LeadImagesHandler.kt @@ -2,6 +2,7 @@ package org.wikipedia.page.leadimages import android.net.Uri import androidx.core.app.ActivityOptionsCompat +import androidx.core.net.toUri import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.Job @@ -12,6 +13,7 @@ import org.wikipedia.Constants.ImageEditType import org.wikipedia.Constants.InvokeSource import org.wikipedia.R import org.wikipedia.WikipediaApp +import org.wikipedia.analytics.eventplatform.DonorExperienceEvent import org.wikipedia.auth.AccountUtil import org.wikipedia.bridge.JavaScriptActionHandler import org.wikipedia.commons.ImageTagsProvider @@ -20,12 +22,17 @@ import org.wikipedia.dataclient.ServiceFactory import org.wikipedia.dataclient.WikiSite import org.wikipedia.dataclient.mwapi.MwQueryPage import org.wikipedia.descriptions.DescriptionEditActivity +import org.wikipedia.donate.DonateDialog +import org.wikipedia.donate.donationreminder.DonationReminderActivity +import org.wikipedia.donate.donationreminder.DonationReminderHelper import org.wikipedia.gallery.GalleryActivity +import org.wikipedia.page.ExclusiveBottomSheetPresenter import org.wikipedia.page.PageFragment import org.wikipedia.page.PageTitle import org.wikipedia.settings.Prefs import org.wikipedia.suggestededits.PageSummaryForEdit import org.wikipedia.util.DimenUtil +import org.wikipedia.util.FeedbackUtil import org.wikipedia.util.StringUtil import org.wikipedia.util.log.L import org.wikipedia.views.ObservableWebView @@ -57,11 +64,11 @@ class LeadImagesHandler(private val parentFragment: PageFragment, // PageProperties' URL. private val leadImageUrl: String? get() { - val url = page?.run { pageProperties.leadImageUrl } ?: return null - title?.let { + return title?.let { // Conditionally add the PageTitle's URL scheme and authority if these are missing from the // PageProperties' URL. - val fullUri = Uri.parse(url) + val url = page?.run { pageProperties.leadImageUrl } ?: return@let null + val fullUri = url.toUri() var scheme: String? = it.wikiSite.scheme() var authority: String? = it.wikiSite.authority() if (fullUri.scheme != null) { @@ -75,10 +82,13 @@ class LeadImagesHandler(private val parentFragment: PageFragment, .authority(authority) .path(fullUri.path) .toString() - } ?: return null + } } - val topMargin get() = DimenUtil.roundedPxToDp((if (isLeadImageEnabled) DimenUtil.leadImageHeightForDevice(parentFragment.requireContext()) else parentFragment.toolbarMargin.toFloat()).toFloat()) + val topMargin get() = DimenUtil.roundedPxToDp( + ((if (isLeadImageEnabled) DimenUtil.leadImageHeightForDevice(parentFragment.requireContext()) else parentFragment.toolbarMargin.toFloat()).toFloat()) + + getDonationReminderCardViewHeight(true) + ) val callToActionEditLang get() = if (callToActionIsTranslation) callToActionTargetSummary?.pageTitle?.wikiSite?.languageCode else callToActionSourceSummary?.pageTitle?.wikiSite?.languageCode @@ -195,9 +205,54 @@ class LeadImagesHandler(private val parentFragment: PageFragment, } } } + + override fun donationReminderCardPositiveClicked(isInitialPrompt: Boolean) { + hideDonationReminderCard() + if (isInitialPrompt) { + DonorExperienceEvent.logDonationReminderAction( + activeInterface = "reminder_start", + action = "start_click" + ) + activity.startActivity(DonationReminderActivity.newIntent(activity)) + } else { + DonorExperienceEvent.logDonationReminderAction( + activeInterface = "reminder_milestone", + action = "donate_start_click", + campaignId = DonationReminderHelper.CAMPAIGN_ID + ) + ExclusiveBottomSheetPresenter.show(parentFragment.parentFragmentManager, DonateDialog.newInstance(fromDonationReminder = true)) + } + } + + override fun donationReminderCardNegativeClicked(isInitialPrompt: Boolean) { + hideDonationReminderCard() + if (isInitialPrompt) { + DonorExperienceEvent.logDonationReminderAction( + activeInterface = "reminder_start", + action = "nothanks_click" + ) + } else { + DonorExperienceEvent.logDonationReminderAction( + activeInterface = "reminder_milestone", + action = "notnow_click" + ) + } + if (isInitialPrompt || Prefs.donationReminderConfig.finalPromptCount == DonationReminderHelper.MAX_REMINDER_PROMPTS) { + FeedbackUtil.showMessage( + parentFragment, + R.string.donation_reminders_prompt_dismiss_snackbar + ) + } + } } } + private fun hideDonationReminderCard() { + pageHeaderView.hideDonationReminderCard() + loadLeadImage() + parentFragment.refreshPage() + } + fun hide() { pageHeaderView.hide() } @@ -206,6 +261,21 @@ class LeadImagesHandler(private val parentFragment: PageFragment, pageHeaderView.refreshCallToActionVisibility() } + fun getDonationReminderCardViewHeight(adjustBottomMargin: Boolean = false): Int { + if (pageHeaderView.donationReminderCardViewHeight == 0) { + return 0 + } + return pageHeaderView.donationReminderCardViewHeight - if (adjustBottomMargin) { + if (DimenUtil.isLandscape(activity) || !isLeadImageEnabled) { + DimenUtil.roundedDpToPx(64f) + } else { + DimenUtil.roundedDpToPx(24f) + } + } else { + 0 + } + } + fun loadLeadImage() { val url = leadImageUrl initDisplayDimensions() diff --git a/app/src/main/java/org/wikipedia/page/leadimages/PageHeaderView.kt b/app/src/main/java/org/wikipedia/page/leadimages/PageHeaderView.kt index e3b4c3ca521..70e66b161ad 100644 --- a/app/src/main/java/org/wikipedia/page/leadimages/PageHeaderView.kt +++ b/app/src/main/java/org/wikipedia/page/leadimages/PageHeaderView.kt @@ -1,28 +1,40 @@ package org.wikipedia.page.leadimages import android.content.Context -import android.net.Uri import android.util.AttributeSet import android.view.Gravity import android.view.LayoutInflater -import android.view.ViewGroup import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.net.toUri +import androidx.core.view.isVisible import org.wikipedia.R +import org.wikipedia.analytics.eventplatform.DonorExperienceEvent import org.wikipedia.databinding.ViewPageHeaderBinding +import org.wikipedia.donate.DonateUtil +import org.wikipedia.donate.donationreminder.DonationReminderHelper import org.wikipedia.settings.Prefs +import org.wikipedia.util.DateUtil import org.wikipedia.util.DimenUtil import org.wikipedia.util.GradientUtil import org.wikipedia.util.ResourceUtil import org.wikipedia.views.LinearLayoutOverWebView import org.wikipedia.views.ObservableWebView +import java.util.Date -class PageHeaderView : LinearLayoutOverWebView, ObservableWebView.OnScrollChangeListener { +class PageHeaderView(context: Context, attrs: AttributeSet? = null) : LinearLayoutOverWebView(context, attrs), ObservableWebView.OnScrollChangeListener { interface Callback { fun onImageClicked() fun onCallToActionClicked() + fun donationReminderCardPositiveClicked(isInitialPrompt: Boolean) // TODO: remove after the experiment + fun donationReminderCardNegativeClicked(isInitialPrompt: Boolean) // TODO: remove after the experiment } private val binding = ViewPageHeaderBinding.inflate(LayoutInflater.from(context), this) + private var messageCardViewHeight: Int = 0 + val donationReminderCardViewHeight get() = if (binding.donationReminderCardView.isVisible) { + // HACK: adjust the height for the message card to handle image/no image scenarios to make sure have better margins + messageCardViewHeight + if (binding.headerImageContainer.isVisible) 0 else DimenUtil.dpToPx(20f).toInt() + } else 0 var callToActionText: String? = null set(value) { field = value @@ -32,10 +44,6 @@ class PageHeaderView : LinearLayoutOverWebView, ObservableWebView.OnScrollChange var callback: Callback? = null val imageView get() = binding.viewPageHeaderImage - constructor(context: Context) : super(context) - constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) - constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) - init { binding.viewPageHeaderImageGradientBottom.background = GradientUtil.getPowerGradient(ResourceUtil.getThemedColor(context, R.attr.overlay_color), Gravity.BOTTOM) binding.viewPageHeaderImage.setOnClickListener { @@ -44,6 +52,8 @@ class PageHeaderView : LinearLayoutOverWebView, ObservableWebView.OnScrollChange binding.callToActionContainer.setOnClickListener { callback?.onCallToActionClicked() } + setDonationReminderCard() + orientation = VERTICAL } override fun onScrollChanged(oldScrollY: Int, scrollY: Int, isHumanScroll: Boolean) { @@ -64,11 +74,26 @@ class PageHeaderView : LinearLayoutOverWebView, ObservableWebView.OnScrollChange visibility = GONE } + fun hideImage() { + binding.headerImageContainer.isVisible = false + layoutParams = CoordinatorLayout.LayoutParams(LayoutParams.MATCH_PARENT, donationReminderCardViewHeight) + visibility = VISIBLE + } + + fun hideDonationReminderCard() { + binding.donationReminderCardView.isVisible = false + } + fun show() { - layoutParams = CoordinatorLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, DimenUtil.leadImageHeightForDevice(context)) + layoutParams = CoordinatorLayout.LayoutParams(LayoutParams.MATCH_PARENT, DimenUtil.leadImageHeightForDevice(context) + donationReminderCardViewHeight) visibility = VISIBLE } + fun showImage() { + binding.headerImageContainer.isVisible = true + show() + } + fun refreshCallToActionVisibility() { if (callToActionText != null && !Prefs.readingFocusModeEnabled) { binding.callToActionContainer.visibility = VISIBLE @@ -81,11 +106,113 @@ class PageHeaderView : LinearLayoutOverWebView, ObservableWebView.OnScrollChange } fun loadImage(url: String?) { + maybeShowDonationReminderCard() if (url.isNullOrEmpty()) { - hide() + hideImage() } else { - show() - binding.viewPageHeaderImage.loadImage(Uri.parse(url)) + showImage() + binding.viewPageHeaderImage.loadImage(url.toUri()) + } + } + + // TODO: remove after the experiment + private fun setDonationReminderCard() { + if (!DonationReminderHelper.isEnabled && !DonationReminderHelper.hasActiveReminder) { + return + } + Prefs.donationReminderConfig.let { config -> + val isInitialPrompt = DonationReminderHelper.maybeShowInitialDonationReminder(false) + val labelText = if (isInitialPrompt) { + context.getString(R.string.donation_reminders_initial_prompt_label) + } else { + null + } + val titleText = if (isInitialPrompt) { + context.getString(R.string.donation_reminders_initial_prompt_title) + } else { + val articleText = context.resources.getQuantityString( + R.plurals.donation_reminders_text_articles, config.articleFrequency, config.articleFrequency + ) + val donationAmount = + DonateUtil.currencyFormat.format(Prefs.donationReminderConfig.donateAmount) + context.getString(R.string.donation_reminders_prompt_title, articleText, donationAmount) + } + val messageText = if (isInitialPrompt) { + context.getString(R.string.donation_reminders_initial_prompt_message) + } else { + val dateText = DateUtil.getShortDateString(Date(config.setupTimestamp)) + val articleText = context.resources.getQuantityString( + R.plurals.donation_reminders_text_articles, config.articleFrequency, config.articleFrequency + ) + val donationAmount = + DonateUtil.currencyFormat.format(Prefs.donationReminderConfig.donateAmount) + context.getString(R.string.donation_reminders_prompt_message, dateText, articleText, donationAmount) + } + val positiveButtonText = if (isInitialPrompt) { + context.getString(R.string.donation_reminders_initial_prompt_positive_button) + } else { + context.getString(R.string.donation_reminders_prompt_positive_button) + } + val negativeButtonText = if (isInitialPrompt) { + context.getString(R.string.donation_reminders_initial_prompt_negative_button) + } else { + context.getString(R.string.donation_reminders_prompt_negative_button) + } + binding.donationReminderCardView.setLabel(labelText) + binding.donationReminderCardView.setTitle(titleText) + binding.donationReminderCardView.setMessage(messageText) + binding.donationReminderCardView.setPositiveButton(positiveButtonText) { + callback?.donationReminderCardPositiveClicked(isInitialPrompt) + DonationReminderHelper.donationReminderDismissed(isInitialPrompt) + } + binding.donationReminderCardView.setNegativeButton(negativeButtonText) { + callback?.donationReminderCardNegativeClicked(isInitialPrompt) + binding.donationReminderCardView.isVisible = false + if (!isInitialPrompt && Prefs.donationReminderConfig.finalPromptCount == DonationReminderHelper.MAX_REMINDER_PROMPTS) { + // Give the user one more chance to see the donation reminder + Prefs.donationReminderConfig = Prefs.donationReminderConfig.copy( + finalPromptCount = 1, + finalPromptActive = true + ) + return@setNegativeButton + } + DonationReminderHelper.donationReminderDismissed(isInitialPrompt) + } + + binding.donationReminderCardView.isVisible = true + visibility = INVISIBLE + binding.donationReminderCardView.post { + val widthSpec = MeasureSpec.makeMeasureSpec(resources.displayMetrics.widthPixels, MeasureSpec.EXACTLY) + val heightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED) + + binding.donationReminderCardView.measure(widthSpec, heightSpec) + // HACK: Manually adjust the height of the message card view + messageCardViewHeight = binding.donationReminderCardView.measuredHeight + DimenUtil.dpToPx(64f).toInt() + binding.donationReminderCardView.isVisible = false + visibility = GONE + } + } + } + + fun maybeShowDonationReminderCard() { + if (!DonationReminderHelper.hasActiveReminder) { + return + } + val canShowInitialDonationReminder = DonationReminderHelper.maybeShowInitialDonationReminder(true) + val canShowFinalDonationReminder = DonationReminderHelper.maybeShowDonationReminder(true) + if (canShowInitialDonationReminder) { + DonorExperienceEvent.logDonationReminderAction( + activeInterface = "reminder_start", + action = "impression" + ) + } + + if (canShowFinalDonationReminder) { + DonorExperienceEvent.logDonationReminderAction( + activeInterface = "reminder_milestone", + action = "impression" + ) } + binding.donationReminderCardView.isVisible = canShowInitialDonationReminder || canShowFinalDonationReminder } } diff --git a/app/src/main/java/org/wikipedia/settings/Prefs.kt b/app/src/main/java/org/wikipedia/settings/Prefs.kt index d8d17369bf1..072891986c8 100644 --- a/app/src/main/java/org/wikipedia/settings/Prefs.kt +++ b/app/src/main/java/org/wikipedia/settings/Prefs.kt @@ -12,6 +12,7 @@ import org.wikipedia.analytics.eventplatform.AppSessionEvent import org.wikipedia.analytics.eventplatform.StreamConfig import org.wikipedia.dataclient.WikiSite import org.wikipedia.donate.DonationResult +import org.wikipedia.donate.donationreminder.DonationReminderConfig import org.wikipedia.games.onthisday.OnThisDayGameNotificationState import org.wikipedia.json.JsonUtil import org.wikipedia.page.PageTitle @@ -828,4 +829,10 @@ object Prefs { var resetRecommendedReadingList get() = PrefsIoUtil.getBoolean(R.string.preference_key_recommended_reading_list_reset, false) set(value) = PrefsIoUtil.setBoolean(R.string.preference_key_recommended_reading_list_reset, value) + + var donationReminderConfig + get() = JsonUtil.decodeFromString( + PrefsIoUtil.getString(R.string.preference_key_donation_reminder_config, null) + ) ?: DonationReminderConfig() + set(types) = PrefsIoUtil.setString(R.string.preference_key_donation_reminder_config, JsonUtil.encodeToString(types)) } diff --git a/app/src/main/java/org/wikipedia/settings/SettingsFragment.kt b/app/src/main/java/org/wikipedia/settings/SettingsFragment.kt index e25c600891f..e83379f2eb1 100644 --- a/app/src/main/java/org/wikipedia/settings/SettingsFragment.kt +++ b/app/src/main/java/org/wikipedia/settings/SettingsFragment.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import org.wikipedia.R import org.wikipedia.concurrency.FlowEventBus +import org.wikipedia.donate.donationreminder.DonationReminderHelper import org.wikipedia.events.ReadingListsEnableSyncStatusEvent import org.wikipedia.events.ReadingListsEnabledStatusEvent import org.wikipedia.events.ReadingListsNoLongerSyncedEvent @@ -62,6 +63,8 @@ class SettingsFragment : PreferenceLoaderFragment(), MenuProvider { preferenceLoader.updateSyncReadingListsPrefSummary() preferenceLoader.updateLanguagePrefSummary() preferenceLoader.updateRecommendedReadingListSummary() + preferenceLoader.updateDonationRemindersDescription() + DonationReminderHelper.maybeShowSettingSnackbar(requireActivity()) } requireActivity().invalidateOptionsMenu() } diff --git a/app/src/main/java/org/wikipedia/settings/SettingsPreferenceLoader.kt b/app/src/main/java/org/wikipedia/settings/SettingsPreferenceLoader.kt index f3ab5cbfa11..e20edba0e60 100644 --- a/app/src/main/java/org/wikipedia/settings/SettingsPreferenceLoader.kt +++ b/app/src/main/java/org/wikipedia/settings/SettingsPreferenceLoader.kt @@ -13,6 +13,9 @@ import org.wikipedia.R import org.wikipedia.WikipediaApp import org.wikipedia.analytics.eventplatform.RecommendedReadingListEvent import org.wikipedia.auth.AccountUtil +import org.wikipedia.donate.DonateUtil +import org.wikipedia.donate.donationreminder.DonationReminderActivity +import org.wikipedia.donate.donationreminder.DonationReminderHelper import org.wikipedia.feed.configure.ConfigureActivity import org.wikipedia.login.LoginActivity import org.wikipedia.readinglist.recommended.RecommendedReadingListOnboardingActivity @@ -40,7 +43,8 @@ internal class SettingsPreferenceLoader(fragment: PreferenceFragmentCompat) : Ba true } findPreference(R.string.preference_key_customize_explore_feed).onPreferenceClickListener = Preference.OnPreferenceClickListener { - activity.startActivityForResult(ConfigureActivity.newIntent(activity, Constants.InvokeSource.NAV_MENU.ordinal), + activity.startActivityForResult( + ConfigureActivity.newIntent(activity, Constants.InvokeSource.NAV_MENU.ordinal), Constants.ACTIVITY_REQUEST_FEED_CONFIGURE) true } @@ -81,6 +85,13 @@ internal class SettingsPreferenceLoader(fragment: PreferenceFragmentCompat) : Ba (findPreference(R.string.preference_key_logout) as LogoutPreference).activity = activity } + val donationCategory = findPreference(R.string.preference_category_donations) + donationCategory.isVisible = DonationReminderHelper.isEnabled + findPreference(R.string.preference_key_donation_reminders).onPreferenceClickListener = + Preference.OnPreferenceClickListener { + activity.startActivity(DonationReminderActivity.newIntent(activity, isFromSettings = true)) + true + } if (Prefs.donationResults.isNotEmpty()) { setupDeleteLocalDonationHistoryPreference() } @@ -124,6 +135,14 @@ internal class SettingsPreferenceLoader(fragment: PreferenceFragmentCompat) : Ba findPreference(R.string.preference_key_recommended_reading_list_enabled).summary = activity.getString(summary) } + fun updateDonationRemindersDescription() { + val articleFrequency = activity.resources.getQuantityString(R.plurals.donation_reminders_text_articles, Prefs.donationReminderConfig.articleFrequency, Prefs.donationReminderConfig.articleFrequency) + val description = if (Prefs.donationReminderConfig.isEnabled) activity.getString(R.string.donation_reminders_settings_description_on, + DonateUtil.currencyFormat.format(Prefs.donationReminderConfig.donateAmount), articleFrequency) else + activity.getString(R.string.donation_reminders_settings_description_off) + findPreference(R.string.preference_key_donation_reminders).summary = description + } + private inner class SyncReadingListsListener : Preference.OnPreferenceChangeListener { override fun onPreferenceChange(preference: Preference, newValue: Any): Boolean { if (AccountUtil.isLoggedIn) { diff --git a/app/src/main/java/org/wikipedia/settings/dev/DeveloperSettingsPreferenceLoader.kt b/app/src/main/java/org/wikipedia/settings/dev/DeveloperSettingsPreferenceLoader.kt index 5634426c00f..dbf270188ff 100644 --- a/app/src/main/java/org/wikipedia/settings/dev/DeveloperSettingsPreferenceLoader.kt +++ b/app/src/main/java/org/wikipedia/settings/dev/DeveloperSettingsPreferenceLoader.kt @@ -17,6 +17,7 @@ import org.wikipedia.R import org.wikipedia.WikipediaApp import org.wikipedia.database.AppDatabase import org.wikipedia.dataclient.WikiSite +import org.wikipedia.donate.donationreminder.DonationReminderConfig import org.wikipedia.games.onthisday.OnThisDayGameNotificationManager import org.wikipedia.games.onthisday.OnThisDayGameNotificationState import org.wikipedia.history.HistoryEntry @@ -235,6 +236,20 @@ internal class DeveloperSettingsPreferenceLoader(fragment: PreferenceFragmentCom true } } + findPreference(R.string.preference_key_donation_reminders_dev_reset).onPreferenceClickListener = Preference.OnPreferenceClickListener { + Prefs.donationReminderConfig = DonationReminderConfig() + Toast.makeText(activity, "donationReminderConfig has been reset", Toast.LENGTH_SHORT).show() + fragment.requireActivity().finish() + true + } + findPreference(R.string.preference_key_donation_reminders_dev_reset_seen_date).onPreferenceClickListener = Preference.OnPreferenceClickListener { + Prefs.donationReminderConfig = Prefs.donationReminderConfig.copy( + promptLastSeen = 0 + ) + Toast.makeText(activity, "promptLastSeen has been reset", Toast.LENGTH_SHORT).show() + fragment.requireActivity().finish() + true + } } private fun setUpMediaWikiSettings() { diff --git a/app/src/main/java/org/wikipedia/util/DimenUtil.kt b/app/src/main/java/org/wikipedia/util/DimenUtil.kt index 4c7738b4c0b..dd497378db5 100644 --- a/app/src/main/java/org/wikipedia/util/DimenUtil.kt +++ b/app/src/main/java/org/wikipedia/util/DimenUtil.kt @@ -115,7 +115,11 @@ object DimenUtil { } fun leadImageHeightForDevice(context: Context): Int { - return if (isLandscape(context)) (displayWidthPx * articleHeaderViewScreenHeightRatio()).toInt() else (displayHeightPx * articleHeaderViewScreenHeightRatio()).toInt() + return (if (isLandscape(context)) { + (displayWidthPx * articleHeaderViewScreenHeightRatio()).toInt() + } else { + (displayHeightPx * articleHeaderViewScreenHeightRatio()).toInt() + }) } private fun articleHeaderViewScreenHeightRatio(): Float { diff --git a/app/src/main/java/org/wikipedia/util/FeedbackUtil.kt b/app/src/main/java/org/wikipedia/util/FeedbackUtil.kt index 37dafcd9073..3fb0d31ab40 100644 --- a/app/src/main/java/org/wikipedia/util/FeedbackUtil.kt +++ b/app/src/main/java/org/wikipedia/util/FeedbackUtil.kt @@ -5,15 +5,19 @@ import android.content.Context import android.content.Intent import android.graphics.Rect import android.net.Uri +import android.provider.Settings import android.view.Gravity import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup import android.widget.TextView import android.widget.Toast import androidx.annotation.LayoutRes import androidx.annotation.StringRes import androidx.appcompat.widget.TooltipCompat import androidx.core.app.ActivityCompat +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity @@ -147,6 +151,39 @@ object FeedbackUtil { return makeSnackbar(findBestView(activity), text, duration, wikiSite) } + fun makeNavigationAwareSnackbar(activity: Activity, text: CharSequence, wikiSite: WikiSite = WikipediaApp.instance.wikiSite): Snackbar { + val rootView = activity.findViewById(android.R.id.content) + val snackbar = makeSnackbar(rootView, text, LENGTH_DEFAULT, wikiSite) + val view = snackbar.view + val params = view.layoutParams as ViewGroup.MarginLayoutParams + // Navigation types: + // 0: 3-button navigation + // 1: 2-button navigation + // 2: Gesture navigation + val navigationType = Settings.Secure.getInt(activity.contentResolver, "navigation_mode", 0) + val windowInsets = ViewCompat.getRootWindowInsets(rootView) + + val marginForNavbar = if (windowInsets != null) { + when (navigationType) { + 0, 1 -> { + windowInsets.getInsets(WindowInsetsCompat.Type.navigationBars()) + } + else -> { + windowInsets.getInsets(WindowInsetsCompat.Type.systemGestures()) + } + } + } else null + + params.setMargins( + params.leftMargin, + params.topMargin, + params.rightMargin, + params.bottomMargin + (marginForNavbar?.bottom ?: 0) + ) + view.layoutParams = params + return snackbar + } + fun showToastOverView(view: View, text: CharSequence?, duration: Int): Toast { val toast = Toast.makeText(view.context, text, duration) val v = LayoutInflater.from(view.context).inflate(androidx.appcompat.R.layout.abc_tooltip, null) diff --git a/app/src/main/java/org/wikipedia/views/LinearLayoutOverWebView.kt b/app/src/main/java/org/wikipedia/views/LinearLayoutOverWebView.kt index 1e9c465ec63..7b23709f29d 100644 --- a/app/src/main/java/org/wikipedia/views/LinearLayoutOverWebView.kt +++ b/app/src/main/java/org/wikipedia/views/LinearLayoutOverWebView.kt @@ -8,7 +8,7 @@ import android.widget.LinearLayout import org.wikipedia.util.DimenUtil.densityScalar import kotlin.math.abs -open class LinearLayoutOverWebView : LinearLayout { +open class LinearLayoutOverWebView(context: Context?, attrs: AttributeSet? = null) : LinearLayout(context, attrs) { private lateinit var webView: ObservableWebView private var touchSlop = 0 private var viewPressed = false @@ -16,10 +16,6 @@ open class LinearLayoutOverWebView : LinearLayout { private var startY = 0f private var slopReached = false - constructor(context: Context?) : super(context) - constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) - constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) - fun setWebView(webView: ObservableWebView) { this.webView = webView touchSlop = ViewConfiguration.get(context).scaledTouchSlop diff --git a/app/src/main/java/org/wikipedia/views/WikiArticleCardView.kt b/app/src/main/java/org/wikipedia/views/WikiArticleCardView.kt index a2d87fc092f..eb26117a5f7 100644 --- a/app/src/main/java/org/wikipedia/views/WikiArticleCardView.kt +++ b/app/src/main/java/org/wikipedia/views/WikiArticleCardView.kt @@ -7,7 +7,9 @@ import android.util.Pair import android.view.LayoutInflater import android.view.View import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.net.toUri import org.wikipedia.databinding.ViewWikiArticleCardBinding +import org.wikipedia.donate.donationreminder.DonationReminderHelper import org.wikipedia.extensions.setLayoutDirectionByLang import org.wikipedia.page.PageTitle import org.wikipedia.settings.Prefs @@ -52,10 +54,12 @@ class WikiArticleCardView(context: Context, attrs: AttributeSet? = null) : Const } fun prepareForTransition(title: PageTitle) { - setImageUri(if (title.thumbUrl.isNullOrEmpty()) null else Uri.parse(title.thumbUrl)) - setTitle(title.displayText) - setDescription(title.description) - binding.articleDivider.visibility = View.GONE + setImageUri(title.thumbUrl?.toUri()) + if (!DonationReminderHelper.hasActiveReminder) { + setTitle(title.displayText) + setDescription(title.description) + } + binding.articleDivider.visibility = GONE setLayoutDirectionByLang(title.wikiSite.languageCode) } } diff --git a/app/src/main/res/drawable/credit_card_heart_24.xml b/app/src/main/res/drawable/credit_card_heart_24.xml new file mode 100644 index 00000000000..ce58e574488 --- /dev/null +++ b/app/src/main/res/drawable/credit_card_heart_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/layout/dialog_donate.xml b/app/src/main/res/layout/dialog_donate.xml index 401a5acfb6f..e452b63894d 100644 --- a/app/src/main/res/layout/dialog_donate.xml +++ b/app/src/main/res/layout/dialog_donate.xml @@ -18,27 +18,56 @@ android:paddingBottom="16dp" android:orientation="vertical"> - + android:orientation="vertical"> + + + + + + + + + android:background="?attr/selectableItemBackground" + android:gravity="center_vertical" + app:drawableStartCompat="@drawable/ic_heart_24" + app:drawableTint="?attr/progressive_color" + android:drawablePadding="24dp" + android:paddingHorizontal="16dp" + android:paddingVertical="12dp" + android:textColor="?attr/progressive_color" + android:fontFamily="sans-serif-medium" + android:text="@string/donate_gpay_dialog_pay_button" /> + android:text="@string/donation_reminders_gpay_different_amount_text" + android:visibility="gone"/> + + diff --git a/app/src/main/res/layout/dialog_feedback_options.xml b/app/src/main/res/layout/dialog_feedback_options.xml index ca09b70d069..9f7506a950b 100644 --- a/app/src/main/res/layout/dialog_feedback_options.xml +++ b/app/src/main/res/layout/dialog_feedback_options.xml @@ -40,6 +40,7 @@ android:paddingHorizontal="24dp"> diff --git a/app/src/main/res/layout/view_donation_reminder_card.xml b/app/src/main/res/layout/view_donation_reminder_card.xml new file mode 100644 index 00000000000..359546d1cb6 --- /dev/null +++ b/app/src/main/res/layout/view_donation_reminder_card.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_page_header.xml b/app/src/main/res/layout/view_page_header.xml index a53731e65ef..36e4f474d8c 100644 --- a/app/src/main/res/layout/view_page_header.xml +++ b/app/src/main/res/layout/view_page_header.xml @@ -7,9 +7,11 @@ tools:parentTag="android.widget.LinearLayout"> + android:layout_height="0dp" + android:layout_weight="1" + android:layout_marginBottom="-48dp"> + + diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 8af950d1aca..ccb35d45903 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1919,7 +1919,7 @@ voci Grazie! Ti ricorderemo di donare %1$s\ndopo aver letto %2$s voci nel corso di questo esperimento. Hai letto %1$s voci da quando hai promesso di donare %2$s! - Il giorno %1$s ci hai chiesto di ricordarti di effettuare una donazione dopo aver letto %2$s voci. Se Wikipedia ti ha fornito %3$s di conoscenza in più, unisciti al 2%% dei lettori che donano abitualmente e dai seguito alla tua promessa. + Il giorno %1$s ci hai chiesto di ricordarti di effettuare una donazione dopo aver letto %2$s. Se Wikipedia ti ha fornito %3$s di conoscenza in più, unisciti al 2%% dei lettori che donano abitualmente e dai seguito alla tua promessa. Dona ora Non ora Dona %s con Google Pay diff --git a/app/src/main/res/values-qq/strings.xml b/app/src/main/res/values-qq/strings.xml index 10becb8f816..09006d3e17a 100644 --- a/app/src/main/res/values-qq/strings.xml +++ b/app/src/main/res/values-qq/strings.xml @@ -1938,6 +1938,8 @@ Description shown on the settings screen when the donation reminder feature is enabled. The first %1$s will be replaced by the donation amount and second %2$s will be replaced by the text of the number of articles. Description shown on the settings screen when the donation reminder feature is disabled. Tooltip label that lets user know the article count is stored locally. - Snackbar message that indicates the donation has been made. - Indicates the number of articles set by the user. %d is replaced by the count. + + Indicating the number of articles that set by the user. %d is replaced by the count. + Indicating the number of articles that set by the user. %d is replaced by the count. + diff --git a/app/src/main/res/values/preference_keys.xml b/app/src/main/res/values/preference_keys.xml index 908bbfc7731..27efcd48697 100644 --- a/app/src/main/res/values/preference_keys.xml +++ b/app/src/main/res/values/preference_keys.xml @@ -194,4 +194,9 @@ recommendedReadingListNotificationSimulator recommendedReadingListNewListGenerated recommendedReadingListReset + donationReminderConfig + donationReminder + donationReminderDevReset + donationReminderDevResetSeenDate + donations diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 67e62ada8a2..313a8519b96 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -432,7 +432,7 @@ Image dimming (in Dark theme) Prefer offline content Save data usage by loading articles that are available offline rather than always loading the latest version of an article - Delete local donation history + Clear donation history Match system theme Content is available under $1 unless otherwise noted New tab @@ -2038,9 +2038,8 @@ Donation reminders are on. Wikipedia will remind you to donate %1$s every %2$s you read while this experiment runs. Donation reminders are off. Article count is stored locally on your device - Thank you! Your generosity to Wikipedia means so much to us. %d article %d articles - + \ No newline at end of file diff --git a/app/src/main/res/values/strings_no_translate.xml b/app/src/main/res/values/strings_no_translate.xml index 4942fe2e0d7..60db97c0a25 100644 --- a/app/src/main/res/values/strings_no_translate.xml +++ b/app/src/main/res/values/strings_no_translate.xml @@ -27,6 +27,7 @@ https://play.google.com/store/apps/details?id=org.wikipedia&referrer=utm_source%3Dwmfapp%26utm_medium%3Dlink%26utm_campaign%3Dyir_2025&pli=1 https://www.mediawiki.org/wiki/Wikimedia_Apps/Team/iOS/Personalized_Wikipedia_Year_in_Review/How_your_data_is_used https://www.mediawiki.org/wiki/Wikimedia_Apps/Team/Android/Rabbit_Holes + https://www.mediawiki.org/wiki/Wikimedia_Apps/Team/Android/Customizable_Donation_Reminder_Experiment @string/wikimedia diff --git a/app/src/main/res/xml/developer_preferences.xml b/app/src/main/res/xml/developer_preferences.xml index 40ce6a697b6..7984a1769fa 100644 --- a/app/src/main/res/xml/developer_preferences.xml +++ b/app/src/main/res/xml/developer_preferences.xml @@ -530,4 +530,21 @@ android:title="@string/preference_key_yir_survey_shown" /> + + + + + + + + + + diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index 9af8f5ed51c..a0e91e3b7d5 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -37,6 +37,18 @@ android:key="@string/preference_key_recommended_reading_list_enabled" android:title="@string/recommended_reading_list_settings_toggle" /> + + + + @@ -65,10 +77,5 @@ android:defaultValue="false" android:title="@string/preference_title_prefer_offline_content" android:summary="@string/preference_summary_prefer_offline_content" /> - \ No newline at end of file