Merge pull request #101 from nsh07/play-paywall

Add in-app purchase in the Google Play version of the app
This commit is contained in:
Nishant Mishra
2025-10-28 11:23:53 +05:30
committed by GitHub
26 changed files with 811 additions and 116 deletions

View File

@@ -0,0 +1,33 @@
/*
* 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()
override val isLoaded = MutableStateFlow(true).asStateFlow()
}
object BillingManagerProvider {
val manager: BillingManager = FossBillingManager()
}

View File

@@ -0,0 +1,85 @@
/*
* 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.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.MaterialTheme.typography
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.nsh07.pomodoro.R
import org.nsh07.pomodoro.ui.theme.AppFonts.robotoFlexTopBar
@Composable
fun TomatoPlusPaywallDialog(
isPlus: Boolean,
onDismiss: () -> Unit
) {
val uriHandler = LocalUriHandler.current
BackHandler(enabled = true, onDismiss)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxSize()
.background(colorScheme.surface)
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
painterResource(R.drawable.bmc),
null,
tint = colorScheme.onSurface
)
Spacer(Modifier.height(16.dp))
Text(
stringResource(R.string.tomato_foss),
style = typography.headlineSmall,
fontFamily = robotoFlexTopBar,
color = colorScheme.onSurface
)
Spacer(Modifier.height(8.dp))
Text(
stringResource(R.string.tomato_foss_desc, "BuyMeACoffee"),
textAlign = TextAlign.Center,
color = colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 24.dp)
)
Spacer(Modifier.height(16.dp))
Button(onClick = { uriHandler.openUri("https://coff.ee/nsh07") }) {
Text("Buy Me A Coffee")
}
}
}
}

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,20 @@ class MainActivity : ComponentActivity() {
val seed = preferencesState.colorScheme.toColor()
val isPlus by settingsViewModel.isPlus.collectAsStateWithLifecycle()
val isPurchaseStateLoaded by settingsViewModel.isPurchaseStateLoaded.collectAsStateWithLifecycle()
val isSettingsLoaded by settingsViewModel.isSettingsLoaded.collectAsStateWithLifecycle()
LaunchedEffect(isPurchaseStateLoaded, isPlus, isSettingsLoaded) {
if (isPurchaseStateLoaded && isSettingsLoaded) {
if (!isPlus) {
settingsViewModel.resetPaywalledSettings()
} else {
settingsViewModel.reloadSettings()
}
}
}
TomatoTheme(
darkTheme = darkTheme,
seedColor = seed,
@@ -73,7 +85,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,25 @@
/*
* 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>
val isLoaded: 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

@@ -23,6 +23,8 @@ import androidx.compose.animation.Crossfade
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.calculateEndPadding
@@ -41,8 +43,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 +58,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 +70,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 +97,7 @@ fun AppScreen(
}
}
var showPaywall by remember { mutableStateOf(false) }
Scaffold(
bottomBar = {
@@ -157,6 +164,7 @@ fun AppScreen(
entry<Screen.Timer> {
TimerScreen(
timerState = uiState,
isPlus = isPlus,
progress = { progress },
onAction = { action ->
when (action) {
@@ -218,6 +226,7 @@ fun AppScreen(
entry<Screen.Settings.Main> {
SettingsScreenRoot(
setShowPaywall = { showPaywall = it },
modifier = modifier.padding(
start = contentPadding.calculateStartPadding(layoutDirection),
end = contentPadding.calculateEndPadding(layoutDirection),
@@ -240,4 +249,12 @@ fun AppScreen(
)
}
}
AnimatedVisibility(
showPaywall,
enter = slideInVertically { it },
exit = slideOutVertically { it }
) {
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
@@ -67,6 +68,7 @@ import org.nsh07.pomodoro.service.TimerService
import org.nsh07.pomodoro.ui.Screen
import org.nsh07.pomodoro.ui.settingsScreen.components.AboutCard
import org.nsh07.pomodoro.ui.settingsScreen.components.ClickableListItem
import org.nsh07.pomodoro.ui.settingsScreen.components.PlusPromo
import org.nsh07.pomodoro.ui.settingsScreen.screens.AlarmSettings
import org.nsh07.pomodoro.ui.settingsScreen.screens.AppearanceSettings
import org.nsh07.pomodoro.ui.settingsScreen.screens.TimerSettings
@@ -80,6 +82,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 +105,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 +123,7 @@ fun SettingsScreenRoot(
}
SettingsScreen(
isPlus = isPlus,
preferencesState = preferencesState,
backStack = backStack,
focusTimeInputFieldState = focusTimeInputFieldState,
@@ -143,13 +148,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 +176,7 @@ private fun SettingsScreen(
onAlarmSoundChanged: (Uri?) -> Unit,
onThemeChange: (String) -> Unit,
onColorSchemeChange: (Color) -> Unit,
setShowPaywall: (Boolean) -> Unit,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
@@ -217,10 +226,20 @@ private fun SettingsScreen(
) {
item { Spacer(Modifier.height(12.dp)) }
item { AboutCard() }
if (!isPlus) item {
PlusPromo(isPlus, setShowPaywall)
Spacer(Modifier.height(14.dp))
}
item { AboutCard(isPlus) }
item { Spacer(Modifier.height(12.dp)) }
if (isPlus) item {
PlusPromo(isPlus, setShowPaywall)
Spacer(Modifier.height(14.dp))
}
itemsIndexed(settingsScreens) { index, item ->
ClickableListItem(
leadingContent = {
@@ -266,14 +285,17 @@ private fun SettingsScreen(
entry<Screen.Settings.Appearance> {
AppearanceSettings(
preferencesState = preferencesState,
isPlus = isPlus,
onBlackThemeChange = onBlackThemeChange,
onThemeChange = onThemeChange,
onColorSchemeChange = onColorSchemeChange,
setShowPaywall = setShowPaywall,
onBack = backStack::removeLastOrNull
)
}
entry<Screen.Settings.Timer> {
TimerSettings(
isPlus = isPlus,
aodEnabled = preferencesState.aodEnabled,
dndEnabled = dndEnabled,
focusTimeInputFieldState = focusTimeInputFieldState,
@@ -282,7 +304,8 @@ private fun SettingsScreen(
sessionsSliderState = sessionsSliderState,
onAodEnabledChange = onAodEnabledChange,
onDndEnabledChange = onDndEnabledChange,
onBack = backStack::removeLastOrNull
setShowPaywall = setShowPaywall,
onBack = backStack::removeLastOrNull,
)
}
}

View File

@@ -51,7 +51,10 @@ import org.nsh07.pomodoro.ui.theme.AppFonts.robotoFlexTopBar
// Taken from https://github.com/shub39/Grit/blob/master/app/src/main/java/com/shub39/grit/core/presentation/settings/ui/component/AboutApp.kt
@Composable
fun AboutCard(modifier: Modifier = Modifier) {
fun AboutCard(
isPlus: Boolean,
modifier: Modifier = Modifier
) {
val uriHandler = LocalUriHandler.current
val context = LocalContext.current
@@ -77,7 +80,8 @@ fun AboutCard(modifier: Modifier = Modifier) {
) {
Column {
Text(
text = stringResource(R.string.app_name),
if (!isPlus) stringResource(R.string.app_name)
else stringResource(R.string.app_name_plus),
style = MaterialTheme.typography.titleLarge,
fontFamily = robotoFlexTopBar
)

View File

@@ -17,9 +17,9 @@
package org.nsh07.pomodoro.ui.settingsScreen.components
import android.os.Build
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
@@ -28,6 +28,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -38,6 +40,7 @@ import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
@@ -56,6 +59,7 @@ fun ColorSchemePickerListItem(
color: Color,
items: Int,
index: Int,
isPlus: Boolean,
onColorChange: (Color) -> Unit,
modifier: Modifier = Modifier
) {
@@ -65,6 +69,7 @@ fun ColorSchemePickerListItem(
Color(0xff9fd75c), Color(0xffc1d02d), Color(0xfffabd00), Color(0xffffb86e),
Color.White
)
val zeroCorner = remember { CornerSize(0) }
Column(
modifier
@@ -76,45 +81,49 @@ fun ColorSchemePickerListItem(
}
)
) {
ListItem(
leadingContent = {
Icon(
painterResource(R.drawable.colors),
null
)
},
headlineContent = { Text("Dynamic color") },
supportingContent = { Text("Adapt theme colors from your wallpaper") },
trailingContent = {
val checked = color == colorSchemes.last()
Switch(
checked = checked,
onCheckedChange = {
if (it) onColorChange(colorSchemes.last())
else onColorChange(colorSchemes.first())
},
thumbContent = {
if (checked) {
Icon(
painter = painterResource(R.drawable.check),
contentDescription = null,
modifier = Modifier.size(SwitchDefaults.IconSize),
)
} else {
Icon(
painter = painterResource(R.drawable.clear),
contentDescription = null,
modifier = Modifier.size(SwitchDefaults.IconSize),
)
}
},
colors = switchColors
)
},
colors = listItemColors,
modifier = Modifier.clip(middleListItemShape)
)
Spacer(Modifier.height(2.dp))
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
ListItem(
leadingContent = {
Icon(
painterResource(R.drawable.colors),
null
)
},
headlineContent = { Text(stringResource(R.string.dynamic_color)) },
supportingContent = { Text(stringResource(R.string.dynamic_color_desc)) },
trailingContent = {
val checked = color == colorSchemes.last()
Switch(
checked = checked,
onCheckedChange = {
if (it) onColorChange(colorSchemes.last())
else onColorChange(colorSchemes.first())
},
enabled = isPlus,
thumbContent = {
if (checked) {
Icon(
painter = painterResource(R.drawable.check),
contentDescription = null,
modifier = Modifier.size(SwitchDefaults.IconSize),
)
} else {
Icon(
painter = painterResource(R.drawable.clear),
contentDescription = null,
modifier = Modifier.size(SwitchDefaults.IconSize),
)
}
},
colors = switchColors
)
},
colors = listItemColors,
modifier = Modifier.clip(middleListItemShape)
)
Spacer(Modifier.height(2.dp))
}
ListItem(
leadingContent = {
Icon(
@@ -131,24 +140,31 @@ fun ColorSchemePickerListItem(
)
},
colors = listItemColors,
modifier = Modifier.clip(middleListItemShape)
modifier = Modifier.clip(
RoundedCornerShape(
topStart = middleListItemShape.topStart,
topEnd = middleListItemShape.topEnd,
zeroCorner,
zeroCorner
)
)
)
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
LazyRow(
contentPadding = PaddingValues(horizontal = 48.dp),
userScrollEnabled = isPlus,
modifier = Modifier
.background(listItemColors.containerColor)
.padding(bottom = 8.dp)
) {
LazyRow(contentPadding = PaddingValues(horizontal = 48.dp)) {
items(colorSchemes.dropLast(1)) {
ColorPickerButton(
it,
it == color,
modifier = Modifier.padding(4.dp)
) {
onColorChange(it)
}
items(colorSchemes.dropLast(1)) {
ColorPickerButton(
color = it,
isSelected = it == color,
enabled = isPlus,
modifier = Modifier.padding(4.dp)
) {
onColorChange(it)
}
}
}
@@ -160,12 +176,17 @@ fun ColorSchemePickerListItem(
fun ColorPickerButton(
color: Color,
isSelected: Boolean,
enabled: Boolean,
modifier: Modifier = Modifier,
onClick: () -> Unit
) {
IconButton(
shapes = IconButtonDefaults.shapes(),
colors = IconButtonDefaults.iconButtonColors(containerColor = color),
colors = IconButtonDefaults.iconButtonColors(
containerColor = color,
disabledContainerColor = color.copy(0.3f)
),
enabled = enabled,
modifier = modifier.size(48.dp),
onClick = onClick
) {

View File

@@ -0,0 +1,51 @@
/*
* 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.ui.settingsScreen.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.MaterialTheme.typography
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
@Composable
fun PlusDivider(
setShowPaywall: (Boolean) -> Unit,
modifier: Modifier = Modifier
) {
Box(contentAlignment = Alignment.Center, modifier = modifier.padding(vertical = 14.dp)) {
HorizontalDivider(modifier = Modifier.clip(CircleShape), thickness = 4.dp)
Button(
onClick = { setShowPaywall(true) },
modifier = Modifier
.background(colorScheme.surfaceContainer)
.padding(horizontal = 8.dp)
) {
Text("Customize further with Tomato+", style = typography.titleSmall)
}
}
}

View File

@@ -0,0 +1,82 @@
/*
* 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.ui.settingsScreen.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.MaterialTheme.typography
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import org.nsh07.pomodoro.R
import org.nsh07.pomodoro.ui.theme.AppFonts.robotoFlexTopBar
@Composable
fun PlusPromo(
isPlus: Boolean,
setShowPaywall: (Boolean) -> Unit,
modifier: Modifier = Modifier
) {
val container = if (isPlus) colorScheme.surfaceBright else colorScheme.primary
val onContainer = if (isPlus) colorScheme.onSurface else colorScheme.onPrimary
val onContainerVariant = if (isPlus) colorScheme.onSurfaceVariant else colorScheme.onPrimary
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.clip(CircleShape)
.background(container)
.padding(16.dp)
.clickable { setShowPaywall(true) }
) {
Icon(
painterResource(R.drawable.tomato_logo_notification),
null,
tint = onContainerVariant,
modifier = Modifier
.size(24.dp)
)
Spacer(Modifier.width(8.dp))
Text(
if (!isPlus) stringResource(R.string.get_plus)
else stringResource(R.string.app_name_plus),
style = typography.titleLarge,
fontFamily = robotoFlexTopBar,
color = onContainer
)
Spacer(Modifier.weight(1f))
Icon(
painterResource(R.drawable.arrow_forward_big),
null,
tint = onContainerVariant
)
}
}

View File

@@ -43,6 +43,7 @@ import androidx.compose.ui.unit.dp
import org.nsh07.pomodoro.R
import org.nsh07.pomodoro.ui.theme.CustomColors.listItemColors
import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.bottomListItemShape
import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.cardShape
import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.middleListItemShape
import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.topListItemShape
@@ -69,11 +70,13 @@ fun ThemePickerListItem(
Column(
modifier
.clip(
when (index) {
0 -> topListItemShape
items - 1 -> bottomListItemShape
else -> middleListItemShape
},
if (items > 1)
when (index) {
0 -> topListItemShape
items - 1 -> bottomListItemShape
else -> middleListItemShape
}
else cardShape,
),
) {
ListItem(

View File

@@ -48,6 +48,7 @@ import androidx.compose.ui.unit.dp
import org.nsh07.pomodoro.R
import org.nsh07.pomodoro.ui.settingsScreen.SettingsSwitchItem
import org.nsh07.pomodoro.ui.settingsScreen.components.ColorSchemePickerListItem
import org.nsh07.pomodoro.ui.settingsScreen.components.PlusDivider
import org.nsh07.pomodoro.ui.settingsScreen.components.ThemePickerListItem
import org.nsh07.pomodoro.ui.settingsScreen.viewModel.PreferencesState
import org.nsh07.pomodoro.ui.theme.AppFonts.robotoFlexTopBar
@@ -55,16 +56,18 @@ import org.nsh07.pomodoro.ui.theme.CustomColors.listItemColors
import org.nsh07.pomodoro.ui.theme.CustomColors.switchColors
import org.nsh07.pomodoro.ui.theme.CustomColors.topBarColors
import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.bottomListItemShape
import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.middleListItemShape
import org.nsh07.pomodoro.ui.theme.TomatoTheme
import org.nsh07.pomodoro.utils.toColor
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun AppearanceSettings(
preferencesState: PreferencesState,
isPlus: Boolean,
onBlackThemeChange: (Boolean) -> Unit,
onThemeChange: (String) -> Unit,
onColorSchemeChange: (Color) -> Unit,
setShowPaywall: (Boolean) -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier
) {
@@ -100,22 +103,26 @@ fun AppearanceSettings(
item {
Spacer(Modifier.height(14.dp))
}
item {
ColorSchemePickerListItem(
color = preferencesState.colorScheme.toColor(),
items = 3,
index = 0,
onColorChange = onColorSchemeChange
)
}
item {
ThemePickerListItem(
theme = preferencesState.theme,
onThemeChange = onThemeChange,
items = if (isPlus) 3 else 1,
index = 0
)
}
if (!isPlus) {
item { PlusDivider(setShowPaywall) }
}
item {
ColorSchemePickerListItem(
color = preferencesState.colorScheme.toColor(),
items = 3,
index = 1,
modifier = Modifier
.clip(middleListItemShape)
index = if (isPlus) 1 else 0,
isPlus = isPlus,
onColorChange = onColorSchemeChange,
)
}
item {
@@ -136,6 +143,7 @@ fun AppearanceSettings(
Switch(
checked = item.checked,
onCheckedChange = { item.onClick(it) },
enabled = isPlus,
thumbContent = {
if (item.checked) {
Icon(
@@ -168,11 +176,15 @@ fun AppearanceSettings(
@Composable
fun AppearanceSettingsPreview() {
val preferencesState = PreferencesState()
AppearanceSettings(
preferencesState = preferencesState,
onBlackThemeChange = {},
onThemeChange = {},
onColorSchemeChange = {},
onBack = {}
)
TomatoTheme {
AppearanceSettings(
preferencesState = preferencesState,
isPlus = false,
onBlackThemeChange = {},
onThemeChange = {},
onColorSchemeChange = {},
setShowPaywall = {},
onBack = {}
)
}
}

View File

@@ -75,6 +75,7 @@ import androidx.compose.ui.unit.dp
import org.nsh07.pomodoro.R
import org.nsh07.pomodoro.ui.settingsScreen.SettingsSwitchItem
import org.nsh07.pomodoro.ui.settingsScreen.components.MinuteInputField
import org.nsh07.pomodoro.ui.settingsScreen.components.PlusDivider
import org.nsh07.pomodoro.ui.theme.AppFonts.robotoFlexTopBar
import org.nsh07.pomodoro.ui.theme.CustomColors.listItemColors
import org.nsh07.pomodoro.ui.theme.CustomColors.switchColors
@@ -88,6 +89,7 @@ import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.topListItemShape
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun TimerSettings(
isPlus: Boolean,
aodEnabled: Boolean,
dndEnabled: Boolean,
focusTimeInputFieldState: TextFieldState,
@@ -97,7 +99,8 @@ fun TimerSettings(
onAodEnabledChange: (Boolean) -> Unit,
onDndEnabledChange: (Boolean) -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
setShowPaywall: (Boolean) -> Unit
) {
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()
val context = LocalContext.current
@@ -260,7 +263,8 @@ fun TimerSettings(
)
}
item { Spacer(Modifier.height(12.dp)) }
itemsIndexed(switchItems) { index, item ->
itemsIndexed(if (isPlus) switchItems else switchItems.take(1)) { index, item ->
ListItem(
leadingContent = {
Icon(
@@ -295,15 +299,60 @@ fun TimerSettings(
},
colors = listItemColors,
modifier = Modifier.clip(
when (index) {
if (isPlus) when (index) {
0 -> topListItemShape
switchItems.size - 1 -> bottomListItemShape
else -> middleListItemShape
}
else cardShape
)
)
}
if (!isPlus) {
item {
PlusDivider(setShowPaywall)
}
itemsIndexed(switchItems.drop(1)) { index, item ->
ListItem(
leadingContent = {
Icon(
painterResource(item.icon),
contentDescription = null,
modifier = Modifier.padding(top = 4.dp)
)
},
headlineContent = { Text(stringResource(item.label)) },
supportingContent = { Text(stringResource(item.description)) },
trailingContent = {
Switch(
checked = item.checked,
onCheckedChange = { item.onClick(it) },
enabled = isPlus,
thumbContent = {
if (item.checked) {
Icon(
painter = painterResource(R.drawable.check),
contentDescription = null,
modifier = Modifier.size(SwitchDefaults.IconSize),
)
} else {
Icon(
painter = painterResource(R.drawable.clear),
contentDescription = null,
modifier = Modifier.size(SwitchDefaults.IconSize),
)
}
},
colors = switchColors
)
},
colors = listItemColors,
modifier = Modifier.clip(cardShape)
)
}
}
item {
var expanded by remember { mutableStateOf(false) }
Column(
@@ -352,14 +401,16 @@ private fun TimerSettingsPreview() {
steps = 6
)
TimerSettings(
isPlus = false,
aodEnabled = true,
dndEnabled = false,
focusTimeInputFieldState = focusTimeInputFieldState,
shortBreakTimeInputFieldState = shortBreakTimeInputFieldState,
longBreakTimeInputFieldState = longBreakTimeInputFieldState,
sessionsSliderState = sessionsSliderState,
aodEnabled = true,
dndEnabled = false,
onBack = {},
onAodEnabledChange = {},
onDndEnabledChange = {}
onDndEnabledChange = {},
setShowPaywall = {},
onBack = {}
)
}

View File

@@ -40,17 +40,25 @@ 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
val isPurchaseStateLoaded = billingManager.isLoaded
private val _isSettingsLoaded = MutableStateFlow(false)
val isSettingsLoaded = _isSettingsLoaded.asStateFlow()
private val _preferencesState = MutableStateFlow(PreferencesState())
val preferencesState = _preferencesState.asStateFlow()
@@ -90,23 +98,8 @@ class SettingsViewModel(
init {
viewModelScope.launch {
val theme = preferenceRepository.getStringPreference("theme")
?: preferenceRepository.saveStringPreference("theme", "auto")
val colorScheme = preferenceRepository.getStringPreference("color_scheme")
?: preferenceRepository.saveStringPreference("color_scheme", Color.White.toString())
val blackTheme = preferenceRepository.getBooleanPreference("black_theme")
?: preferenceRepository.saveBooleanPreference("black_theme", false)
val aodEnabled = preferenceRepository.getBooleanPreference("aod_enabled")
?: preferenceRepository.saveBooleanPreference("aod_enabled", false)
_preferencesState.update { currentState ->
currentState.copy(
theme = theme,
colorScheme = colorScheme,
blackTheme = blackTheme,
aodEnabled = aodEnabled
)
}
reloadSettings()
_isSettingsLoaded.value = true
}
}
@@ -231,14 +224,46 @@ class SettingsViewModel(
}
}
fun resetPaywalledSettings() {
_preferencesState.update { currentState ->
currentState.copy(
aodEnabled = false,
blackTheme = false,
colorScheme = Color.White.toString()
)
}
}
suspend fun reloadSettings() {
val theme = preferenceRepository.getStringPreference("theme")
?: preferenceRepository.saveStringPreference("theme", "auto")
val colorScheme = preferenceRepository.getStringPreference("color_scheme")
?: preferenceRepository.saveStringPreference("color_scheme", Color.White.toString())
val blackTheme = preferenceRepository.getBooleanPreference("black_theme")
?: preferenceRepository.saveBooleanPreference("black_theme", false)
val aodEnabled = preferenceRepository.getBooleanPreference("aod_enabled")
?: preferenceRepository.saveBooleanPreference("aod_enabled", false)
_preferencesState.update { currentState ->
currentState.copy(
theme = theme,
colorScheme = colorScheme,
blackTheme = blackTheme,
aodEnabled = aodEnabled
)
}
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
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

@@ -107,6 +107,7 @@ import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerState
@Composable
fun SharedTransitionScope.TimerScreen(
timerState: TimerState,
isPlus: Boolean,
progress: () -> Float,
onAction: (TimerAction) -> Unit,
modifier: Modifier = Modifier
@@ -159,7 +160,8 @@ fun SharedTransitionScope.TimerScreen(
when (it) {
TimerMode.BRAND ->
Text(
stringResource(R.string.app_name),
if (!isPlus) stringResource(R.string.app_name)
else stringResource(R.string.app_name_plus),
style = TextStyle(
fontFamily = robotoFlexTopBar,
fontSize = 32.sp,
@@ -552,6 +554,7 @@ fun TimerScreenPreview() {
SharedTransitionLayout {
TimerScreen(
timerState,
isPlus = true,
{ 0.3f },
{}
)

View File

@@ -81,4 +81,10 @@
<string name="sound">Sound</string>
<string name="dnd">Do Not Disturb</string>
<string name="dnd_desc">Turn on DND when running a Focus timer</string>
<string name="app_name_plus">Tomato+</string>
<string name="get_plus">Get Tomato+</string>
<string name="dynamic_color">Dynamic color</string>
<string name="dynamic_color_desc">Adapt theme colors from your wallpaper</string>
<string name="tomato_foss">Tomato FOSS</string>
<string name="tomato_foss_desc">All features are unlocked in this version. If my app made a difference in your life, please consider supporting me by donating on %1$s.</string>
</resources>

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ 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/>.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="com.android.vending.BILLING" />
</manifest>

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 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 _isLoaded = MutableStateFlow(false)
override val isLoaded = _isLoaded.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
_isLoaded.value = true
},
onError = { error ->
Log.e("GooglePlayPaywallManager", "Error fetching customer info: $error")
_isLoaded.value = true
}
)
}
}
object BillingManagerProvider {
val manager: BillingManager = PlayBillingManager()
}

View File

@@ -0,0 +1,62 @@
/*
* 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()
)
}