feat(billing): implement a simple RevenueCat billing system

Only for the Google Play version. The FOSS version remains free.
This commit is contained in:
Nishant Mishra
2025-10-27 11:31:07 +05:30
parent 7aed88d1c5
commit 16b4a9f125
15 changed files with 354 additions and 9 deletions

View File

@@ -0,0 +1,32 @@
/*
* Copyright (c) 2025 Nishant Mishra
*
* This file is part of Tomato - a minimalist pomodoro timer for Android.
*
* Tomato is free software: you can redistribute it and/or modify it under the terms of the GNU
* General Public License as published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* Tomato is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tomato.
* If not, see <https://www.gnu.org/licenses/>.
*/
package org.nsh07.pomodoro.billing
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Google Play implementation of BillingManager
*/
class FossBillingManager : BillingManager {
override val isPlus = MutableStateFlow(true).asStateFlow()
}
object BillingManagerProvider {
val manager: BillingManager = FossBillingManager()
}

View File

@@ -0,0 +1,27 @@
/*
* Copyright (c) 2025 Nishant Mishra
*
* This file is part of Tomato - a minimalist pomodoro timer for Android.
*
* Tomato is free software: you can redistribute it and/or modify it under the terms of the GNU
* General Public License as published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* Tomato is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tomato.
* If not, see <https://www.gnu.org/licenses/>.
*/
package org.nsh07.pomodoro.billing
import androidx.compose.runtime.Composable
@Composable
fun TomatoPlusPaywallDialog(
isPlus: Boolean,
onDismiss: () -> Unit
) {
}

View File

@@ -0,0 +1,22 @@
/*
* Copyright (c) 2025 Nishant Mishra
*
* This file is part of Tomato - a minimalist pomodoro timer for Android.
*
* Tomato is free software: you can redistribute it and/or modify it under the terms of the GNU
* General Public License as published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* Tomato is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tomato.
* If not, see <https://www.gnu.org/licenses/>.
*/
package org.nsh07.pomodoro.billing
import android.content.Context
fun initializePurchases(context: Context) {}

View File

