diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e75772f..0d6023c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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" diff --git a/app/src/main/java/com/mindmachine/mvp/MainActivity.kt b/app/src/main/java/com/mindmachine/mvp/MainActivity.kt index a7989ce..4a54059 100644 --- a/app/src/main/java/com/mindmachine/mvp/MainActivity.kt +++ b/app/src/main/java/com/mindmachine/mvp/MainActivity.kt @@ -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(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()