2026-04-10 16:14:31 -05:00
package solutions.tretter.mindmachine
2026-03-10 22:50:50 -05:00
2026-03-10 23:24:49 -05:00
import android.app.Activity
2026-04-13 18:01:01 -05:00
import android.content.Context
2026-03-10 23:24:49 -05:00
import android.content.pm.ActivityInfo
2026-04-13 18:01:01 -05:00
import android.graphics.Canvas
import android.graphics.Paint
2026-03-10 22:50:50 -05:00
import android.os.Bundle
2026-04-13 18:01:01 -05:00
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.util.Log
import android.view.View
2026-03-24 19:01:02 -05:00
import android.view.WindowManager
2026-03-10 22:50:50 -05:00
import androidx.activity.ComponentActivity
2026-03-10 23:34:07 -05:00
import androidx.activity.compose.BackHandler
2026-03-10 22:50:50 -05:00
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.background
2026-04-10 16:14:31 -05:00
import solutions.tretter.mindmachine.session.DurationSliderCard
2026-04-13 20:01:01 -05:00
import solutions.tretter.mindmachine.session.FlashSequenceEditor
2026-04-10 16:14:31 -05:00
import solutions.tretter.mindmachine.session.SetupTimelineEditor
2026-03-10 22:50:50 -05:00
import androidx.compose.foundation.clickable
2026-03-24 17:01:02 -05:00
import androidx.compose.foundation.gestures.detectTapGestures
2026-03-10 22:50:50 -05:00
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
2026-03-24 18:01:01 -05:00
import androidx.compose.foundation.layout.WindowInsets
2026-03-10 23:24:49 -05:00
import androidx.compose.foundation.layout.fillMaxHeight
2026-03-10 22:50:50 -05:00
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
2026-04-09 21:01:02 -05:00
import androidx.compose.foundation.layout.width
2026-03-24 18:01:01 -05:00
import androidx.compose.foundation.layout.navigationBars
2026-03-10 22:50:50 -05:00
import androidx.compose.foundation.layout.padding
2026-03-10 23:42:55 -05:00
import androidx.compose.foundation.layout.safeDrawingPadding
2026-03-24 18:01:01 -05:00
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
2026-03-10 23:42:55 -05:00
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
2026-03-10 22:50:50 -05:00
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
2026-04-09 21:37:37 -05:00
import androidx.compose.foundation.Image
2026-04-09 22:11:55 -05:00
import androidx.compose.foundation.Image
2026-03-24 18:01:01 -05:00
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Settings
2026-04-09 21:33:09 -05:00
import androidx.compose.ui.res.painterResource
2026-03-24 18:01:01 -05:00
import androidx.compose.material3.AlertDialog
2026-03-10 22:50:50 -05:00
import androidx.compose.material3.Button
import androidx.compose.material3.Card
2026-03-10 23:14:33 -05:00
import androidx.compose.material3.CardDefaults
2026-03-10 22:50:50 -05:00
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
2026-03-24 18:01:01 -05:00
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
2026-03-10 22:50:50 -05:00
import androidx.compose.material3.MaterialTheme
2026-03-21 23:01:02 -05:00
import androidx.compose.material3.LinearProgressIndicator
2026-03-10 22:50:50 -05:00
import androidx.compose.material3.OutlinedButton
2026-03-10 23:24:49 -05:00
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
2026-03-10 22:50:50 -05:00
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
2026-03-21 22:01:01 -05:00
import androidx.compose.material3.TextField
2026-03-10 22:50:50 -05:00
import androidx.compose.material3.TopAppBar
2026-03-10 23:14:33 -05:00
import androidx.compose.material3.darkColorScheme
2026-03-10 22:50:50 -05:00
import androidx.compose.runtime.Composable
2026-03-10 23:24:49 -05:00
import androidx.compose.runtime.DisposableEffect
2026-03-10 23:34:07 -05:00
import androidx.compose.runtime.LaunchedEffect
2026-03-10 22:50:50 -05:00
import androidx.compose.runtime.getValue
2026-03-10 23:51:55 -05:00
import androidx.compose.runtime.mutableIntStateOf
2026-03-10 22:50:50 -05:00
import androidx.compose.runtime.mutableStateOf
2026-03-10 23:14:33 -05:00
import androidx.compose.runtime.produceState
2026-03-10 22:50:50 -05:00
import androidx.compose.runtime.remember
2026-03-22 00:01:01 -05:00
import androidx.compose.runtime.rememberUpdatedState
2026-03-10 22:50:50 -05:00
import androidx.compose.runtime.setValue
2026-03-10 23:14:33 -05:00
import androidx.compose.runtime.withFrameNanos
2026-03-10 22:50:50 -05:00
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
2026-03-24 17:01:02 -05:00
import androidx.compose.ui.input.pointer.pointerInput
2026-03-10 23:24:49 -05:00
import androidx.compose.ui.platform.LocalContext
2026-03-10 22:50:50 -05:00
import androidx.compose.ui.platform.LocalLifecycleOwner
2026-04-11 13:01:03 -05:00
import androidx.compose.ui.platform.LocalView
2026-03-10 22:50:50 -05:00
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
2026-04-13 18:01:01 -05:00
import androidx.compose.ui.viewinterop.AndroidView
2026-04-09 22:11:55 -05:00
import androidx.compose.ui.layout.ContentScale
2026-03-10 23:51:55 -05:00
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
2026-03-10 22:50:50 -05:00
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.collectAsStateWithLifecycle
2026-04-10 12:12:50 -05:00
import androidx.lifecycle.lifecycleScope
2026-03-10 22:50:50 -05:00
import androidx.lifecycle.viewmodel.compose.viewModel
2026-03-24 19:01:02 -05:00
import androidx.navigation.NavType
2026-03-10 22:50:50 -05:00
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
2026-03-24 19:01:02 -05:00
import androidx.navigation.navArgument
2026-04-10 12:01:02 -05:00
import com.google.android.gms.ads.MobileAds
2026-04-10 16:14:31 -05:00
import solutions.tretter.mindmachine.audio.BinauralAudioEngine
import solutions.tretter.mindmachine.audio.HeadsetMonitor
import solutions.tretter.mindmachine.ads.AdBanner
import solutions.tretter.mindmachine.ads.rememberInterstitialController
import solutions.tretter.mindmachine.data.SettingsRepository
import solutions.tretter.mindmachine.data.UserProgramRepository
import solutions.tretter.mindmachine.domain.RuntimeState
import solutions.tretter.mindmachine.session.FlashColor
import solutions.tretter.mindmachine.session.MainViewModel
import solutions.tretter.mindmachine.session.SplitFlashFrame
2026-04-10 12:12:50 -05:00
import kotlinx.coroutines.launch
2026-04-10 16:14:31 -05:00
import solutions.tretter.mindmachine.session.flashIntervalMsToSeconds
import solutions.tretter.mindmachine.session.flashIntervalSecondsToMs
import solutions.tretter.mindmachine.session.formatDuration
import solutions.tretter.mindmachine.session.sessionProgressFraction
import solutions.tretter.mindmachine.session.shouldShowActiveControlsByDefault
import solutions.tretter.mindmachine.session.shouldUseImmersiveFullscreen
2026-03-10 23:51:55 -05:00
import kotlinx.coroutines.delay
2026-03-10 22:50:50 -05:00
import kotlin.math.roundToInt
2026-04-11 13:01:03 -05:00
import kotlin.math.roundToLong
2026-03-24 18:01:01 -05:00
import java.util.Locale
2026-03-10 22:50:50 -05:00
2026-04-09 21:01:02 -05:00
2026-03-24 19:01:02 -05:00
private const val SESSION _ENTRY _HOME = " home "
private const val SESSION _ENTRY _SETUP = " setup "
2026-03-10 23:14:33 -05:00
private val MindMachineColors = darkColorScheme (
primary = Color ( 0xFF8AB4FF ) ,
onPrimary = Color ( 0xFF001B3D ) ,
primaryContainer = Color ( 0xFF004689 ) ,
onPrimaryContainer = Color ( 0xFFD7E3FF ) ,
secondary = Color ( 0xFFFFD166 ) ,
onSecondary = Color ( 0xFF2E2300 ) ,
background = Color ( 0xFF05070D ) ,
onBackground = Color ( 0xFFF3F6FF ) ,
surface = Color ( 0xFF121621 ) ,
onSurface = Color ( 0xFFF3F6FF ) ,
error = Color ( 0xFFFF6B6B ) ,
onError = Color ( 0xFF3A0002 )
)
2026-03-10 22:50:50 -05:00
class MainActivity : ComponentActivity ( ) {
2026-04-10 12:12:50 -05:00
private val settingsRepo by lazy { SettingsRepository ( applicationContext ) }
private val billingManager by lazy {
2026-04-10 16:14:31 -05:00
solutions . tretter . mindmachine . billing . BillingManager (
2026-04-10 12:01:02 -05:00
applicationContext ,
onNoAdsEntitlementChanged = { hasNoAds ->
lifecycleScope . launch { settingsRepo . updateNoAds ( hasNoAds ) }
} ,
)
2026-04-10 12:12:50 -05:00
}
private val vm : MainViewModel by viewModels {
2026-03-21 22:01:01 -05:00
MainViewModel . Factory (
2026-04-10 12:01:02 -05:00
settingsRepo ,
2026-03-21 22:01:01 -05:00
UserProgramRepository ( applicationContext ) ,
HeadsetMonitor ( applicationContext ) ,
BinauralAudioEngine ( ) ,
2026-04-10 12:12:50 -05:00
billingManager ,
2026-03-21 22:01:01 -05:00
)
2026-03-10 22:50:50 -05:00
}
override fun onCreate ( savedInstanceState : Bundle ? ) {
super . onCreate ( savedInstanceState )
2026-04-10 12:01:02 -05:00
MobileAds . initialize ( this ) { }
2026-03-10 23:14:33 -05:00
setContent {
MaterialTheme ( colorScheme = MindMachineColors ) {
2026-04-10 12:12:50 -05:00
App ( vm , billingManager )
2026-03-10 23:14:33 -05:00
}
}
2026-03-10 22:50:50 -05:00
}
}
2026-03-10 23:03:46 -05:00
@OptIn ( ExperimentalMaterial3Api :: class )
2026-03-10 22:50:50 -05:00
@Composable
2026-04-10 16:14:31 -05:00
fun App ( vm : MainViewModel = viewModel ( ) , billingManager : solutions . tretter . mindmachine . billing . BillingManager ) {
2026-03-10 22:50:50 -05:00
val nav = rememberNavController ( )
val ui by vm . ui . collectAsStateWithLifecycle ( )
val lifecycle = LocalLifecycleOwner . current . lifecycle
2026-03-22 12:01:01 -05:00
val activity = LocalContext . current as ? Activity
2026-03-10 22:50:50 -05:00
2026-03-22 12:01:01 -05:00
DisposableEffect ( lifecycle , activity , ui . runtimeState ) {
2026-03-10 22:50:50 -05:00
val observer = LifecycleEventObserver { _ , event ->
2026-03-22 12:01:01 -05:00
val appActuallyBackgrounded = activity ?. isChangingConfigurations != true
if (
event == Lifecycle . Event . ON _STOP &&
ui . runtimeState == RuntimeState . RUNNING &&
appActuallyBackgrounded
) {
2026-03-10 22:50:50 -05:00
vm . pause ( " Session paused because app moved to background. " )
}
}
lifecycle . addObserver ( observer )
onDispose { lifecycle . removeObserver ( observer ) }
}
2026-03-22 01:01:14 -05:00
if ( ! ui . settingsInitialized ) {
Box (
modifier = Modifier
. fillMaxSize ( )
. background ( MaterialTheme . colorScheme . background )
)
return
}
2026-04-10 11:23:11 -05:00
val startRoute = if ( ui . settings . safetyAcknowledged ) " home " else " safety "
2026-03-10 23:14:33 -05:00
NavHost ( navController = nav , startDestination = startRoute , modifier = Modifier . background ( MaterialTheme . colorScheme . background ) ) {
2026-03-10 22:50:50 -05:00
composable ( " safety " ) {
SafetyScreen (
onAck = {
vm . acknowledgeSafety ( )
nav . navigate ( " home " ) { popUpTo ( 0 ) }
} ,
onHolder = { nav . navigate ( " holder " ) }
)
}
composable ( " home " ) {
2026-03-24 18:01:01 -05:00
var confirmDeleteProgramId by remember { mutableStateOf < String ? > ( null ) }
var confirmDeleteProgramName by remember { mutableStateOf ( " " ) }
2026-04-10 12:12:50 -05:00
val interstitial = rememberInterstitialController ( enabled = ! ui . settings . noAds )
2026-03-24 18:01:01 -05:00
Column (
Modifier
. fillMaxSize ( )
. safeDrawingPadding ( )
) {
2026-04-09 21:01:02 -05:00
TopAppBar (
2026-04-09 21:33:09 -05:00
title = {
Row ( verticalAlignment = Alignment . CenterVertically ) {
2026-04-09 21:37:37 -05:00
Image (
2026-04-09 21:33:09 -05:00
painter = painterResource ( R . drawable . ic _app _logo ) ,
contentDescription = " MindMachine " ,
2026-04-09 22:00:51 -05:00
modifier = Modifier . size ( 60. dp )
2026-04-09 21:33:09 -05:00
)
Spacer ( Modifier . width ( 8. dp ) )
Text ( " MindMachine " )
}
} ,
2026-04-09 21:01:02 -05:00
actions = {
2026-03-24 18:01:01 -05:00
IconButton (
onClick = {
vm . createNewProgramDraft ( )
nav . navigate ( " setup " )
}
) {
Icon ( Icons . Filled . Add , contentDescription = " Add " )
}
IconButton ( onClick = { nav . navigate ( " settings " ) } ) {
Icon ( Icons . Filled . Settings , contentDescription = " Settings " )
}
2026-04-09 21:01:02 -05:00
}
)
2026-04-10 12:12:50 -05:00
LazyColumn (
Modifier
. weight ( 1f )
. padding ( 12. dp ) ,
verticalArrangement = Arrangement . spacedBy ( 8. dp )
) {
2026-03-10 22:50:50 -05:00
item {
2026-03-24 18:01:01 -05:00
TextButton ( onClick = { nav . navigate ( " holder " ) } ) { Text ( " User Guidance/Disclaimer " ) }
2026-03-10 22:50:50 -05:00
}
items ( ui . presets ) { p ->
2026-03-10 23:14:33 -05:00
Card (
colors = CardDefaults . cardColors (
containerColor = MaterialTheme . colorScheme . surface ,
contentColor = MaterialTheme . colorScheme . onSurface
) ,
modifier = Modifier . fillMaxWidth ( ) . clickable {
vm . choosePreset ( p . id )
nav . navigate ( " setup " )
} . padding ( 4. dp )
) {
2026-03-21 22:01:01 -05:00
Row (
modifier = Modifier . fillMaxWidth ( ) . padding ( 12. dp ) ,
horizontalArrangement = Arrangement . SpaceBetween ,
verticalAlignment = Alignment . CenterVertically ,
) {
Column ( Modifier . weight ( 1f ) ) {
Text ( p . name , style = MaterialTheme . typography . titleMedium )
Text ( p . description )
2026-03-24 18:01:01 -05:00
Text ( " ${formatDuration(p.defaultDurationSec)} • ${p.visualPatternType} • binaural ${formatMaxTwoDecimals(p.binauralDifferenceHz)} " )
}
Row (
verticalAlignment = Alignment . CenterVertically ,
horizontalArrangement = Arrangement . spacedBy ( 8. dp )
) {
2026-03-24 19:01:02 -05:00
IconButton (
onClick = {
2026-04-10 12:12:50 -05:00
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 ( )
2026-03-24 19:01:02 -05:00
}
} ,
modifier = Modifier . size ( 48. dp )
) {
Icon (
imageVector = Icons . Filled . PlayArrow ,
contentDescription = " Play ${p.name} " ,
tint = Color ( 0xFF4CAF50 ) ,
modifier = Modifier . size ( 38. dp )
)
}
2026-04-15 09:44:17 -05:00
if ( p . id in ui . deletableProgramIds ) {
Box (
modifier = Modifier
. size ( 24. dp )
. clickable {
confirmDeleteProgramId = p . id
confirmDeleteProgramName = p . name
} ,
contentAlignment = Alignment . Center
) {
Icon (
imageVector = Icons . Filled . Delete ,
contentDescription = " Delete ${p.name} " ,
tint = Color ( 0xFFFF5252 ) ,
modifier = Modifier . fillMaxSize ( )
)
}
2026-03-24 18:01:01 -05:00
}
2026-03-21 22:01:01 -05:00
}
2026-03-10 22:50:50 -05:00
}
}
}
}
2026-04-10 12:12:50 -05:00
AdBanner (
enabled = ! ui . settings . noAds ,
modifier = Modifier . fillMaxWidth ( )
)
2026-03-10 22:50:50 -05:00
}
2026-03-24 18:01:01 -05:00
if ( confirmDeleteProgramId != null ) {
AlertDialog (
onDismissRequest = {
confirmDeleteProgramId = null
confirmDeleteProgramName = " "
} ,
title = { Text ( " Delete program? " ) } ,
text = { Text ( " Delete \" $confirmDeleteProgramName \" ? " ) } ,
confirmButton = {
TextButton ( onClick = {
confirmDeleteProgramId ?. let ( vm :: deleteProgram )
confirmDeleteProgramId = null
confirmDeleteProgramName = " "
} ) {
Text ( " Delete " , color = Color ( 0xFFFF5252 ) )
}
} ,
dismissButton = {
TextButton ( onClick = {
confirmDeleteProgramId = null
confirmDeleteProgramName = " "
} ) { Text ( " Cancel " ) }
}
)
}
2026-03-10 22:50:50 -05:00
}
composable ( " setup " ) {
2026-04-10 12:12:50 -05:00
val interstitial = rememberInterstitialController ( enabled = ! ui . settings . noAds )
2026-03-19 15:56:50 -05:00
SetupScreen (
vm = vm ,
onStart = {
2026-04-10 12:12:50 -05:00
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 ( )
2026-03-22 00:01:01 -05:00
}
2026-03-19 15:56:50 -05:00
} ,
)
2026-03-10 22:50:50 -05:00
}
2026-03-24 19:01:02 -05:00
composable (
route = " active/{entryPoint} " ,
arguments = listOf ( navArgument ( " entryPoint " ) { type = NavType . StringType } )
) { backStackEntry ->
val entryPoint = backStackEntry . arguments ?. getString ( " entryPoint " ) ?: SESSION _ENTRY _SETUP
2026-03-10 23:34:07 -05:00
ActiveSessionScreen (
vm = vm ,
2026-03-24 19:01:02 -05:00
onFinish = {
nav . navigate ( entryPoint ) {
popUpTo ( " active/ $entryPoint " ) { inclusive = true }
launchSingleTop = true
2026-03-22 00:01:01 -05:00
}
2026-03-24 19:01:02 -05:00
} ,
onBackToEntry = {
vm . stop ( )
nav . navigate ( entryPoint ) {
popUpTo ( " active/ $entryPoint " ) { inclusive = true }
launchSingleTop = true
}
}
)
2026-03-10 22:50:50 -05:00
}
composable ( " settings " ) {
2026-04-10 12:12:50 -05:00
SettingsScreen ( vm , onRemoveAds = { nav . navigate ( " remove_ads " ) } )
}
composable ( " remove_ads " ) {
RemoveAdsScreen ( billingManager = billingManager , vm = vm )
2026-03-10 22:50:50 -05:00
}
composable ( " holder " ) {
2026-04-09 20:51:39 -05:00
PositioningScreen ( )
2026-03-10 22:50:50 -05:00
}
}
}
@Composable
fun SimpleScreen ( title : String , subtitle : String , actions : @Composable ColumnScope . ( ) -> Unit ) {
Column (
2026-03-24 18:01:01 -05:00
modifier = Modifier
. fillMaxSize ( )
. background ( MaterialTheme . colorScheme . background )
. safeDrawingPadding ( )
. padding ( 16. dp ) ,
2026-03-10 22:50:50 -05:00
verticalArrangement = Arrangement . spacedBy ( 12. dp ) ,
) {
2026-03-10 23:14:33 -05:00
Text ( title , style = MaterialTheme . typography . headlineMedium , color = MaterialTheme . colorScheme . onBackground )
Text ( subtitle , color = MaterialTheme . colorScheme . onBackground )
2026-03-10 22:50:50 -05:00
actions ( )
}
}
@Composable
fun SafetyScreen ( onAck : ( ) -> Unit , onHolder : ( ) -> Unit ) {
var checked by remember { mutableStateOf ( false ) }
2026-03-24 18:01:01 -05:00
Column (
Modifier
. fillMaxSize ( )
. background ( MaterialTheme . colorScheme . background )
. safeDrawingPadding ( )
. padding ( 16. dp )
) {
2026-03-10 23:14:33 -05:00
Text ( " Read before using MindMachine " , style = MaterialTheme . typography . headlineSmall , color = MaterialTheme . colorScheme . onBackground )
2026-03-10 22:50:50 -05:00
Spacer ( Modifier . height ( 12. dp ) )
2026-03-10 23:14:33 -05:00
Text (
2026-04-09 20:51:39 -05:00
" Flashing lights may be unsafe for people with epilepsy, seizure sensitivity, or migraine triggers. \n \n Do not use while driving, walking, cycling, or operating machinery. \n \n For immersive visual sessions, keep your eyes closed. Do not stare at the screen. \n \n Do not place the phone directly on your eyes. Avoid pressure on face/eyes and ensure comfortable breathing/airflow. \n \n Use at your own risk. This app is a prototype and not a medical device. \n \n Stop immediately for discomfort, dizziness, headache, nausea, anxiety, or eye strain. \n \n Binaural mode requires stereo headphones. " ,
2026-03-10 23:14:33 -05:00
color = MaterialTheme . colorScheme . onBackground
)
2026-03-10 22:50:50 -05:00
Row ( verticalAlignment = Alignment . CenterVertically ) {
Checkbox ( checked = checked , onCheckedChange = { checked = it } )
2026-03-10 23:14:33 -05:00
Text ( " I understand the risks and will stop immediately if I feel discomfort. " , color = MaterialTheme . colorScheme . onBackground )
2026-03-10 22:50:50 -05:00
}
Button ( onClick = onAck , enabled = checked , modifier = Modifier . semantics { contentDescription = " I Understand " } ) { Text ( " I Understand " ) }
2026-04-09 20:51:39 -05:00
TextButton ( onClick = onHolder ) { Text ( " Positioning Guidance " ) }
2026-03-10 22:50:50 -05:00
}
}
@Composable
2026-03-19 15:56:50 -05:00
fun SetupScreen (
vm : MainViewModel ,
onStart : ( ) -> Unit ,
) {
2026-03-10 22:50:50 -05:00
val ui by vm . ui . collectAsStateWithLifecycle ( )
2026-03-10 23:42:55 -05:00
val scrollState = rememberScrollState ( )
val containerModifier = Modifier
. fillMaxSize ( )
. background ( MaterialTheme . colorScheme . background )
2026-03-24 18:01:01 -05:00
. windowInsetsPadding ( WindowInsets . statusBars )
. windowInsetsPadding ( WindowInsets . navigationBars )
2026-03-10 23:42:55 -05:00
. then ( if ( setupScreenUsesScrollableContainer ( ) ) Modifier . verticalScroll ( scrollState ) else Modifier )
. padding ( horizontal = 16. dp , vertical = 12. dp )
2026-03-21 22:01:01 -05:00
var programName by remember ( ui . selectedPreset . id ) { mutableStateOf ( ui . selectedPreset . name ) }
2026-03-22 01:01:14 -05:00
val hasUnsavedChanges = setupScreenHasUnsavedChanges (
hasUnsavedTimelineChanges = ui . hasUnsavedChanges ,
editedProgramName = programName ,
savedProgramName = ui . savedProgramName ,
)
2026-03-10 23:14:33 -05:00
Column (
2026-03-19 20:01:01 -05:00
modifier = containerModifier ,
verticalArrangement = Arrangement . spacedBy ( 14. dp ) ,
2026-03-10 23:14:33 -05:00
) {
2026-03-19 20:01:01 -05:00
Text (
ui . selectedPreset . name ,
style = MaterialTheme . typography . headlineSmall ,
color = MaterialTheme . colorScheme . onBackground ,
)
2026-03-21 22:01:01 -05:00
TextField (
value = programName ,
onValueChange = { programName = it } ,
label = { Text ( " Program name " ) } ,
modifier = Modifier . fillMaxWidth ( ) ,
)
2026-03-19 20:01:01 -05:00
DurationSliderCard (
durationSec = ui . timeline . durationSec ,
onDurationSecChanged = { vm . setDurationSec ( it ) } ,
)
// Integrated timeline editor (no separate screen).
SetupTimelineEditor (
state = ui . timeline ,
onStateChanged = vm :: updateTimeline ,
2026-03-22 00:01:01 -05:00
onCurveGranularityChanged = vm :: setCurveGranularitySec ,
2026-03-19 20:01:01 -05:00
)
2026-04-13 20:01:01 -05:00
FlashSequenceEditor (
sequence = ui . timeline . flashSequence ,
onSequenceChanged = { sequence -> vm . updateTimeline ( ui . timeline . withFlashSequence ( sequence ) ) } ,
)
2026-04-13 19:01:01 -05:00
2026-03-21 22:01:01 -05:00
Row ( horizontalArrangement = Arrangement . spacedBy ( 8. dp ) , modifier = Modifier . fillMaxWidth ( ) ) {
2026-03-22 01:01:14 -05:00
if ( hasUnsavedChanges ) {
2026-04-15 09:44:17 -05:00
OutlinedButton (
onClick = { vm . saveCurrentProgram ( programName ) } ,
modifier = Modifier . weight ( 1f ) . height ( 52. dp )
) {
2026-03-22 01:01:14 -05:00
Text ( " Save " )
}
2026-04-15 10:26:46 -05:00
} else {
Button ( onClick = onStart , modifier = Modifier . weight ( 1f ) . height ( 52. dp ) ) { Text ( " Start " ) }
2026-03-21 22:01:01 -05:00
}
}
if ( ui . error != null ) Text ( ui . error !! , color = if ( ui . error !! . startsWith ( " Saved " ) ) Color ( 0xFF72E39A ) else MaterialTheme . colorScheme . error )
2026-03-10 23:42:55 -05:00
Spacer ( Modifier . height ( 8. dp ) )
2026-03-10 22:50:50 -05:00
}
}
2026-03-10 23:42:55 -05:00
internal fun setupScreenFlashIntervalLabel ( flashIntervalMs : Int ) : String =
" Flash interval: ${"%.2f".format(flashIntervalMsToSeconds(flashIntervalMs))} s "
2026-03-24 18:01:01 -05:00
internal fun formatMaxTwoDecimals ( value : Float ) : String =
String . format ( Locale . US , " %.2f " , value ) . trimEnd ( '0' ) . trimEnd ( '.' )
2026-03-22 01:01:14 -05:00
internal fun setupScreenHasUnsavedChanges (
hasUnsavedTimelineChanges : Boolean ,
editedProgramName : String ,
savedProgramName : String ,
) : Boolean {
val nameChanged = editedProgramName . trim ( ) != savedProgramName . trim ( )
return hasUnsavedTimelineChanges || nameChanged
}
2026-03-10 23:42:55 -05:00
internal fun setupScreenUsesScrollableContainer ( ) : Boolean = true
2026-03-10 22:50:50 -05:00
@Composable
2026-03-24 19:01:02 -05:00
fun ActiveSessionScreen ( vm : MainViewModel , onFinish : ( ) -> Unit , onBackToEntry : ( ) -> Unit ) {
2026-03-10 22:50:50 -05:00
val ui by vm . ui . collectAsStateWithLifecycle ( )
2026-03-10 23:34:07 -05:00
if ( ui . runtimeState == RuntimeState . COMPLETED ) onFinish ( )
BackHandler {
2026-04-09 20:40:46 -05:00
onBackToEntry ( )
2026-03-10 23:34:07 -05:00
}
2026-03-10 22:50:50 -05:00
2026-03-22 01:01:14 -05:00
val isVisualMode = true
2026-03-10 23:24:49 -05:00
val context = LocalContext . current
DisposableEffect ( isVisualMode ) {
val activity = context as ? Activity
val previous = activity ?. requestedOrientation
if ( isVisualMode ) {
activity ?. requestedOrientation = ActivityInfo . SCREEN _ORIENTATION _SENSOR _LANDSCAPE
}
onDispose {
if ( isVisualMode ) {
activity ?. requestedOrientation = previous ?: ActivityInfo . SCREEN _ORIENTATION _UNSPECIFIED
}
}
}
2026-03-10 23:51:55 -05:00
val immersiveFullscreen = shouldUseImmersiveFullscreen ( isVisualMode , ui . runtimeState )
var revealSystemBarsSignal by remember { mutableIntStateOf ( 0 ) }
ApplyImmersiveMode (
enabled = immersiveFullscreen ,
revealSignal = revealSystemBarsSignal ,
)
2026-03-10 23:34:07 -05:00
var showOverlay by remember ( ui . runtimeState ) { mutableStateOf ( shouldShowActiveControlsByDefault ( ui . runtimeState ) ) }
LaunchedEffect ( ui . runtimeState ) {
showOverlay = shouldShowActiveControlsByDefault ( ui . runtimeState )
}
2026-03-24 17:01:02 -05:00
var brightnessOverlayVisible by remember { mutableStateOf ( false ) }
var brightnessOverlayToken by remember { mutableIntStateOf ( 0 ) }
2026-04-13 19:01:01 -05:00
val timingLogTag = " MindMachineTiming "
2026-03-24 17:01:02 -05:00
LaunchedEffect ( brightnessOverlayToken ) {
if ( ! brightnessOverlayVisible ) return @LaunchedEffect
delay ( 2500 )
brightnessOverlayVisible = false
}
2026-04-13 19:01:01 -05:00
LaunchedEffect ( ui . runtimeState ) {
when ( ui . runtimeState ) {
RuntimeState . COUNTDOWN -> Log . d ( timingLogTag , " COUNTDOWN_START " )
RuntimeState . RUNNING -> Log . d ( timingLogTag , " IMMERSIVE_START " )
RuntimeState . STOPPED , RuntimeState . COMPLETED , RuntimeState . PAUSED , RuntimeState . INTERRUPTED , RuntimeState . ERROR -> {
Log . d ( timingLogTag , " IMMERSIVE_STOP state= ${ui.runtimeState} " )
}
else -> Unit
}
}
2026-03-24 19:01:02 -05:00
ApplyKeepScreenOn ( enabled = true )
2026-03-24 17:01:02 -05:00
ApplyScreenBrightnessOverride (
enabled = true ,
brightnessPercent = ui . settings . immersiveBrightnessPercent ,
)
2026-04-13 19:01:01 -05:00
ApplyPreferredRefreshRate (
enabled = true ,
preferLowest = ui . settings . preferLowestRefreshRate ,
)
2026-03-21 23:01:02 -05:00
val progress = sessionProgressFraction (
durationSec = ui . timeline . durationSec ,
remainingSec = ui . remainingSec ,
)
2026-03-10 22:50:50 -05:00
Box (
2026-03-10 23:14:33 -05:00
modifier = Modifier
. fillMaxSize ( )
. background ( Color . Black )
2026-03-24 18:01:01 -05:00
. then ( if ( immersiveFullscreen ) Modifier else Modifier . safeDrawingPadding ( ) )
2026-03-24 17:01:02 -05:00
. pointerInput ( immersiveFullscreen ) {
detectTapGestures {
brightnessOverlayVisible = true
brightnessOverlayToken ++
if ( ! immersiveFullscreen ) {
showOverlay = ! showOverlay
}
2026-03-10 23:51:55 -05:00
}
}
2026-03-10 22:50:50 -05:00
) {
2026-03-10 23:24:49 -05:00
if ( isVisualMode ) {
2026-04-13 18:01:01 -05:00
AndroidView (
modifier = Modifier . fillMaxSize ( ) ,
factory = { ctx ->
SplitFlashRenderView ( ctx ) . apply {
setRunning ( ui . runtimeState == RuntimeState . RUNNING )
updateIntervals ( ui . runtimeParams . flashOnMs , ui . runtimeParams . flashOffMs )
2026-04-13 20:01:01 -05:00
setFlashSequence ( ui . config . flashSequence )
2026-04-13 19:01:01 -05:00
setMonochrome ( ui . config . monochromeFlashMode )
2026-04-13 18:01:01 -05:00
}
} ,
update = { view ->
view . updateIntervals ( ui . runtimeParams . flashOnMs , ui . runtimeParams . flashOffMs )
2026-04-13 20:01:01 -05:00
view . setFlashSequence ( ui . config . flashSequence )
2026-04-13 19:01:01 -05:00
view . setMonochrome ( ui . config . monochromeFlashMode )
2026-04-13 18:01:01 -05:00
view . setRunning ( ui . runtimeState == RuntimeState . RUNNING )
}
)
2026-03-10 23:24:49 -05:00
}
2026-03-10 22:50:50 -05:00
if ( ui . runtimeState == RuntimeState . COUNTDOWN ) {
Text (
" ${ui.countdownSec} " ,
modifier = Modifier . align ( Alignment . Center ) ,
style = MaterialTheme . typography . displayLarge ,
2026-03-10 23:14:33 -05:00
color = Color . White
2026-03-10 22:50:50 -05:00
)
}
2026-03-21 23:01:02 -05:00
if ( immersiveFullscreen && ui . settings . showImmersiveProgressBar ) {
LinearProgressIndicator (
progress = { progress } ,
modifier = Modifier
. align ( Alignment . BottomCenter )
. fillMaxWidth ( )
. height ( 5. dp ) ,
trackColor = Color . White . copy ( alpha = 0.2f ) ,
color = Color . White ,
)
}
2026-03-24 17:01:02 -05:00
if ( brightnessOverlayVisible ) {
Column (
Modifier
. align ( Alignment . TopCenter )
. padding ( top = 24. dp )
. fillMaxWidth ( 0.45f )
. background ( Color . Black . copy ( alpha = 0.72f ) )
. padding ( horizontal = 12. dp , vertical = 10. dp )
) {
Text (
text = " Brightness ${ui.settings.immersiveBrightnessPercent} % " ,
color = Color . White ,
style = MaterialTheme . typography . bodyMedium ,
)
Slider (
value = ui . settings . immersiveBrightnessPercent . toFloat ( ) ,
onValueChange = {
vm . updateImmersiveBrightnessPercent ( it . roundToInt ( ) )
brightnessOverlayVisible = true
brightnessOverlayToken ++
} ,
valueRange = 5f .. 100f ,
)
}
}
2026-03-10 22:50:50 -05:00
if ( showOverlay ) {
2026-03-10 23:14:33 -05:00
Column (
Modifier
. align ( Alignment . BottomCenter )
. fillMaxWidth ( )
2026-03-10 23:24:49 -05:00
. background ( Color . Black . copy ( alpha = 0.72f ) )
2026-03-10 23:14:33 -05:00
. padding ( 16. dp )
) {
2026-03-10 22:50:50 -05:00
Text ( " ${ui.selectedPreset.name} • ${ui.remainingSec} s " , color = Color . White )
if ( ui . runtimeState == RuntimeState . RUNNING ) {
Button ( onClick = { vm . pause ( ) } , modifier = Modifier . fillMaxWidth ( ) ) { Text ( " Pause " ) }
} else {
Button ( onClick = { vm . resume ( ) } , modifier = Modifier . fillMaxWidth ( ) ) { Text ( " Resume " ) }
}
2026-04-04 08:01:01 -05:00
OutlinedButton (
onClick = {
2026-04-09 20:40:46 -05:00
vm . stop ( )
onFinish ( )
2026-04-04 08:01:01 -05:00
} ,
modifier = Modifier . fillMaxWidth ( )
) { Text ( " Stop " ) }
2026-03-10 22:50:50 -05:00
if ( ui . interruptionReason != null ) {
Text ( ui . interruptionReason !! , color = Color . White )
if ( ui . runtimeState == RuntimeState . INTERRUPTED ) {
OutlinedButton ( onClick = { vm . switchToVisualOnlyAndResume ( ) } , modifier = Modifier . fillMaxWidth ( ) ) {
Text ( " Resume Visual-only " )
}
}
}
}
}
}
}
2026-03-24 19:01:02 -05:00
@Composable
private fun ApplyKeepScreenOn ( enabled : Boolean ) {
val activity = LocalContext . current as ? Activity ?: return
val window = activity . window
DisposableEffect ( window , enabled ) {
if ( enabled ) {
window . addFlags ( WindowManager . LayoutParams . FLAG _KEEP _SCREEN _ON )
} else {
window . clearFlags ( WindowManager . LayoutParams . FLAG _KEEP _SCREEN _ON )
}
onDispose {
window . clearFlags ( WindowManager . LayoutParams . FLAG _KEEP _SCREEN _ON )
}
}
}
2026-03-24 17:01:02 -05:00
@Composable
private fun ApplyScreenBrightnessOverride ( enabled : Boolean , brightnessPercent : Int ) {
val activity = LocalContext . current as ? Activity ?: return
val window = activity . window
DisposableEffect ( window ) {
onDispose {
val reset = window . attributes
reset . screenBrightness = - 1f
window . attributes = reset
}
}
LaunchedEffect ( enabled , brightnessPercent ) {
val params = window . attributes
params . screenBrightness = if ( enabled ) {
( brightnessPercent . coerceIn ( 5 , 100 ) / 100f )
} else {
- 1f
}
window . attributes = params
}
}
2026-04-13 19:01:01 -05:00
@Composable
private fun ApplyPreferredRefreshRate ( enabled : Boolean , preferLowest : Boolean ) {
val activity = LocalContext . current as ? Activity ?: return
val window = activity . window
val timingLogTag = " MindMachineTiming "
DisposableEffect ( window ) {
onDispose {
val params = window . attributes
params . preferredRefreshRate = 0f
params . preferredDisplayModeId = 0
window . attributes = params
}
}
LaunchedEffect ( enabled , preferLowest ) {
val params = window . attributes
if ( enabled && preferLowest ) {
val display = activity . display
val chosenMode = display ?. supportedModes ?. minByOrNull { it . refreshRate }
params . preferredDisplayModeId = chosenMode ?. modeId ?: 0
params . preferredRefreshRate = chosenMode ?. refreshRate ?: 0f
Log . d (
timingLogTag ,
" DISPLAY_MODE_REQUEST modeId= ${chosenMode?.modeId ?: 0} refreshHz= ${chosenMode?.refreshRate ?: 0f} supported= ${display?.supportedModes?.joinToString { "${it.modeId} : ${it.refreshRate} " } } "
)
} else {
params . preferredDisplayModeId = 0
params . preferredRefreshRate = 0f
}
window . attributes = params
}
}
2026-03-10 23:51:55 -05:00
@Composable
private fun ApplyImmersiveMode ( enabled : Boolean , revealSignal : Int ) {
val context = LocalContext . current
val activity = context as ? Activity ?: return
val window = activity . window
val controller = remember ( window ) { WindowInsetsControllerCompat ( window , window . decorView ) }
DisposableEffect ( window ) {
onDispose {
controller . show ( WindowInsetsCompat . Type . systemBars ( ) )
WindowCompat . setDecorFitsSystemWindows ( window , true )
}
}
LaunchedEffect ( enabled ) {
if ( enabled ) {
WindowCompat . setDecorFitsSystemWindows ( window , false )
controller . systemBarsBehavior = WindowInsetsControllerCompat . BEHAVIOR _SHOW _TRANSIENT _BARS _BY _SWIPE
controller . hide ( WindowInsetsCompat . Type . systemBars ( ) )
} else {
controller . show ( WindowInsetsCompat . Type . systemBars ( ) )
WindowCompat . setDecorFitsSystemWindows ( window , true )
}
}
LaunchedEffect ( revealSignal , enabled ) {
if ( ! enabled || revealSignal == 0 ) return @LaunchedEffect
controller . show ( WindowInsetsCompat . Type . systemBars ( ) )
delay ( 2500 )
if ( enabled ) {
controller . hide ( WindowInsetsCompat . Type . systemBars ( ) )
}
}
}
2026-04-13 18:01:01 -05:00
private class SplitFlashRenderView ( context : Context ) : View ( context ) {
private val timingLogTag = " MindMachineTiming "
private val handler = Handler ( Looper . getMainLooper ( ) )
private val leftPaint = Paint ( Paint . ANTI _ALIAS _FLAG )
private val rightPaint = Paint ( Paint . ANTI _ALIAS _FLAG )
private var isRunning = false
private var phase = 1 // start black
private var rawOnMs = 167L
private var rawOffMs = 167L
private var onMs = 167L
private var offMs = 167L
2026-04-13 19:01:01 -05:00
private var monochrome = false
2026-04-13 20:01:01 -05:00
private var flashSequence = solutions . tretter . mindmachine . session . defaultFlashSequence ( )
2026-04-13 18:01:01 -05:00
private var started = false
private var startUptimeMs = 0L
private var nextToggleUptimeMs = 0L
private var toggleCount = 0L
private var lateToggleCount = 0L
private var maxLatenessMs = 0L
private val toggleRunnable = object : Runnable {
override fun run ( ) {
if ( !is Running ) return
val now = SystemClock . uptimeMillis ( )
val latenessMs = ( now - nextToggleUptimeMs ) . coerceAtLeast ( 0L )
val previousPhase = phase
if ( latenessMs > 0L ) {
lateToggleCount += 1
maxLatenessMs = maxOf ( maxLatenessMs , latenessMs )
}
2026-04-13 20:01:01 -05:00
phase = ( phase + 1 ) % phaseCount ( )
2026-04-13 18:01:01 -05:00
toggleCount += 1
invalidate ( )
2026-04-13 20:01:01 -05:00
val nextDuration = if ( isVisiblePhase ( phase ) ) onMs else offMs
2026-04-13 18:01:01 -05:00
nextToggleUptimeMs += nextDuration
val shouldLogToggle = toggleCount <= 20 || latenessMs > framePeriodMs ( ) || toggleCount % 60L == 0L
if ( shouldLogToggle ) {
Log . d (
timingLogTag ,
" toggle# $toggleCount prev= ${phaseName(previousPhase)} next= ${phaseName(phase)} " +
" scheduledMs= ${nextToggleUptimeMs - startUptimeMs - nextDuration} " +
" actualMs= ${now - startUptimeMs} latenessMs= $latenessMs " +
" lateCount= $lateToggleCount maxLateMs= $maxLatenessMs "
)
}
scheduleNext ( )
}
}
fun updateIntervals ( flashOnMs : Int , flashOffMs : Int ) {
2026-04-13 20:01:01 -05:00
val newRawOnMs = flashOnMs . coerceIn ( 50 , 2000 ) . toLong ( )
val newRawOffMs = flashOffMs . coerceIn ( 50 , 2000 ) . toLong ( )
2026-04-13 18:01:01 -05:00
val frameMs = framePeriodMs ( ) . coerceAtLeast ( 1L )
2026-04-13 20:01:01 -05:00
val newOnMs = snapToFrameMs ( newRawOnMs , frameMs )
val newOffMs = snapToFrameMs ( newRawOffMs , frameMs )
val changed = newRawOnMs != rawOnMs || newRawOffMs != rawOffMs || newOnMs != onMs || newOffMs != offMs
if ( ! changed ) return
rawOnMs = newRawOnMs
rawOffMs = newRawOffMs
onMs = newOnMs
offMs = newOffMs
2026-04-13 18:01:01 -05:00
2026-04-13 19:01:01 -05:00
if ( isRunning ) {
Log . d (
timingLogTag ,
" config onMs= $rawOnMs offMs= $rawOffMs refreshHz= ${"%.2f".format(refreshRate())} frameMs= $frameMs snappedOnMs= $onMs snappedOffMs= $offMs "
)
}
2026-04-13 18:01:01 -05:00
if ( isRunning && started ) {
2026-04-13 20:01:01 -05:00
val now = SystemClock . uptimeMillis ( )
val nextDuration = if ( isVisiblePhase ( phase ) ) onMs else offMs
nextToggleUptimeMs = maxOf ( nextToggleUptimeMs , now + 1L )
val remaining = ( nextToggleUptimeMs - now ) . coerceAtLeast ( 1L )
if ( remaining > nextDuration ) {
nextToggleUptimeMs = now + nextDuration
}
scheduleNext ( )
2026-04-13 18:01:01 -05:00
}
}
fun setRunning ( running : Boolean ) {
if ( isRunning == running ) return
isRunning = running
handler . removeCallbacks ( toggleRunnable )
if ( ! running ) {
started = false
phase = 1
invalidate ( )
return
}
started = true
phase = 0
startUptimeMs = SystemClock . uptimeMillis ( )
nextToggleUptimeMs = startUptimeMs + onMs
toggleCount = 0L
lateToggleCount = 0L
maxLatenessMs = 0L
Log . d (
timingLogTag ,
2026-04-13 19:01:01 -05:00
" session-start phase= ${phaseName(phase)} modeId= ${display?.mode?.modeId ?: 0} refreshHz= ${"%.2f".format(refreshRate())} frameMs= ${framePeriodMs()} nowMs= $startUptimeMs "
2026-04-13 18:01:01 -05:00
)
invalidate ( )
scheduleNext ( )
}
2026-04-13 19:01:01 -05:00
fun setMonochrome ( enabled : Boolean ) {
if ( monochrome == enabled ) return
monochrome = enabled
invalidate ( )
}
2026-04-13 20:01:01 -05:00
fun setFlashSequence ( sequence : List < solutions . tretter . mindmachine . session . FlashSequenceRow > ) {
val normalized = sequence . ifEmpty { solutions . tretter . mindmachine . session . defaultFlashSequence ( ) }
if ( flashSequence == normalized ) return
flashSequence = normalized
if ( started ) {
phase %= phaseCount ( )
}
invalidate ( )
}
2026-04-13 18:01:01 -05:00
override fun onDetachedFromWindow ( ) {
handler . removeCallbacks ( toggleRunnable )
super . onDetachedFromWindow ( )
}
override fun onDraw ( canvas : Canvas ) {
super . onDraw ( canvas )
val mid = width / 2f
val ( leftColor , rightColor ) = colorsForPhase ( phase )
leftPaint . color = leftColor
rightPaint . color = rightColor
canvas . drawRect ( 0f , 0f , mid , height . toFloat ( ) , leftPaint )
canvas . drawRect ( mid , 0f , width . toFloat ( ) , height . toFloat ( ) , rightPaint )
}
private fun scheduleNext ( ) {
handler . removeCallbacks ( toggleRunnable )
handler . postAtTime ( toggleRunnable , nextToggleUptimeMs )
}
2026-04-13 20:01:01 -05:00
private fun colorsForPhase ( phase : Int ) : Pair < Int , Int > {
if ( !is VisiblePhase ( phase ) ) return Pair ( android . graphics . Color . BLACK , android . graphics . Color . BLACK )
if ( monochrome ) return Pair ( android . graphics . Color . WHITE , android . graphics . Color . WHITE )
val row = flashSequence [ ( phase / 2 ) . mod ( flashSequence . size ) ]
val left = paletteColor ( row . leftColor )
val right = if ( row . splitScreen ) paletteColor ( row . rightColor ) else left
return Pair ( left , right )
2026-04-13 18:01:01 -05:00
}
2026-04-13 20:01:01 -05:00
private fun phaseName ( phase : Int ) : String {
if ( !is VisiblePhase ( phase ) ) return " BLACK "
val row = flashSequence [ ( phase / 2 ) . mod ( flashSequence . size ) ]
return if ( row . splitScreen ) {
" ${row.leftColor.name} _ ${row.rightColor.name} "
} else {
row . leftColor . name
}
2026-04-13 18:01:01 -05:00
}
2026-04-13 20:01:01 -05:00
private fun isVisiblePhase ( phase : Int ) : Boolean = phase % 2 == 0
private fun phaseCount ( ) : Int = ( flashSequence . size . coerceAtLeast ( 1 ) ) * 2
private fun paletteColor ( color : solutions . tretter . mindmachine . session . FlashPaletteColor ) : Int = when ( color ) {
solutions . tretter . mindmachine . session . FlashPaletteColor . BLACK -> android . graphics . Color . BLACK
solutions . tretter . mindmachine . session . FlashPaletteColor . RED -> android . graphics . Color . RED
solutions . tretter . mindmachine . session . FlashPaletteColor . GREEN -> android . graphics . Color . GREEN
solutions . tretter . mindmachine . session . FlashPaletteColor . YELLOW -> android . graphics . Color . YELLOW
solutions . tretter . mindmachine . session . FlashPaletteColor . BLUE -> android . graphics . Color . BLUE
2026-04-13 18:01:01 -05:00
}
private fun refreshRate ( ) : Float = display ?. refreshRate ?. takeIf { it > 0f } ?: 60f
private fun framePeriodMs ( ) : Long = ( 1000f / refreshRate ( ) ) . roundToLong ( ) . coerceAtLeast ( 1L )
private fun snapToFrameMs ( valueMs : Long , frameMs : Long ) : Long = ( ( valueMs + frameMs / 2 ) / frameMs ) . coerceAtLeast ( 1L ) * frameMs
}
2026-03-10 23:24:49 -05:00
private fun FlashColor . toComposeColor ( ) : Color = when ( this ) {
FlashColor . RED -> Color . Red
FlashColor . GREEN -> Color . Green
FlashColor . BLACK -> Color . Black
}
2026-03-10 23:14:33 -05:00
@Composable
2026-03-10 23:24:49 -05:00
private fun rememberSplitFlashFrame (
2026-03-10 23:14:33 -05:00
isRunning : Boolean ,
2026-03-19 20:01:01 -05:00
flashOnMs : Int ,
flashOffMs : Int ,
2026-03-22 00:01:01 -05:00
) : androidx . compose . runtime . State < SplitFlashFrame > {
val latestFlashOnMs = rememberUpdatedState ( flashOnMs )
val latestFlashOffMs = rememberUpdatedState ( flashOffMs )
2026-04-11 13:01:03 -05:00
// The screen can only change on vsync boundaries. If the configured interval is not a
// multiple of the refresh period, the *visual* interval will wobble by ~1 frame.
// Snapping intervals to whole frames makes the blink look uniform.
val refreshRateHz = LocalView . current . display ?. refreshRate ?: 60f
val framePeriodNanos = remember ( refreshRateHz ) {
( 1 _000 _000 _000 . 0 / refreshRateHz . coerceAtLeast ( 1f ) ) . roundToLong ( ) . coerceAtLeast ( 1L )
}
2026-04-13 18:01:01 -05:00
val timingLogTag = " MindMachineTiming "
2026-04-11 13:01:03 -05:00
return produceState ( initialValue = SplitFlashFrame ( FlashColor . BLACK , FlashColor . BLACK ) , isRunning , framePeriodNanos ) {
2026-03-22 01:01:14 -05:00
if ( !is Running ) {
2026-03-22 00:01:01 -05:00
value = SplitFlashFrame ( FlashColor . BLACK , FlashColor . BLACK )
return @produceState
}
2026-04-11 13:01:03 -05:00
// Drive the blink schedule from the frame clock, but keep phase boundaries aligned
// to a monotonic time base (frameTimeNanos) so the *configured timing* stays steady
// even if some frames are late.
//
// Key idea: instead of "advance by whatever elapsed since last frame", we keep an
// explicit nextToggleTime and only advance the phase when now >= nextToggleTime.
2026-04-11 12:01:03 -05:00
var started = false
2026-04-11 13:01:03 -05:00
var phase = 0 // 0=RG, 1=BK, 2=GR, 3=BK
var nextToggleNanos = 0L
var onNanos = 0L
var offNanos = 0L
2026-04-13 18:01:01 -05:00
var lastLoggedFlashOnMs = - 1
var lastLoggedFlashOffMs = - 1
var toggleCount = 0L
var lateToggleCount = 0L
var maxLatenessNanos = 0L
var lastToggleAtNanos = 0L
var sessionStartNanos = 0L
fun phaseName ( p : Int ) : String = when ( p ) {
0 -> " RED_GREEN "
1 -> " BLACK "
2 -> " GREEN_RED "
else -> " BLACK "
}
2026-04-11 13:01:03 -05:00
fun frameForPhase ( p : Int ) : SplitFlashFrame = when ( p ) {
0 -> SplitFlashFrame ( left = FlashColor . RED , right = FlashColor . GREEN )
1 -> SplitFlashFrame ( left = FlashColor . BLACK , right = FlashColor . BLACK )
2 -> SplitFlashFrame ( left = FlashColor . GREEN , right = FlashColor . RED )
else -> SplitFlashFrame ( left = FlashColor . BLACK , right = FlashColor . BLACK )
}
fun updateDurations ( ) {
val rawOn = latestFlashOnMs . value . coerceIn ( 50 , 2000 ) . toLong ( ) * 1 _000 _000L
val rawOff = latestFlashOffMs . value . coerceIn ( 50 , 2000 ) . toLong ( ) * 1 _000 _000L
val onFrames = ( ( rawOn + framePeriodNanos / 2 ) / framePeriodNanos ) . coerceAtLeast ( 1L )
val offFrames = ( ( rawOff + framePeriodNanos / 2 ) / framePeriodNanos ) . coerceAtLeast ( 1L )
onNanos = onFrames * framePeriodNanos
offNanos = offFrames * framePeriodNanos
2026-04-13 18:01:01 -05:00
val currentFlashOnMs = latestFlashOnMs . value . coerceIn ( 50 , 2000 )
val currentFlashOffMs = latestFlashOffMs . value . coerceIn ( 50 , 2000 )
if ( currentFlashOnMs != lastLoggedFlashOnMs || currentFlashOffMs != lastLoggedFlashOffMs ) {
Log . d (
timingLogTag ,
" config onMs= $currentFlashOnMs offMs= $currentFlashOffMs refreshHz= ${"%.2f".format(refreshRateHz)} " +
" frameMs= ${"%.3f".format(framePeriodNanos / 1_000_000.0)} snappedOnMs= ${"%.3f".format(onNanos / 1_000_000.0)} " +
" snappedOffMs= ${"%.3f".format(offNanos / 1_000_000.0)} "
)
lastLoggedFlashOnMs = currentFlashOnMs
lastLoggedFlashOffMs = currentFlashOffMs
}
2026-04-11 13:01:03 -05:00
}
2026-04-11 12:01:03 -05:00
2026-03-22 00:01:01 -05:00
while ( true ) {
2026-04-11 13:01:03 -05:00
withFrameNanos { now ->
2026-04-11 12:01:03 -05:00
if ( ! started ) {
2026-04-11 13:01:03 -05:00
updateDurations ( )
phase = 0
nextToggleNanos = now + onNanos
value = frameForPhase ( phase )
2026-04-11 12:01:03 -05:00
started = true
2026-04-13 18:01:01 -05:00
sessionStartNanos = now
lastToggleAtNanos = now
Log . d (
timingLogTag ,
" session-start phase= ${phaseName(phase)} refreshHz= ${"%.2f".format(refreshRateHz)} " +
" frameMs= ${"%.3f".format(framePeriodNanos / 1_000_000.0)} nowNs= $now "
)
2026-04-11 13:01:03 -05:00
return @withFrameNanos
2026-04-11 12:01:03 -05:00
}
2026-04-11 13:01:03 -05:00
updateDurations ( )
// Catch up if we missed one or more phase boundaries due to frame delays.
while ( now >= nextToggleNanos ) {
2026-04-13 18:01:01 -05:00
val scheduledToggleNanos = nextToggleNanos
val previousPhase = phase
2026-04-11 13:01:03 -05:00
phase = ( phase + 1 ) % 4
2026-04-13 18:01:01 -05:00
val latenessNanos = now - scheduledToggleNanos
val actualDeltaNanos = now - lastToggleAtNanos
2026-04-11 13:01:03 -05:00
val dur = if ( phase == 0 || phase == 2 ) onNanos else offNanos
nextToggleNanos += dur
2026-04-13 18:01:01 -05:00
toggleCount += 1
lastToggleAtNanos = now
if ( latenessNanos > 0L ) {
lateToggleCount += 1
if ( latenessNanos > maxLatenessNanos ) {
maxLatenessNanos = latenessNanos
}
}
val shouldLogToggle = toggleCount <= 20 || latenessNanos > framePeriodNanos || toggleCount % 60L == 0L
if ( shouldLogToggle ) {
Log . d (
timingLogTag ,
" toggle# $toggleCount prev= ${phaseName(previousPhase)} next= ${phaseName(phase)} " +
" scheduledMs= ${"%.3f".format((scheduledToggleNanos - sessionStartNanos) / 1_000_000.0)} " +
" actualMs= ${"%.3f".format((now - sessionStartNanos) / 1_000_000.0)} " +
" actualDeltaMs= ${"%.3f".format(actualDeltaNanos / 1_000_000.0)} " +
" latenessMs= ${"%.3f".format(latenessNanos / 1_000_000.0)} " +
" lateCount= $lateToggleCount maxLateMs= ${"%.3f".format(maxLatenessNanos / 1_000_000.0)} "
)
}
2026-04-11 13:01:03 -05:00
}
value = frameForPhase ( phase )
2026-03-22 00:01:01 -05:00
}
2026-03-10 23:14:33 -05:00
}
}
}
2026-03-10 22:50:50 -05:00
@Composable
2026-04-10 12:12:50 -05:00
fun SettingsScreen ( vm : MainViewModel , onRemoveAds : ( ) -> Unit ) {
2026-03-10 22:50:50 -05:00
val ui by vm . ui . collectAsStateWithLifecycle ( )
2026-03-24 18:01:01 -05:00
val scrollState = rememberScrollState ( )
2026-03-10 23:14:33 -05:00
Column (
2026-03-24 18:01:01 -05:00
Modifier
. fillMaxSize ( )
. background ( MaterialTheme . colorScheme . background )
. safeDrawingPadding ( )
. verticalScroll ( scrollState )
. padding ( 16. dp ) ,
2026-03-10 23:14:33 -05:00
verticalArrangement = Arrangement . spacedBy ( 12. dp )
) {
Text ( " Settings " , style = MaterialTheme . typography . headlineSmall , color = MaterialTheme . colorScheme . onBackground )
2026-03-21 23:01:02 -05:00
Text ( " Countdown: ${ui.settings.countdownSeconds} s " , color = MaterialTheme . colorScheme . onBackground )
Slider (
value = ui . settings . countdownSeconds . toFloat ( ) ,
onValueChange = { vm . updateCountdownSeconds ( it . roundToInt ( ) ) } ,
valueRange = 0f .. 10f ,
steps = 9 ,
)
2026-03-10 22:50:50 -05:00
Row ( verticalAlignment = Alignment . CenterVertically ) {
2026-03-22 01:01:14 -05:00
Checkbox (
checked = ui . settings . forceAudioWithoutHeadphones ,
onCheckedChange = vm :: updateForceAudioWithoutHeadphones ,
)
Text ( " force audio without headphones " , color = MaterialTheme . colorScheme . onBackground )
2026-03-10 22:50:50 -05:00
}
2026-03-21 23:01:02 -05:00
Row ( verticalAlignment = Alignment . CenterVertically ) {
Checkbox ( checked = ui . settings . showImmersiveProgressBar , onCheckedChange = vm :: updateShowImmersiveProgressBar )
Text ( " Show immersive bottom progress bar " , color = MaterialTheme . colorScheme . onBackground )
}
2026-04-13 19:01:01 -05:00
Row ( verticalAlignment = Alignment . CenterVertically ) {
Checkbox ( checked = ui . settings . preferLowestRefreshRate , onCheckedChange = vm :: updatePreferLowestRefreshRate )
Text ( " Prefer lowest display refresh rate during sessions " , color = MaterialTheme . colorScheme . onBackground )
}
2026-04-10 12:12:50 -05:00
Button ( onClick = onRemoveAds ) {
Text ( if ( ui . settings . noAds ) " Ads removed " else " Remove ads " )
}
2026-03-10 22:50:50 -05:00
HorizontalDivider ( )
2026-04-09 20:51:39 -05:00
Text (
2026-04-09 21:02:35 -05:00
" About/Disclaimer: MindMachine is a prototype and not a medical device. Use at your own risk. For immersive visual sessions, keep your eyes closed. " ,
2026-04-09 20:51:39 -05:00
color = MaterialTheme . colorScheme . onBackground
)
2026-03-10 22:50:50 -05:00
}
}
2026-04-10 12:12:50 -05:00
@Composable
2026-04-10 16:14:31 -05:00
fun RemoveAdsScreen ( billingManager : solutions . tretter . mindmachine . billing . BillingManager , vm : MainViewModel ) {
2026-04-10 12:12:50 -05:00
val ui by vm . ui . collectAsStateWithLifecycle ( )
val activity = LocalContext . current as ? Activity
val details by billingManager . productDetails . collectAsStateWithLifecycle ( )
2026-04-11 13:01:03 -05:00
val logs by billingManager . logs . collectAsStateWithLifecycle ( )
val subDetails = details [ solutions . tretter . mindmachine . billing . BillingProducts . SUB _REMOVE _ADS _MONTHLY ]
val subPrice = subDetails
?. subscriptionOfferDetails
?. firstOrNull ( )
?. pricingPhases
?. pricingPhaseList
?. firstOrNull ( )
?. formattedPrice
val inAppDetails = details [ solutions . tretter . mindmachine . billing . BillingProducts . INAPP _REMOVE _ADS _FOREVER ]
val inAppPrice = inAppDetails ?. oneTimePurchaseOfferDetails ?. formattedPrice
2026-04-10 12:12:50 -05:00
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 = {
2026-04-10 16:14:31 -05:00
activity ?. let { billingManager . launchPurchase ( it , solutions . tretter . mindmachine . billing . BillingProducts . SUB _REMOVE _ADS _MONTHLY ) }
2026-04-10 12:12:50 -05:00
}
) {
2026-04-11 13:01:03 -05:00
Text ( " Subscribe: ${subPrice ?: "$0.99"} /month " )
2026-04-10 12:12:50 -05:00
}
Button (
enabled = activity != null && ! ui . settings . noAds ,
onClick = {
2026-04-10 16:14:31 -05:00
activity ?. let { billingManager . launchPurchase ( it , solutions . tretter . mindmachine . billing . BillingProducts . INAPP _REMOVE _ADS _FOREVER ) }
2026-04-10 12:12:50 -05:00
}
) {
2026-04-11 13:01:03 -05:00
Text ( " Buy: ${inAppPrice ?: "$19.99"} one-time " )
2026-04-10 12:12:50 -05:00
}
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 )
)
2026-04-11 13:01:03 -05:00
// Debug logs
Text ( " Billing logs: " , style = MaterialTheme . typography . titleMedium , color = MaterialTheme . colorScheme . onBackground )
Column (
Modifier
. weight ( 1f )
. fillMaxWidth ( )
. verticalScroll ( rememberScrollState ( ) )
. background ( MaterialTheme . colorScheme . surfaceVariant . copy ( alpha = 0.2f ) )
. padding ( 8. dp )
) {
logs . forEach { log ->
Text ( log , color = MaterialTheme . colorScheme . onBackground , style = MaterialTheme . typography . bodySmall )
}
}
2026-04-10 12:12:50 -05:00
}
}
2026-03-10 22:50:50 -05:00
@Composable
2026-04-09 20:51:39 -05:00
fun PositioningScreen ( ) {
2026-04-09 22:23:52 -05:00
val scrollState = rememberScrollState ( )
2026-03-10 23:14:33 -05:00
Column (
2026-03-24 18:01:01 -05:00
Modifier
. fillMaxSize ( )
. background ( MaterialTheme . colorScheme . background )
. safeDrawingPadding ( )
2026-04-09 22:23:52 -05:00
. verticalScroll ( scrollState )
2026-03-24 18:01:01 -05:00
. padding ( 16. dp ) ,
2026-03-10 23:14:33 -05:00
verticalArrangement = Arrangement . spacedBy ( 8. dp )
) {
2026-04-09 20:51:39 -05:00
Text ( " Positioning Guidance " , style = MaterialTheme . typography . headlineSmall , color = MaterialTheme . colorScheme . onBackground )
2026-04-09 21:01:02 -05:00
2026-04-09 22:11:55 -05:00
Image (
painter = painterResource ( R . drawable . positioning _guide ) ,
contentDescription = " Positioning guide " ,
contentScale = ContentScale . Fit ,
modifier = Modifier
. fillMaxWidth ( )
. height ( 260. dp )
. padding ( vertical = 6. dp )
)
2026-04-09 20:51:39 -05:00
Text ( " • Lie on your back in a safe, comfortable place. " , color = MaterialTheme . colorScheme . onBackground )
Text ( " • Rest the phone across the upper nose/forehead area so it stays stable. " , color = MaterialTheme . colorScheme . onBackground )
2026-04-09 22:26:34 -05:00
Text ( " • Keep your eyes closed during immersive visual sessions. " , color = MaterialTheme . colorScheme . onBackground )
2026-04-09 21:01:02 -05:00
2026-04-09 22:43:15 -05:00
Text ( " Warnings " , style = MaterialTheme . typography . titleMedium , color = MaterialTheme . colorScheme . onBackground )
Text ( " • Flashing lights may be unsafe for people with epilepsy, seizure sensitivity, or migraine triggers. " , color = MaterialTheme . colorScheme . onBackground )
Text ( " • Do not stare at the screen, for immersive visual sessions, keep your eyes closed. " , color = MaterialTheme . colorScheme . onBackground )
Text ( " • Stop immediately for discomfort, dizziness, headache, nausea, anxiety, or eye strain. " , color = MaterialTheme . colorScheme . onBackground )
2026-04-09 21:01:02 -05:00
Spacer ( Modifier . height ( 12. dp ) )
Text ( " Disclaimer " , style = MaterialTheme . typography . titleMedium , color = MaterialTheme . colorScheme . onBackground )
Text (
" MindMachine is provided \" as is \" for informational and experimental purposes only, without warranties of any kind. You assume all risks of use. MindMachine is not a medical device and does not provide medical advice. To the maximum extent permitted by law, the developers and distributors disclaim all liability for any claims, damages, losses, or injuries arising from or related to use of the app. " ,
color = MaterialTheme . colorScheme . onBackground
)
2026-03-10 22:50:50 -05:00
}
}