diff --git a/app/src/googleplay/java/org/tasks/analytics/Firebase.kt b/app/src/googleplay/java/org/tasks/analytics/Firebase.kt index 92522fbc6..26c0bf9d9 100644 --- a/app/src/googleplay/java/org/tasks/analytics/Firebase.kt +++ b/app/src/googleplay/java/org/tasks/analytics/Firebase.kt @@ -22,7 +22,6 @@ class Firebase @Inject constructor( @param:ApplicationContext val context: Context, private val preferences: Preferences ) { - private var crashlytics: FirebaseCrashlytics? = null private var analytics: FirebaseAnalytics? = null private var remoteConfig: FirebaseRemoteConfig? = null @@ -76,11 +75,8 @@ class Firebase @Inject constructor( get() = installCooldown || preferences.lastSubscribeRequest + days("subscribe_cooldown", 30L) > currentTimeMillis() - val moreOptionsBadge: Boolean - get() = remoteConfig?.getBoolean("more_options_badge") ?: false - - val moreOptionsSolid: Boolean - get() = remoteConfig?.getBoolean("more_options_solid") ?: false + val subGroupA: Boolean + get() = remoteConfig?.getBoolean("sub_group_a") ?: false private fun days(key: String, default: Long): Long = TimeUnit.DAYS.toMillis(remoteConfig?.getLong(key) ?: default) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1517b3755..5842d8a6d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -302,7 +302,7 @@ + android:theme="@style/Tasks" /> diff --git a/app/src/main/java/org/tasks/billing/PurchaseActivity.kt b/app/src/main/java/org/tasks/billing/PurchaseActivity.kt index 2f9a7bb7a..b96102df2 100644 --- a/app/src/main/java/org/tasks/billing/PurchaseActivity.kt +++ b/app/src/main/java/org/tasks/billing/PurchaseActivity.kt @@ -4,17 +4,18 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.os.Bundle +import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf -import androidx.compose.ui.window.Dialog import androidx.lifecycle.lifecycleScope import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import org.tasks.LocalBroadcastManager import org.tasks.R 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.preferences.Preferences import org.tasks.themes.TasksTheme @@ -56,16 +57,19 @@ class PurchaseActivity : AppCompatActivity(), OnPurchasesUpdated { setContent { TasksTheme { - Dialog(onDismissRequest = { finish() }) { - PurchaseText( - nameYourPrice = nameYourPrice, - sliderPosition = sliderPosition, - github = github, - solidButton = firebase.moreOptionsSolid, - badge = firebase.moreOptionsBadge, - onDisplayed = { firebase.logEvent(R.string.event_showed_purchase_dialog) }, - subscribe = this::purchase, - ) + BackHandler { + finish() + } + SubscriptionScreen( + nameYourPrice = nameYourPrice, + sliderPosition = sliderPosition, + github = github, + hideText = firebase.subGroupA, + subscribe = this::purchase, + onBack = { finish() }, + ) + LaunchedEffect(key1 = Unit) { + firebase.logEvent(R.string.event_showed_purchase_dialog) } } } diff --git a/app/src/main/java/org/tasks/compose/Pager.kt b/app/src/main/java/org/tasks/compose/Pager.kt deleted file mode 100644 index b41b2fe77..000000000 --- a/app/src/main/java/org/tasks/compose/Pager.kt +++ /dev/null @@ -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 -) \ No newline at end of file diff --git a/app/src/main/java/org/tasks/compose/Subscription.kt b/app/src/main/java/org/tasks/compose/Subscription.kt index 1811cea70..cc4f1cf3f 100644 --- a/app/src/main/java/org/tasks/compose/Subscription.kt +++ b/app/src/main/java/org/tasks/compose/Subscription.kt @@ -8,31 +8,43 @@ 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.requiredSize +import androidx.compose.foundation.layout.size 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.shape.CircleShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Badge -import androidx.compose.material3.BadgedBox +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material3.Button 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.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold import androidx.compose.material3.Slider import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter 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.unit.dp import androidx.compose.ui.unit.sp +import kotlinx.coroutines.launch import org.tasks.R import org.tasks.compose.Constants.HALF_KEYLINE 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.themes.TasksTheme @@ -59,7 +72,7 @@ object PurchaseText { val title: Int, val icon: Int, val description: Int, - val tint: Boolean = true + val tint: Boolean = true, ) private val featureList = listOf( @@ -121,53 +134,93 @@ object PurchaseText { ) ) + @OptIn(ExperimentalMaterial3Api::class) @Composable - fun PurchaseText( + fun SubscriptionScreen( nameYourPrice: MutableState = mutableStateOf(false), sliderPosition: MutableState = mutableStateOf(0f), github: Boolean = false, - solidButton: Boolean = false, - badge: Boolean = false, - onDisplayed: () -> Unit = {}, - subscribe: (Int, Boolean) -> Unit + hideText: Boolean, + subscribe: (Int, Boolean) -> Unit, + onBack: () -> Unit, ) { - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .background(color = colorResource(R.color.content_background)), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - GreetingText(R.string.upgrade_blurb_1) - GreetingText(R.string.upgrade_blurb_2) - Spacer(Modifier.height(KEYLINE_FIRST)) - val pagerState = remember { - PagerState(maxPage = (featureList.size - 1).coerceAtLeast(0)) - } - Pager( - state = pagerState, + 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( modifier = Modifier - .fillMaxWidth() - .height(200.dp) + .padding(paddingValues) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .background(color = colorResource(R.color.content_background)), + horizontalAlignment = Alignment.CenterHorizontally, ) { - PagerItem(featureList[page], nameYourPrice.value && page == 0) - } - if (github) { - SponsorButton() - } else { - GooglePlayButtons( - nameYourPrice = nameYourPrice, - sliderPosition = sliderPosition, - pagerState = pagerState, - subscribe = subscribe, - solidButton = solidButton, - badge = badge, - ) + if (!hideText) { + GreetingText(R.string.upgrade_blurb_1) + GreetingText(R.string.upgrade_blurb_2) + } + Spacer(Modifier.height(KEYLINE_FIRST)) + val pagerState = rememberPagerState { + featureList.size + } + HorizontalPager( + state = pagerState // Optional: to control the pager's state + ) { index -> + val item = featureList[index] + PagerItem(item, nameYourPrice.value && index == 0) + } + Row( + Modifier + .wrapContentHeight() + .fillMaxWidth() + .align(Alignment.CenterHorizontally) + .padding(bottom = 8.dp), + horizontalArrangement = Arrangement.Center + ) { + 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) { + SponsorButton() + } else { + GooglePlayButtons( + nameYourPrice = nameYourPrice, + sliderPosition = sliderPosition, + pagerState = pagerState, + subscribe = subscribe, + ) + } } } - LaunchedEffect(key1 = Unit) { - onDisplayed() - } } @Composable @@ -212,47 +265,35 @@ object PurchaseText { sliderPosition: MutableState, pagerState: PagerState, subscribe: (Int, Boolean) -> Unit, - solidButton: Boolean, - badge: Boolean, ) { Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, ) { - Divider(color = MaterialTheme.colorScheme.onSurface, thickness = 0.25.dp) - Spacer(Modifier.height(KEYLINE_FIRST)) + HorizontalDivider(modifier = Modifier.padding(vertical = KEYLINE_FIRST)) if (nameYourPrice.value) { NameYourPrice(sliderPosition, subscribe) } else { TasksAccount(subscribe) } Spacer(Modifier.height(KEYLINE_FIRST)) + val scope = rememberCoroutineScope() OutlinedButton( onClick = { nameYourPrice.value = !nameYourPrice.value - pagerState.currentPage = 0 + scope.launch { + pagerState.animateScrollToPage(0) + } }, colors = ButtonDefaults.textButtonColors( - containerColor = if (solidButton) - MaterialTheme.colorScheme.primary - else - Color.Transparent + containerColor = Color.Transparent ) ) { - BadgedBox(badge = { - if (!nameYourPrice.value && badge) { - Badge() - } - }) { - Text( - text = stringResource(R.string.more_options), - color = if (solidButton) - MaterialTheme.colorScheme.onSecondary - else - MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.bodyLarge - ) - } + Text( + text = stringResource(R.string.more_options), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyLarge + ) } Text( text = stringResource(R.string.pro_free_trial), @@ -269,56 +310,54 @@ object PurchaseText { @Composable fun PagerItem( feature: CarouselItem, - disabled: Boolean = false + disabled: Boolean = false, ) { - Column { - Box( + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + Column( modifier = Modifier - .weight(1f) - .fillMaxWidth(.5f), + .width(250.dp) + .height(150.dp) + .padding(HALF_KEYLINE), + horizontalAlignment = Alignment.CenterHorizontally ) { - Column( + Image( + painter = painterResource(feature.icon), + contentDescription = null, + modifier = Modifier.requiredSize(72.dp), + alignment = Alignment.Center, + colorFilter = if (feature.tint) { + ColorFilter.tint(colorResource(R.color.icon_tint_with_alpha)) + } else { + null + } + ) + Text( + text = stringResource(feature.title), modifier = Modifier .fillMaxWidth() - .padding(HALF_KEYLINE), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Image( - painter = painterResource(feature.icon), - contentDescription = null, - modifier = Modifier.requiredSize(72.dp), - alignment = Alignment.Center, - colorFilter = if (feature.tint) { - ColorFilter.tint(colorResource(R.color.icon_tint_with_alpha)) - } else { - null - } - ) - Text( - text = stringResource(feature.title), - modifier = Modifier - .fillMaxWidth() - .padding(0.dp, 4.dp), - color = MaterialTheme.colorScheme.onBackground, - style = TextStyle( - fontWeight = FontWeight.Bold, - fontSize = 14.sp, - letterSpacing = 0.25.sp - ), - textAlign = TextAlign.Center - ) - Text( - text = stringResource(if (disabled) R.string.account_not_included else feature.description), - modifier = Modifier.fillMaxWidth(), - color = if (disabled) Color.Red else MaterialTheme.colorScheme.onBackground, - style = TextStyle( - fontWeight = if (disabled) FontWeight.Bold else FontWeight.Normal, - fontSize = 12.sp, - letterSpacing = 0.4.sp - ), - textAlign = TextAlign.Center, - ) - } + .padding(0.dp, 4.dp), + color = MaterialTheme.colorScheme.onBackground, + style = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 14.sp, + letterSpacing = 0.25.sp + ), + textAlign = TextAlign.Center + ) + Text( + text = stringResource(if (disabled) R.string.account_not_included else feature.description), + modifier = Modifier.fillMaxWidth(), + color = if (disabled) Color.Red else MaterialTheme.colorScheme.onBackground, + style = TextStyle( + fontWeight = if (disabled) FontWeight.Bold else FontWeight.Normal, + fontSize = 12.sp, + letterSpacing = 0.4.sp + ), + textAlign = TextAlign.Center, + ) } } } @@ -355,7 +394,7 @@ object PurchaseText { price: Int, monthly: Boolean = false, popperText: String = "", - onClick: (Int, Boolean) -> Unit + onClick: (Int, Boolean) -> Unit, ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Button( @@ -434,24 +473,23 @@ object PurchaseText { @Composable private fun PurchaseDialogPreview() { TasksTheme { - PurchaseText { _, _ -> } - } -} - -@Preview(showBackground = true) -@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES) -@Composable -private fun PurchaseDialogPreviewSolid() { - TasksTheme { - PurchaseText(solidButton = true) { _, _ -> } + SubscriptionScreen( + hideText = false, + subscribe = { _, _ -> }, + onBack = {}, + ) } } @Preview(showBackground = true) @Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES) @Composable -private fun PurchaseDialogPreviewBadge() { +private fun PurchaseDialogPreviewNoText() { TasksTheme { - PurchaseText(badge = true) { _, _ -> } + SubscriptionScreen( + hideText = true, + subscribe = { _, _ -> }, + onBack = {}, + ) } }