Monetization: ads + billing scaffolding
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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,9 +286,17 @@ fun App(vm: MainViewModel = viewModel()) {
|
|||||||
) {
|
) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
val started = vm.startSessionFromProgramSelection(p.id)
|
val go = {
|
||||||
if (started) {
|
val started = vm.startSessionFromProgramSelection(p.id)
|
||||||
nav.navigate("active/$SESSION_ENTRY_HOME")
|
if (started) {
|
||||||
|
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,12 +363,21 @@ 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 started = vm.startSession()
|
val go = {
|
||||||
if (started) {
|
val started = vm.startSession()
|
||||||
nav.navigate("active/$SESSION_ENTRY_SETUP")
|
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") {
|
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 won’t complete real billing flows.",
|
||||||
|
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PositioningScreen() {
|
fun PositioningScreen() {
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
|
|||||||
Reference in New Issue
Block a user