@@ -30,12 +30,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.nsh07.pomodoro.ui.AppScreen
import org.nsh07.pomodoro.ui.settingsScreen.viewModel.SettingsViewModel
import org.nsh07.pomodoro.ui.theme.TomatoTheme
import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerViewModel
import org.nsh07.pomodoro.utils.toColor
class MainActivity : ComponentActivity() {
private val timerViewModel: TimerViewModel by viewModels(factoryProducer = { TimerViewModel.Factory })
private val settingsViewModel: SettingsViewModel by viewModels(factoryProducer = { SettingsViewModel.Factory })
private val appContainer by lazy {
@@ -62,6 +60,8 @@ class MainActivity : ComponentActivity() {
val seed = preferencesState.colorScheme.toColor()
val isPlus by settingsViewModel.isPlus.collectAsStateWithLifecycle()
TomatoTheme(
darkTheme = darkTheme,
seedColor = seed,
@@ -73,7 +73,7 @@ class MainActivity : ComponentActivity() {
}
AppScreen(
timerViewModel = timerViewModel,
isPlus = isPlus,
isAODEnabled = preferencesState.aodEnabled,
setTimerFrequency = {
appContainer.appTimerRepository.timerFrequency = it

View File

@@ -1,8 +1,26 @@
/*
* Copyright (c) 2025 Nishant Mishra
*
* This file is part of Tomato - a minimalist pomodoro timer for Android.
*
* Tomato is free software: you can redistribute it and/or modify it under the terms of the GNU
* General Public License as published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* Tomato is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tomato.
* If not, see <https://www.gnu.org/licenses/>.
*/
package org.nsh07.pomodoro
import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import org.nsh07.pomodoro.billing.initializePurchases
import org.nsh07.pomodoro.data.AppContainer
import org.nsh07.pomodoro.data.DefaultAppContainer
@@ -12,6 +30,8 @@ class TomatoApplication : Application() {
super.onCreate()
container = DefaultAppContainer(this)
initializePurchases(this)
val notificationChannel = NotificationChannel(
"timer",
getString(R.string.timer_progress),

View File

@@ -0,0 +1,24 @@
/*
* Copyright (c) 2025 Nishant Mishra
*
* This file is part of Tomato - a minimalist pomodoro timer for Android.
*
* Tomato is free software: you can redistribute it and/or modify it under the terms of the GNU
* General Public License as published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* Tomato is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tomato.
* If not, see <https://www.gnu.org/licenses/>.
*/
package org.nsh07.pomodoro.billing
import kotlinx.coroutines.flow.StateFlow
interface BillingManager {
val isPlus: StateFlow<Boolean>
}

View File

@@ -26,6 +26,8 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import kotlinx.coroutines.flow.MutableStateFlow
import org.nsh07.pomodoro.R
import org.nsh07.pomodoro.billing.BillingManager
import org.nsh07.pomodoro.billing.BillingManagerProvider
import org.nsh07.pomodoro.service.addTimerActions
import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerState
import org.nsh07.pomodoro.utils.millisecondsToStr
@@ -34,6 +36,7 @@ interface AppContainer {
val appPreferenceRepository: AppPreferenceRepository
val appStatRepository: AppStatRepository
val appTimerRepository: AppTimerRepository
val billingManager: BillingManager
val notificationManager: NotificationManagerCompat
val notificationManagerService: NotificationManager
val notificationBuilder: NotificationCompat.Builder
@@ -54,6 +57,8 @@ class DefaultAppContainer(context: Context) : AppContainer {
override val appTimerRepository: AppTimerRepository by lazy { AppTimerRepository() }
override val billingManager: BillingManager by lazy { BillingManagerProvider.manager }
override val notificationManager: NotificationManagerCompat by lazy {
NotificationManagerCompat.from(context)
}

View File

@@ -41,8 +41,10 @@ import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
@@ -54,6 +56,7 @@ import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
import androidx.window.core.layout.WindowSizeClass
import org.nsh07.pomodoro.billing.TomatoPlusPaywallDialog
import org.nsh07.pomodoro.service.TimerService
import org.nsh07.pomodoro.ui.settingsScreen.SettingsScreenRoot
import org.nsh07.pomodoro.ui.statsScreen.StatsScreenRoot
@@ -65,10 +68,11 @@ import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerViewModel
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun AppScreen(
modifier: Modifier = Modifier,
timerViewModel: TimerViewModel = viewModel(factory = TimerViewModel.Factory),
isAODEnabled: Boolean,
setTimerFrequency: (Float) -> Unit
isPlus: Boolean,
setTimerFrequency: (Float) -> Unit,
modifier: Modifier = Modifier,
timerViewModel: TimerViewModel = viewModel(factory = TimerViewModel.Factory)
) {
val context = LocalContext.current
@@ -91,6 +95,7 @@ fun AppScreen(
}
}
var showPaywall by remember { mutableStateOf(false) }
Scaffold(
bottomBar = {
@@ -218,6 +223,7 @@ fun AppScreen(
entry<Screen.Settings.Main> {
SettingsScreenRoot(
setShowPaywall = { showPaywall = it },
modifier = modifier.padding(
start = contentPadding.calculateStartPadding(layoutDirection),
end = contentPadding.calculateEndPadding(layoutDirection),
@@ -240,4 +246,5 @@ fun AppScreen(
)
}
}
if (showPaywall) TomatoPlusPaywallDialog(isPlus = isPlus) { showPaywall = false }
}

View File

@@ -17,6 +17,7 @@
package org.nsh07.pomodoro.ui.settingsScreen
import android.annotation.SuppressLint
import android.content.Intent
import android.net.Uri
import androidx.compose.animation.fadeIn
@@ -25,19 +26,26 @@ import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.MaterialTheme.typography
import androidx.compose.material3.SliderState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
@@ -50,6 +58,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.snapshots.SnapshotStateList
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.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
@@ -80,6 +89,7 @@ import org.nsh07.pomodoro.ui.theme.CustomColors.topBarColors
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreenRoot(
setShowPaywall: (Boolean) -> Unit,
modifier: Modifier = Modifier,
viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.Factory)
) {
@@ -102,6 +112,7 @@ fun SettingsScreenRoot(
viewModel.longBreakTimeTextFieldState
}
val isPlus by viewModel.isPlus.collectAsStateWithLifecycle()
val alarmEnabled by viewModel.alarmEnabled.collectAsStateWithLifecycle(true)
val vibrateEnabled by viewModel.vibrateEnabled.collectAsStateWithLifecycle(true)
val dndEnabled by viewModel.dndEnabled.collectAsStateWithLifecycle(false)
@@ -119,6 +130,7 @@ fun SettingsScreenRoot(
}
SettingsScreen(
isPlus = isPlus,
preferencesState = preferencesState,
backStack = backStack,
focusTimeInputFieldState = focusTimeInputFieldState,
@@ -143,13 +155,16 @@ fun SettingsScreenRoot(
},
onThemeChange = viewModel::saveTheme,
onColorSchemeChange = viewModel::saveColorScheme,
setShowPaywall = setShowPaywall,
modifier = modifier
)
}
@SuppressLint("LocalContextGetResourceValueCall")
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun SettingsScreen(
isPlus: Boolean,
preferencesState: PreferencesState,
backStack: SnapshotStateList<Screen.Settings>,
focusTimeInputFieldState: TextFieldState,
@@ -168,6 +183,7 @@ private fun SettingsScreen(
onAlarmSoundChanged: (Uri?) -> Unit,
onThemeChange: (String) -> Unit,
onColorSchemeChange: (Color) -> Unit,
setShowPaywall: (Boolean) -> Unit,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
@@ -217,6 +233,39 @@ private fun SettingsScreen(
) {
item { Spacer(Modifier.height(12.dp)) }
if (!isPlus) item {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clip(CircleShape)
.background(colorScheme.primary)
.padding(16.dp)
.clickable { setShowPaywall(true) }
) {
Icon(
painterResource(R.drawable.tomato_logo_notification),
null,
tint = colorScheme.onPrimary,
modifier = Modifier
.size(24.dp)
)
Spacer(Modifier.width(8.dp))
Text(
"Get Tomato+",
style = typography.titleLarge,
fontFamily = robotoFlexTopBar,
color = colorScheme.onPrimary
)
Spacer(Modifier.weight(1f))
Icon(
painterResource(R.drawable.arrow_forward_big),
null,
tint = colorScheme.onPrimary
)
}
Spacer(Modifier.height(14.dp))
}
item { AboutCard() }
item { Spacer(Modifier.height(12.dp)) }

View File

@@ -40,17 +40,21 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.nsh07.pomodoro.TomatoApplication
import org.nsh07.pomodoro.billing.BillingManager
import org.nsh07.pomodoro.data.AppPreferenceRepository
import org.nsh07.pomodoro.data.TimerRepository
import org.nsh07.pomodoro.ui.Screen
@OptIn(FlowPreview::class, ExperimentalMaterial3Api::class)
class SettingsViewModel(
private val billingManager: BillingManager,
private val preferenceRepository: AppPreferenceRepository,
private val timerRepository: TimerRepository,
) : ViewModel() {
val backStack = mutableStateListOf<Screen.Settings>(Screen.Settings.Main)
val isPlus = billingManager.isPlus
private val _preferencesState = MutableStateFlow(PreferencesState())
val preferencesState = _preferencesState.asStateFlow()
@@ -237,8 +241,10 @@ class SettingsViewModel(
val application = (this[APPLICATION_KEY] as TomatoApplication)
val appPreferenceRepository = application.container.appPreferenceRepository
val appTimerRepository = application.container.appTimerRepository
val appBillingManager = application.container.billingManager
SettingsViewModel(
billingManager = appBillingManager,
preferenceRepository = appPreferenceRepository,
timerRepository = appTimerRepository,
)

View File

@@ -0,0 +1,58 @@
/*
* Copyright (c) 2025 Nishant Mishra
*
* This file is part of Tomato - a minimalist pomodoro timer for Android.
*
* Tomato is free software: you can redistribute it and/or modify it under the terms of the GNU
* General Public License as published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* Tomato is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tomato.
* If not, see <https://www.gnu.org/licenses/>.
*/
package org.nsh07.pomodoro.billing
import android.util.Log
import com.revenuecat.purchases.Purchases
import com.revenuecat.purchases.getCustomerInfoWith
import com.revenuecat.purchases.interfaces.UpdatedCustomerInfoListener
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
private const val ENTITLEMENT_ID = "plus"
/**
* Google Play implementation of BillingManager
*/
class PlayBillingManager : BillingManager {
private val _isPlus = MutableStateFlow(false)
override val isPlus = _isPlus.asStateFlow()
private val purchases by lazy { Purchases.sharedInstance }
init {
purchases.updatedCustomerInfoListener =
UpdatedCustomerInfoListener { customerInfo ->
_isPlus.value = customerInfo.entitlements[ENTITLEMENT_ID]?.isActive == true
}
// Fetch initial customer info
purchases.getCustomerInfoWith(
onSuccess = { customerInfo ->
_isPlus.value = customerInfo.entitlements[ENTITLEMENT_ID]?.isActive == true
},
onError = { error ->
Log.e("GooglePlayPaywallManager", "Error fetching customer info: $error")
}
)
}
}
object BillingManagerProvider {
val manager: BillingManager = PlayBillingManager()
}

View File

@@ -0,0 +1,63 @@
/*
* Copyright (c) 2025 Nishant Mishra
*
* This file is part of Tomato - a minimalist pomodoro timer for Android.
*
* Tomato is free software: you can redistribute it and/or modify it under the terms of the GNU
* General Public License as published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* Tomato is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tomato.
* If not, see <https://www.gnu.org/licenses/>.
*/
package org.nsh07.pomodoro.billing
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.revenuecat.purchases.ui.revenuecatui.Paywall
import com.revenuecat.purchases.ui.revenuecatui.PaywallOptions
import com.revenuecat.purchases.ui.revenuecatui.customercenter.CustomerCenter
import org.nsh07.pomodoro.R
@Composable
fun TomatoPlusPaywallDialog(
isPlus: Boolean,
onDismiss: () -> Unit
) {
val paywallOptions = remember {
PaywallOptions.Builder(dismissRequest = onDismiss)
.build()
}
Scaffold { innerPadding ->
if (!isPlus) {
Paywall(paywallOptions)
FilledTonalIconButton(
onClick = onDismiss,
modifier = Modifier
.padding(innerPadding)
.padding(16.dp)
) {
Icon(
painterResource(R.drawable.arrow_back),
null
)
}
} else {
CustomerCenter(onDismiss = onDismiss)
}
}
}

View File

@@ -0,0 +1,30 @@
/*
* Copyright (c) 2025 Nishant Mishra
*
* This file is part of Tomato - a minimalist pomodoro timer for Android.
*
* Tomato is free software: you can redistribute it and/or modify it under the terms of the GNU
* General Public License as published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* Tomato is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tomato.
* If not, see <https://www.gnu.org/licenses/>.
*/
package org.nsh07.pomodoro.billing
import android.content.Context
import com.revenuecat.purchases.Purchases
import com.revenuecat.purchases.PurchasesConfiguration
fun initializePurchases(context: Context) {
Purchases.configure(
PurchasesConfiguration
.Builder(context, "goog_jBpRIBjTYvhKYluCqkPXSHbuFbX")
.build()
)
}