Monetization: ads + billing scaffolding

This commit is contained in:
Tretzi
2026-04-10 12:12:50 -05:00
parent 3cbd8138ee
commit b291c378f6
2 changed files with 103 additions and 18 deletions

View File

@@ -90,6 +90,7 @@ import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
@@ -107,6 +108,7 @@ import com.mindmachine.mvp.domain.RuntimeState
import com.mindmachine.mvp.session.FlashColor
import com.mindmachine.mvp.session.MainViewModel
import com.mindmachine.mvp.session.SplitFlashFrame
import kotlinx.coroutines.launch
import com.mindmachine.mvp.session.SplitFlashSequencer
import com.mindmachine.mvp.session.flashIntervalMsToSeconds
import com.mindmachine.mvp.session.flashIntervalSecondsToMs
@@ -138,22 +140,23 @@ private val MindMachineColors = darkColorScheme(
)
class MainActivity : ComponentActivity() {
private val vm: MainViewModel by viewModels {
val settingsRepo = SettingsRepository(applicationContext)
val billing = com.mindmachine.mvp.billing.BillingManager(
private val settingsRepo by lazy { SettingsRepository(applicationContext) }
private val billingManager by lazy {
com.mindmachine.mvp.billing.BillingManager(
applicationContext,
onNoAdsEntitlementChanged = { hasNoAds ->
// Cache the entitlement so UI can gate ads.
// Billing will refresh on each app start.
lifecycleScope.launch { settingsRepo.updateNoAds(hasNoAds) }
},
)
}
private val vm: MainViewModel by viewModels {
MainViewModel.Factory(
settingsRepo,
UserProgramRepository(applicationContext),
HeadsetMonitor(applicationContext),
BinauralAudioEngine(),
billing,
billingManager,
)
}
@@ -162,7 +165,7 @@ class MainActivity : ComponentActivity() {
MobileAds.initialize(this) {}
setContent {
MaterialTheme(colorScheme = MindMachineColors) {
App(vm)
App(vm, billingManager)
}
}
}
@@ -170,7 +173,7 @@ class MainActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun App(vm: MainViewModel = viewModel()) {
fun App(vm: MainViewModel = viewModel(), billingManager: com.mindmachine.mvp.billing.BillingManager) {
val nav = rememberNavController()
val ui by vm.ui.collectAsStateWithLifecycle()
val lifecycle = LocalLifecycleOwner.current.lifecycle
@@ -214,6 +217,7 @@ fun App(vm: MainViewModel = viewModel()) {
composable("home") {
var confirmDeleteProgramId by remember { mutableStateOf<String?>(null) }
var confirmDeleteProgramName by remember { mutableStateOf("") }
val interstitial = rememberInterstitialController(enabled = !ui.settings.noAds)
Column(
Modifier
@@ -246,7 +250,12 @@ fun App(vm: MainViewModel = viewModel()) {
}
}
)
LazyColumn(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
LazyColumn(
Modifier
.weight(1f)
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
item {
TextButton(onClick = { nav.navigate("holder") }) { Text("User Guidance/Disclaimer") }
}
@@ -277,9 +286,17 @@ fun App(vm: MainViewModel = viewModel()) {
) {
IconButton(
onClick = {
val started = vm.startSessionFromProgramSelection(p.id)
if (started) {
nav.navigate("active/$SESSION_ENTRY_HOME")
val go = {
val started = vm.startSessionFromProgramSelection(p.id)
if (started) {
nav.navigate("active/$SESSION_ENTRY_HOME")
}
}
if (ui.settings.noAds) {
go()
} else {
interstitial?.show(onDone = go) ?: go()
}
},
modifier = Modifier.size(48.dp)
@@ -312,6 +329,11 @@ fun App(vm: MainViewModel = viewModel()) {
}
}
}
AdBanner(
enabled = !ui.settings.noAds,
modifier = Modifier.fillMaxWidth()
)
}
if (confirmDeleteProgramId != null) {
@@ -341,12 +363,21 @@ fun App(vm: MainViewModel = viewModel()) {
}
}
composable("setup") {
val interstitial = rememberInterstitialController(enabled = !ui.settings.noAds)
SetupScreen(
vm = vm,
onStart = {
val started = vm.startSession()
if (started) {
nav.navigate("active/$SESSION_ENTRY_SETUP")
val go = {
val started = vm.startSession()
if (started) {
nav.navigate("active/$SESSION_ENTRY_SETUP")
}
}
if (ui.settings.noAds) {
go()
} else {
interstitial?.show(onDone = go) ?: go()
}
},
)
@@ -374,7 +405,10 @@ fun App(vm: MainViewModel = viewModel()) {
)
}
composable("settings") {
SettingsScreen(vm)
SettingsScreen(vm, onRemoveAds = { nav.navigate("remove_ads") })
}
composable("remove_ads") {
RemoveAdsScreen(billingManager = billingManager, vm = vm)
}
composable("holder") {
PositioningScreen()
@@ -794,7 +828,7 @@ private fun rememberSplitFlashFrame(
}
@Composable
fun SettingsScreen(vm: MainViewModel) {
fun SettingsScreen(vm: MainViewModel, onRemoveAds: () -> Unit) {
val ui by vm.ui.collectAsStateWithLifecycle()
val scrollState = rememberScrollState()
Column(
@@ -829,6 +863,10 @@ fun SettingsScreen(vm: MainViewModel) {
Text("Show immersive bottom progress bar", color = MaterialTheme.colorScheme.onBackground)
}
Button(onClick = onRemoveAds) {
Text(if (ui.settings.noAds) "Ads removed" else "Remove ads")
}
HorizontalDivider()
Text(
"About/Disclaimer: MindMachine is a prototype and not a medical device. Use at your own risk. For immersive visual sessions, keep your eyes closed.",
@@ -837,6 +875,53 @@ fun SettingsScreen(vm: MainViewModel) {
}
}
@Composable
fun RemoveAdsScreen(billingManager: com.mindmachine.mvp.billing.BillingManager, vm: MainViewModel) {
val ui by vm.ui.collectAsStateWithLifecycle()
val activity = LocalContext.current as? Activity
val details by billingManager.productDetails.collectAsStateWithLifecycle()
Column(
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.safeDrawingPadding()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text("Remove Ads", style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onBackground)
Text(
if (ui.settings.noAds) "You already have ads removed." else "MindMachine is free with ads. You can remove ads via subscription or one-time purchase.",
color = MaterialTheme.colorScheme.onBackground
)
Button(
enabled = activity != null && !ui.settings.noAds,
onClick = {
activity?.let { billingManager.launchPurchase(it, com.mindmachine.mvp.billing.BillingProducts.SUB_REMOVE_ADS_MONTHLY) }
}
) {
Text(details[com.mindmachine.mvp.billing.BillingProducts.SUB_REMOVE_ADS_MONTHLY]?.name ?: "Subscribe: $1/month")
}
Button(
enabled = activity != null && !ui.settings.noAds,
onClick = {
activity?.let { billingManager.launchPurchase(it, com.mindmachine.mvp.billing.BillingProducts.INAPP_REMOVE_ADS_FOREVER) }
}
) {
Text(details[com.mindmachine.mvp.billing.BillingProducts.INAPP_REMOVE_ADS_FOREVER]?.name ?: "Buy: $20 one-time")
}
TextButton(onClick = { billingManager.refresh() }) { Text("Restore / Refresh purchases") }
Text(
"Note: Purchases only work when the app is installed from Google Play (Internal testing/Production). Debug sideloaded builds wont complete real billing flows.",
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f)
)
}
}
@Composable
fun PositioningScreen() {
val scrollState = rememberScrollState()