Update subscription screen to fix rejection

pull/3054/head
Alex Baker 1 year ago
parent a289cb80fd
commit 5fa00ea53d

@ -22,7 +22,6 @@ class Firebase @Inject constructor(
@param:ApplicationContext val context: Context, @param:ApplicationContext val context: Context,
private val preferences: Preferences private val preferences: Preferences
) { ) {
private var crashlytics: FirebaseCrashlytics? = null private var crashlytics: FirebaseCrashlytics? = null
private var analytics: FirebaseAnalytics? = null private var analytics: FirebaseAnalytics? = null
private var remoteConfig: FirebaseRemoteConfig? = null private var remoteConfig: FirebaseRemoteConfig? = null
@ -76,11 +75,8 @@ class Firebase @Inject constructor(
get() = installCooldown get() = installCooldown
|| preferences.lastSubscribeRequest + days("subscribe_cooldown", 30L) > currentTimeMillis() || preferences.lastSubscribeRequest + days("subscribe_cooldown", 30L) > currentTimeMillis()
val moreOptionsBadge: Boolean val subGroupA: Boolean
get() = remoteConfig?.getBoolean("more_options_badge") ?: false get() = remoteConfig?.getBoolean("sub_group_a") ?: false
val moreOptionsSolid: Boolean
get() = remoteConfig?.getBoolean("more_options_solid") ?: false
private fun days(key: String, default: Long): Long = private fun days(key: String, default: Long): Long =
TimeUnit.DAYS.toMillis(remoteConfig?.getLong(key) ?: default) TimeUnit.DAYS.toMillis(remoteConfig?.getLong(key) ?: default)

@ -302,7 +302,7 @@
<activity <activity
android:name=".billing.PurchaseActivity" android:name=".billing.PurchaseActivity"
android:theme="@style/TranslucentDialog" /> android:theme="@style/Tasks" />
<!-- ======================================================= Receivers = --> <!-- ======================================================= Receivers = -->

@ -4,17 +4,18 @@ import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.window.Dialog
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.tasks.LocalBroadcastManager import org.tasks.LocalBroadcastManager
import org.tasks.R import org.tasks.R
import org.tasks.analytics.Firebase import org.tasks.analytics.Firebase
import org.tasks.compose.PurchaseText.PurchaseText import org.tasks.compose.PurchaseText.SubscriptionScreen
import org.tasks.extensions.Context.toast import org.tasks.extensions.Context.toast
import org.tasks.preferences.Preferences import org.tasks.preferences.Preferences
import org.tasks.themes.TasksTheme import org.tasks.themes.TasksTheme
@ -56,16 +57,19 @@ class PurchaseActivity : AppCompatActivity(), OnPurchasesUpdated {
setContent { setContent {
TasksTheme { TasksTheme {
Dialog(onDismissRequest = { finish() }) { BackHandler {
PurchaseText( finish()
}
SubscriptionScreen(
nameYourPrice = nameYourPrice, nameYourPrice = nameYourPrice,
sliderPosition = sliderPosition, sliderPosition = sliderPosition,
github = github, github = github,
solidButton = firebase.moreOptionsSolid, hideText = firebase.subGroupA,
badge = firebase.moreOptionsBadge,
onDisplayed = { firebase.logEvent(R.string.event_showed_purchase_dialog) },
subscribe = this::purchase, subscribe = this::purchase,
onBack = { finish() },
) )
LaunchedEffect(key1 = Unit) {
firebase.logEvent(R.string.event_showed_purchase_dialog)
} }
} }
} }

@ -1,201 +0,0 @@
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.tasks.compose
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.structuralEqualityPolicy
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.ParentDataModifier
import androidx.compose.ui.unit.Density
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
/**
* This is a modified version of:
* https://gist.github.com/adamp/07d468f4bcfe632670f305ce3734f511
*/
class PagerState(
currentPage: Int = 0,
minPage: Int = 0,
maxPage: Int = 0
) {
private var _minPage by mutableStateOf(minPage)
var minPage: Int
get() = _minPage
set(value) {
_minPage = value.coerceAtMost(_maxPage)
_currentPage = _currentPage.coerceIn(_minPage, _maxPage)
}
private var _maxPage by mutableStateOf(maxPage, structuralEqualityPolicy())
var maxPage: Int
get() = _maxPage
set(value) {
_maxPage = value.coerceAtLeast(_minPage)
_currentPage = _currentPage.coerceIn(_minPage, maxPage)
}
private var _currentPage by mutableStateOf(currentPage.coerceIn(minPage, maxPage))
var currentPage: Int
get() = _currentPage
set(value) {
_currentPage = value.coerceIn(minPage, maxPage)
}
enum class SelectionState { Selected, Undecided }
var selectionState by mutableStateOf(SelectionState.Selected)
private suspend fun selectPage() {
currentPage -= currentPageOffset.roundToInt()
snapToOffset(0f)
selectionState = SelectionState.Selected
}
private var _currentPageOffset = Animatable(0f).apply {
updateBounds(-1f, 1f)
}
val currentPageOffset: Float
get() = _currentPageOffset.value
suspend fun snapToOffset(offset: Float) {
val max = if (currentPage == minPage) 0f else 1f
val min = if (currentPage == maxPage) 0f else -1f
_currentPageOffset.snapTo(offset.coerceIn(min, max))
}
suspend fun fling(velocity: Float) {
if (velocity < 0 && currentPage == maxPage) return
if (velocity > 0 && currentPage == minPage) return
_currentPageOffset.animateTo(currentPageOffset.roundToInt().toFloat())
selectPage()
}
override fun toString(): String = "PagerState{minPage=$minPage, maxPage=$maxPage, " +
"currentPage=$currentPage, currentPageOffset=$currentPageOffset}"
}
@Immutable
private data class PageData(val page: Int) : ParentDataModifier {
override fun Density.modifyParentData(parentData: Any?): Any = this@PageData
}
private val Measurable.page: Int
get() = (parentData as? PageData)?.page ?: error("no PageData for measurable $this")
@Composable
fun Pager(
state: PagerState,
modifier: Modifier = Modifier,
offscreenLimit: Int = 2,
pageContent: @Composable PagerScope.() -> Unit
) {
var pageSize by remember { mutableStateOf(0) }
val coroutineScope = rememberCoroutineScope()
Layout(
content = {
val minPage = (state.currentPage - offscreenLimit).coerceAtLeast(state.minPage)
val maxPage = (state.currentPage + offscreenLimit).coerceAtMost(state.maxPage)
for (page in minPage..maxPage) {
val pageData = PageData(page)
val scope = PagerScope(page)
key(pageData) {
Box(contentAlignment = Alignment.Center, modifier = pageData) {
scope.pageContent()
}
}
}
},
modifier = modifier.draggable(
orientation = Orientation.Horizontal,
onDragStarted = {
state.selectionState = PagerState.SelectionState.Undecided
},
onDragStopped = { velocity ->
coroutineScope.launch {
// Velocity is in pixels per second, but we deal in percentage offsets, so we
// need to scale the velocity to match
state.fling(velocity / pageSize)
}
},
state = rememberDraggableState { dy ->
coroutineScope.launch {
with(state) {
val pos = pageSize * currentPageOffset
val max = if (currentPage == minPage) 0 else pageSize * offscreenLimit
val min = if (currentPage == maxPage) 0 else -pageSize * offscreenLimit
val newPos = (pos + dy).coerceIn(min.toFloat(), max.toFloat())
snapToOffset(newPos / pageSize)
}
}
},
)
) { measurables, constraints ->
layout(constraints.maxWidth, constraints.maxHeight) {
val currentPage = state.currentPage
val offset = state.currentPageOffset
val childConstraints = constraints.copy(minWidth = 0, minHeight = 0)
measurables
.map {
it.measure(childConstraints) to it.page
}
.forEach { (placeable, page) ->
// TODO: current this centers each page. We should investigate reading
// gravity modifiers on the child, or maybe as a param to Pager.
val xCenterOffset = (constraints.maxWidth - placeable.width) / 2
val yCenterOffset = (constraints.maxHeight - placeable.height) / 2
if (currentPage == page) {
pageSize = placeable.width
}
val xItemOffset = ((page + offset - currentPage) * placeable.width).roundToInt()
placeable.place(
x = xCenterOffset + xItemOffset,
y = yCenterOffset
)
}
}
}
}
/**
* Scope for [Pager] content.
*/
class PagerScope(
val page: Int
)

@ -8,31 +8,43 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Badge import androidx.compose.material.icons.Icons
import androidx.compose.material3.BadgedBox import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Divider import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@ -45,10 +57,11 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import org.tasks.R import org.tasks.R
import org.tasks.compose.Constants.HALF_KEYLINE import org.tasks.compose.Constants.HALF_KEYLINE
import org.tasks.compose.Constants.KEYLINE_FIRST import org.tasks.compose.Constants.KEYLINE_FIRST
import org.tasks.compose.PurchaseText.PurchaseText import org.tasks.compose.PurchaseText.SubscriptionScreen
import org.tasks.extensions.Context.openUri import org.tasks.extensions.Context.openUri
import org.tasks.themes.TasksTheme import org.tasks.themes.TasksTheme
@ -59,7 +72,7 @@ object PurchaseText {
val title: Int, val title: Int,
val icon: Int, val icon: Int,
val description: Int, val description: Int,
val tint: Boolean = true val tint: Boolean = true,
) )
private val featureList = listOf( private val featureList = listOf(
@ -121,36 +134,80 @@ object PurchaseText {
) )
) )
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun PurchaseText( fun SubscriptionScreen(
nameYourPrice: MutableState<Boolean> = mutableStateOf(false), nameYourPrice: MutableState<Boolean> = mutableStateOf(false),
sliderPosition: MutableState<Float> = mutableStateOf(0f), sliderPosition: MutableState<Float> = mutableStateOf(0f),
github: Boolean = false, github: Boolean = false,
solidButton: Boolean = false, hideText: Boolean,
badge: Boolean = false, subscribe: (Int, Boolean) -> Unit,
onDisplayed: () -> Unit = {}, onBack: () -> Unit,
subscribe: (Int, Boolean) -> Unit
) { ) {
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(R.string.upgrade_to_pro),
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.titleLarge
)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
contentDescription = null
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background
)
)
},
) { paddingValues ->
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .padding(paddingValues)
.fillMaxSize()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.background(color = colorResource(R.color.content_background)), .background(color = colorResource(R.color.content_background)),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
if (!hideText) {
GreetingText(R.string.upgrade_blurb_1) GreetingText(R.string.upgrade_blurb_1)
GreetingText(R.string.upgrade_blurb_2) GreetingText(R.string.upgrade_blurb_2)
}
Spacer(Modifier.height(KEYLINE_FIRST)) Spacer(Modifier.height(KEYLINE_FIRST))
val pagerState = remember { val pagerState = rememberPagerState {
PagerState(maxPage = (featureList.size - 1).coerceAtLeast(0)) featureList.size
} }
Pager( HorizontalPager(
state = pagerState, state = pagerState // Optional: to control the pager's state
modifier = Modifier ) { index ->
val item = featureList[index]
PagerItem(item, nameYourPrice.value && index == 0)
}
Row(
Modifier
.wrapContentHeight()
.fillMaxWidth() .fillMaxWidth()
.height(200.dp) .align(Alignment.CenterHorizontally)
.padding(bottom = 8.dp),
horizontalArrangement = Arrangement.Center
) { ) {
PagerItem(featureList[page], nameYourPrice.value && page == 0) repeat(pagerState.pageCount) { iteration ->
val color = if (pagerState.currentPage == iteration) Color.DarkGray else Color.LightGray
Box(
modifier = Modifier
.padding(2.dp)
.clip(CircleShape)
.background(color)
.size(16.dp)
)
}
} }
if (github) { if (github) {
SponsorButton() SponsorButton()
@ -160,13 +217,9 @@ object PurchaseText {
sliderPosition = sliderPosition, sliderPosition = sliderPosition,
pagerState = pagerState, pagerState = pagerState,
subscribe = subscribe, subscribe = subscribe,
solidButton = solidButton,
badge = badge,
) )
} }
} }
LaunchedEffect(key1 = Unit) {
onDisplayed()
} }
} }
@ -212,48 +265,36 @@ object PurchaseText {
sliderPosition: MutableState<Float>, sliderPosition: MutableState<Float>,
pagerState: PagerState, pagerState: PagerState,
subscribe: (Int, Boolean) -> Unit, subscribe: (Int, Boolean) -> Unit,
solidButton: Boolean,
badge: Boolean,
) { ) {
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Divider(color = MaterialTheme.colorScheme.onSurface, thickness = 0.25.dp) HorizontalDivider(modifier = Modifier.padding(vertical = KEYLINE_FIRST))
Spacer(Modifier.height(KEYLINE_FIRST))
if (nameYourPrice.value) { if (nameYourPrice.value) {
NameYourPrice(sliderPosition, subscribe) NameYourPrice(sliderPosition, subscribe)
} else { } else {
TasksAccount(subscribe) TasksAccount(subscribe)
} }
Spacer(Modifier.height(KEYLINE_FIRST)) Spacer(Modifier.height(KEYLINE_FIRST))
val scope = rememberCoroutineScope()
OutlinedButton( OutlinedButton(
onClick = { onClick = {
nameYourPrice.value = !nameYourPrice.value nameYourPrice.value = !nameYourPrice.value
pagerState.currentPage = 0 scope.launch {
pagerState.animateScrollToPage(0)
}
}, },
colors = ButtonDefaults.textButtonColors( colors = ButtonDefaults.textButtonColors(
containerColor = if (solidButton) containerColor = Color.Transparent
MaterialTheme.colorScheme.primary
else
Color.Transparent
) )
) { ) {
BadgedBox(badge = {
if (!nameYourPrice.value && badge) {
Badge()
}
}) {
Text( Text(
text = stringResource(R.string.more_options), text = stringResource(R.string.more_options),
color = if (solidButton) color = MaterialTheme.colorScheme.onSurface,
MaterialTheme.colorScheme.onSecondary
else
MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyLarge style = MaterialTheme.typography.bodyLarge
) )
} }
}
Text( Text(
text = stringResource(R.string.pro_free_trial), text = stringResource(R.string.pro_free_trial),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@ -269,17 +310,16 @@ object PurchaseText {
@Composable @Composable
fun PagerItem( fun PagerItem(
feature: CarouselItem, feature: CarouselItem,
disabled: Boolean = false disabled: Boolean = false,
) { ) {
Column {
Box( Box(
modifier = Modifier modifier = Modifier.fillMaxWidth(),
.weight(1f) contentAlignment = Alignment.Center,
.fillMaxWidth(.5f),
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .width(250.dp)
.height(150.dp)
.padding(HALF_KEYLINE), .padding(HALF_KEYLINE),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
@ -321,7 +361,6 @@ object PurchaseText {
} }
} }
} }
}
@Composable @Composable
fun TasksAccount(subscribe: (Int, Boolean) -> Unit) { fun TasksAccount(subscribe: (Int, Boolean) -> Unit) {
@ -355,7 +394,7 @@ object PurchaseText {
price: Int, price: Int,
monthly: Boolean = false, monthly: Boolean = false,
popperText: String = "", popperText: String = "",
onClick: (Int, Boolean) -> Unit onClick: (Int, Boolean) -> Unit,
) { ) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Button( Button(
@ -434,24 +473,23 @@ object PurchaseText {
@Composable @Composable
private fun PurchaseDialogPreview() { private fun PurchaseDialogPreview() {
TasksTheme { TasksTheme {
PurchaseText { _, _ -> } SubscriptionScreen(
} hideText = false,
} subscribe = { _, _ -> },
onBack = {},
@Preview(showBackground = true) )
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES)
@Composable
private fun PurchaseDialogPreviewSolid() {
TasksTheme {
PurchaseText(solidButton = true) { _, _ -> }
} }
} }
@Preview(showBackground = true) @Preview(showBackground = true)
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES) @Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES)
@Composable @Composable
private fun PurchaseDialogPreviewBadge() { private fun PurchaseDialogPreviewNoText() {
TasksTheme { TasksTheme {
PurchaseText(badge = true) { _, _ -> } SubscriptionScreen(
hideText = true,
subscribe = { _, _ -> },
onBack = {},
)
} }
} }

Loading…
Cancel
Save