Monetization: ads + billing scaffolding
This commit is contained in:
@@ -11,7 +11,7 @@ android {
|
||||
applicationId = "com.mindmachine.mvp"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 4
|
||||
versionCode = 5
|
||||
versionName = "1.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
@@ -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 won’t complete real billing flows.",
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PositioningScreen() {
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
Reference in New Issue
Block a user