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

@@ -11,7 +11,7 @@ android {
applicationId = "com.mindmachine.mvp" applicationId = "com.mindmachine.mvp"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 4 versionCode = 5
versionName = "1.0" versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

View File

@@ -90,6 +90,7 @@ import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavType import androidx.navigation.NavType
import androidx.navigation.compose.NavHost 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.FlashColor
import com.mindmachine.mvp.session.MainViewModel import com.mindmachine.mvp.session.MainViewModel
import com.mindmachine.mvp.session.SplitFlashFrame import com.mindmachine.mvp.session.SplitFlashFrame
import kotlinx.coroutines.launch
import com.mindmachine.mvp.session.SplitFlashSequencer import com.mindmachine.mvp.session.SplitFlashSequencer
import com.mindmachine.mvp.session.flashIntervalMsToSeconds import com.mindmachine.mvp.session.flashIntervalMsToSeconds
import com.mindmachine.mvp.session.flashIntervalSecondsToMs import com.mindmachine.mvp.session.flashIntervalSecondsToMs
@@ -138,22 +140,23 @@ private val MindMachineColors = darkColorScheme(
) )
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val vm: MainViewModel by viewModels { private val settingsRepo by lazy { SettingsRepository(applicationContext) }
val settingsRepo = SettingsRepository(applicationContext) private val billingManager by lazy {
val billing = com.mindmachine.mvp.billing.BillingManager( com.mindmachine.mvp.billing.BillingManager(
applicationContext, applicationContext,
onNoAdsEntitlementChanged = { hasNoAds -> onNoAdsEntitlementChanged = { hasNoAds ->
// Cache the entitlement so UI can gate ads.
// Billing will refresh on each app start.
lifecycleScope.launch { settingsRepo.updateNoAds(hasNoAds) } lifecycleScope.launch { settingsRepo.updateNoAds(hasNoAds) }
}, },
) )
}
private val vm: MainViewModel by viewModels {
MainViewModel.Factory( MainViewModel.Factory(
settingsRepo, settingsRepo,
UserProgramRepository(applicationContext), UserProgramRepository(applicationContext),
HeadsetMonitor(applicationContext), HeadsetMonitor(applicationContext),
BinauralAudioEngine(), BinauralAudioEngine(),
billing, billingManager,
) )
} }
@@ -162,7 +165,7 @@ class MainActivity : ComponentActivity() {
MobileAds.initialize(this) {} MobileAds.initialize(this) {}
setContent { setContent {
MaterialTheme(colorScheme = MindMachineColors) { MaterialTheme(colorScheme = MindMachineColors) {
App(vm) App(vm, billingManager)
} }
} }
} }
@@ -170,7 +173,7 @@ class MainActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun App(vm: MainViewModel = viewModel()) { fun App(vm: MainViewModel = viewModel(), billingManager: com.mindmachine.mvp.billing.BillingManager) {
val nav = rememberNavController() val nav = rememberNavController()
val ui by vm.ui.collectAsStateWithLifecycle() val ui by vm.ui.collectAsStateWithLifecycle()
val lifecycle = LocalLifecycleOwner.current.lifecycle val lifecycle = LocalLifecycleOwner.current.lifecycle
@@ -214,6 +217,7 @@ fun App(vm: MainViewModel = viewModel()) {
composable("home") { composable("home") {
var confirmDeleteProgramId by remember { mutableStateOf<String?>(null) } var confirmDeleteProgramId by remember { mutableStateOf<String?>(null) }
var confirmDeleteProgramName by remember { mutableStateOf("") } var confirmDeleteProgramName by remember { mutableStateOf("") }
val interstitial = rememberInterstitialController(enabled = !ui.settings.noAds)
Column( Column(
Modifier 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 { item {
TextButton(onClick = { nav.navigate("holder") }) { Text("User Guidance/Disclaimer") } TextButton(onClick = { nav.navigate("holder") }) { Text("User Guidance/Disclaimer") }
} }
@@ -277,10 +286,18 @@ fun App(vm: MainViewModel = viewModel()) {
) { ) {
IconButton( IconButton(
onClick = { onClick = {
val go = {
val started = vm.startSessionFromProgramSelection(p.id) val started = vm.startSessionFromProgramSelection(p.id)
if (started) { if (started) {
nav.navigate("active/$SESSION_ENTRY_HOME") nav.navigate("active/$SESSION_ENTRY_HOME")
} }
}
if (ui.settings.noAds) {
go()
} else {
interstitial?.show(onDone = go) ?: go()
}
}, },
modifier = Modifier.size(48.dp) 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) { if (confirmDeleteProgramId != null) {
@@ -341,13 +363,22 @@ fun App(vm: MainViewModel = viewModel()) {
} }
} }
composable("setup") { composable("setup") {
val interstitial = rememberInterstitialController(enabled = !ui.settings.noAds)
SetupScreen( SetupScreen(
vm = vm, vm = vm,
onStart = { onStart = {
val go = {
val started = vm.startSession() val started = vm.startSession()
if (started) { if (started) {
nav.navigate("active/$SESSION_ENTRY_SETUP") 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") { composable("settings") {
SettingsScreen(vm) SettingsScreen(vm, onRemoveAds = { nav.navigate("remove_ads") })
}
composable("remove_ads") {
RemoveAdsScreen(billingManager = billingManager, vm = vm)
} }
composable("holder") { composable("holder") {
PositioningScreen() PositioningScreen()
@@ -794,7 +828,7 @@ private fun rememberSplitFlashFrame(
} }
@Composable @Composable
fun SettingsScreen(vm: MainViewModel) { fun SettingsScreen(vm: MainViewModel, onRemoveAds: () -> Unit) {
val ui by vm.ui.collectAsStateWithLifecycle() val ui by vm.ui.collectAsStateWithLifecycle()
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
Column( Column(
@@ -829,6 +863,10 @@ fun SettingsScreen(vm: MainViewModel) {
Text("Show immersive bottom progress bar", color = MaterialTheme.colorScheme.onBackground) Text("Show immersive bottom progress bar", color = MaterialTheme.colorScheme.onBackground)
} }
Button(onClick = onRemoveAds) {
Text(if (ui.settings.noAds) "Ads removed" else "Remove ads")
}
HorizontalDivider() HorizontalDivider()
Text( 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.", "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 @Composable
fun PositioningScreen() { fun PositioningScreen() {
val scrollState = rememberScrollState() val scrollState = rememberScrollState()