Auto commit (MindMachine) Sat Apr 11 01:01:02 PM CDT 2026
This commit is contained in:
@@ -80,6 +80,7 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -109,7 +110,6 @@ import solutions.tretter.mindmachine.session.FlashColor
|
|||||||
import solutions.tretter.mindmachine.session.MainViewModel
|
import solutions.tretter.mindmachine.session.MainViewModel
|
||||||
import solutions.tretter.mindmachine.session.SplitFlashFrame
|
import solutions.tretter.mindmachine.session.SplitFlashFrame
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import solutions.tretter.mindmachine.session.SplitFlashSequencer
|
|
||||||
import solutions.tretter.mindmachine.session.flashIntervalMsToSeconds
|
import solutions.tretter.mindmachine.session.flashIntervalMsToSeconds
|
||||||
import solutions.tretter.mindmachine.session.flashIntervalSecondsToMs
|
import solutions.tretter.mindmachine.session.flashIntervalSecondsToMs
|
||||||
import solutions.tretter.mindmachine.session.formatDuration
|
import solutions.tretter.mindmachine.session.formatDuration
|
||||||
@@ -118,6 +118,7 @@ import solutions.tretter.mindmachine.session.shouldShowActiveControlsByDefault
|
|||||||
import solutions.tretter.mindmachine.session.shouldUseImmersiveFullscreen
|
import solutions.tretter.mindmachine.session.shouldUseImmersiveFullscreen
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
import kotlin.math.roundToLong
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
|
|
||||||
@@ -806,33 +807,71 @@ private fun rememberSplitFlashFrame(
|
|||||||
val latestFlashOnMs = rememberUpdatedState(flashOnMs)
|
val latestFlashOnMs = rememberUpdatedState(flashOnMs)
|
||||||
val latestFlashOffMs = rememberUpdatedState(flashOffMs)
|
val latestFlashOffMs = rememberUpdatedState(flashOffMs)
|
||||||
|
|
||||||
return produceState(initialValue = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK), isRunning) {
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
return produceState(initialValue = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK), isRunning, framePeriodNanos) {
|
||||||
if (!isRunning) {
|
if (!isRunning) {
|
||||||
value = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK)
|
value = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK)
|
||||||
return@produceState
|
return@produceState
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lock the sequencer's time base to Compose's frame clock.
|
// Drive the blink schedule from the frame clock, but keep phase boundaries aligned
|
||||||
// Mixing System.nanoTime() (start) with withFrameNanos() (updates) can introduce
|
// to a monotonic time base (frameTimeNanos) so the *configured timing* stays steady
|
||||||
// an arbitrary offset that looks like irregular intervals.
|
// even if some frames are late.
|
||||||
val sequencer = SplitFlashSequencer(
|
//
|
||||||
startTimeNanos = 0L,
|
// Key idea: instead of "advance by whatever elapsed since last frame", we keep an
|
||||||
flashOnMs = latestFlashOnMs.value,
|
// explicit nextToggleTime and only advance the phase when now >= nextToggleTime.
|
||||||
flashOffMs = latestFlashOffMs.value,
|
|
||||||
)
|
|
||||||
|
|
||||||
var started = false
|
var started = false
|
||||||
|
var phase = 0 // 0=RG, 1=BK, 2=GR, 3=BK
|
||||||
|
var nextToggleNanos = 0L
|
||||||
|
var onNanos = 0L
|
||||||
|
var offNanos = 0L
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
withFrameNanos { frameTimeNanos ->
|
withFrameNanos { now ->
|
||||||
if (!started) {
|
if (!started) {
|
||||||
// Initialize phaseStartNanos to the first real frame timestamp.
|
updateDurations()
|
||||||
// This keeps phase transitions aligned to the same monotonic clock.
|
phase = 0
|
||||||
sequencer.frameAt(frameTimeNanos)
|
nextToggleNanos = now + onNanos
|
||||||
|
value = frameForPhase(phase)
|
||||||
started = true
|
started = true
|
||||||
|
return@withFrameNanos
|
||||||
}
|
}
|
||||||
sequencer.updateIntervals(latestFlashOnMs.value, latestFlashOffMs.value)
|
|
||||||
value = sequencer.frameAt(frameTimeNanos)
|
updateDurations()
|
||||||
|
|
||||||
|
// Catch up if we missed one or more phase boundaries due to frame delays.
|
||||||
|
while (now >= nextToggleNanos) {
|
||||||
|
phase = (phase + 1) % 4
|
||||||
|
val dur = if (phase == 0 || phase == 2) onNanos else offNanos
|
||||||
|
nextToggleNanos += dur
|
||||||
|
}
|
||||||
|
|
||||||
|
value = frameForPhase(phase)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -891,6 +930,19 @@ fun RemoveAdsScreen(billingManager: solutions.tretter.mindmachine.billing.Billin
|
|||||||
val ui by vm.ui.collectAsStateWithLifecycle()
|
val ui by vm.ui.collectAsStateWithLifecycle()
|
||||||
val activity = LocalContext.current as? Activity
|
val activity = LocalContext.current as? Activity
|
||||||
val details by billingManager.productDetails.collectAsStateWithLifecycle()
|
val details by billingManager.productDetails.collectAsStateWithLifecycle()
|
||||||
|
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
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
@@ -912,7 +964,7 @@ fun RemoveAdsScreen(billingManager: solutions.tretter.mindmachine.billing.Billin
|
|||||||
activity?.let { billingManager.launchPurchase(it, solutions.tretter.mindmachine.billing.BillingProducts.SUB_REMOVE_ADS_MONTHLY) }
|
activity?.let { billingManager.launchPurchase(it, solutions.tretter.mindmachine.billing.BillingProducts.SUB_REMOVE_ADS_MONTHLY) }
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
Text(details[solutions.tretter.mindmachine.billing.BillingProducts.SUB_REMOVE_ADS_MONTHLY]?.name ?: "Subscribe: $1/month")
|
Text("Subscribe: ${subPrice ?: "$0.99"}/month")
|
||||||
}
|
}
|
||||||
|
|
||||||
Button(
|
Button(
|
||||||
@@ -921,7 +973,7 @@ fun RemoveAdsScreen(billingManager: solutions.tretter.mindmachine.billing.Billin
|
|||||||
activity?.let { billingManager.launchPurchase(it, solutions.tretter.mindmachine.billing.BillingProducts.INAPP_REMOVE_ADS_FOREVER) }
|
activity?.let { billingManager.launchPurchase(it, solutions.tretter.mindmachine.billing.BillingProducts.INAPP_REMOVE_ADS_FOREVER) }
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
Text(details[solutions.tretter.mindmachine.billing.BillingProducts.INAPP_REMOVE_ADS_FOREVER]?.name ?: "Buy: $20 one-time")
|
Text("Buy: ${inAppPrice ?: "$19.99"} one-time")
|
||||||
}
|
}
|
||||||
|
|
||||||
TextButton(onClick = { billingManager.refresh() }) { Text("Restore / Refresh purchases") }
|
TextButton(onClick = { billingManager.refresh() }) { Text("Restore / Refresh purchases") }
|
||||||
@@ -930,6 +982,21 @@ fun RemoveAdsScreen(billingManager: solutions.tretter.mindmachine.billing.Billin
|
|||||||
"Note: Purchases only work when the app is installed from Google Play (Internal testing/Production). Debug sideloaded builds won’t complete real billing flows.",
|
"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)
|
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ import com.android.billingclient.api.QueryPurchasesParams
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
class BillingManager(
|
class BillingManager(
|
||||||
context: Context,
|
context: Context,
|
||||||
@@ -29,6 +32,17 @@ class BillingManager(
|
|||||||
private val _productDetails = MutableStateFlow<Map<String, ProductDetails>>(emptyMap())
|
private val _productDetails = MutableStateFlow<Map<String, ProductDetails>>(emptyMap())
|
||||||
val productDetails: StateFlow<Map<String, ProductDetails>> = _productDetails.asStateFlow()
|
val productDetails: StateFlow<Map<String, ProductDetails>> = _productDetails.asStateFlow()
|
||||||
|
|
||||||
|
private val _logs = MutableStateFlow<List<String>>(emptyList())
|
||||||
|
val logs: StateFlow<List<String>> = _logs.asStateFlow()
|
||||||
|
|
||||||
|
private fun log(message: String) {
|
||||||
|
val timestamp = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()).format(Date())
|
||||||
|
_logs.value = _logs.value + "[$timestamp] $message"
|
||||||
|
if (_logs.value.size > 50) {
|
||||||
|
_logs.value = _logs.value.takeLast(50)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val billingClient: BillingClient = BillingClient.newBuilder(appContext)
|
private val billingClient: BillingClient = BillingClient.newBuilder(appContext)
|
||||||
.setListener { _, purchases ->
|
.setListener { _, purchases ->
|
||||||
if (purchases != null) {
|
if (purchases != null) {
|
||||||
@@ -39,37 +53,50 @@ class BillingManager(
|
|||||||
.build()
|
.build()
|
||||||
|
|
||||||
fun start() {
|
fun start() {
|
||||||
|
log("BillingManager.start()")
|
||||||
if (billingClient.isReady) {
|
if (billingClient.isReady) {
|
||||||
|
log("Billing client already ready")
|
||||||
refresh()
|
refresh()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
billingClient.startConnection(object : BillingClientStateListener {
|
billingClient.startConnection(object : BillingClientStateListener {
|
||||||
override fun onBillingSetupFinished(result: BillingResult) {
|
override fun onBillingSetupFinished(result: BillingResult) {
|
||||||
|
log("Billing setup finished: ${result.responseCode} ${result.debugMessage}")
|
||||||
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
|
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
|
||||||
refresh()
|
refresh()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBillingServiceDisconnected() {
|
override fun onBillingServiceDisconnected() {
|
||||||
|
log("Billing service disconnected")
|
||||||
// No-op, will retry on next call.
|
// No-op, will retry on next call.
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
|
log("refresh() called")
|
||||||
queryProductDetails()
|
queryProductDetails()
|
||||||
queryActivePurchases()
|
queryActivePurchases()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun launchPurchase(activity: Activity, productId: String) {
|
fun launchPurchase(activity: Activity, productId: String) {
|
||||||
val details = _productDetails.value[productId] ?: return
|
log("launchPurchase() productId=$productId")
|
||||||
|
val details = _productDetails.value[productId]
|
||||||
|
if (details == null) {
|
||||||
|
log("Product details not found for $productId")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val productDetailsParams = when (details.productType) {
|
val productDetailsParams = when (details.productType) {
|
||||||
BillingClient.ProductType.SUBS -> {
|
BillingClient.ProductType.SUBS -> {
|
||||||
val offerToken = details.subscriptionOfferDetails
|
val offerToken = details.subscriptionOfferDetails
|
||||||
?.firstOrNull()
|
?.firstOrNull()
|
||||||
?.offerToken
|
?.offerToken
|
||||||
?: return
|
if (offerToken == null) {
|
||||||
|
log("No subscription offer token for $productId")
|
||||||
|
return
|
||||||
|
}
|
||||||
BillingFlowParams.ProductDetailsParams.newBuilder()
|
BillingFlowParams.ProductDetailsParams.newBuilder()
|
||||||
.setProductDetails(details)
|
.setProductDetails(details)
|
||||||
.setOfferToken(offerToken)
|
.setOfferToken(offerToken)
|
||||||
@@ -82,18 +109,26 @@ class BillingManager(
|
|||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> return
|
else -> {
|
||||||
|
log("Unknown product type for $productId")
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val params = BillingFlowParams.newBuilder()
|
val params = BillingFlowParams.newBuilder()
|
||||||
.setProductDetailsParamsList(listOf(productDetailsParams))
|
.setProductDetailsParamsList(listOf(productDetailsParams))
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
billingClient.launchBillingFlow(activity, params)
|
val result = billingClient.launchBillingFlow(activity, params)
|
||||||
|
log("launchBillingFlow result: ${result.responseCode} ${result.debugMessage}")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun queryProductDetails() {
|
private fun queryProductDetails() {
|
||||||
if (!billingClient.isReady) return
|
log("queryProductDetails()")
|
||||||
|
if (!billingClient.isReady) {
|
||||||
|
log("Billing client not ready")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val products = listOf(
|
val products = listOf(
|
||||||
QueryProductDetailsParams.Product.newBuilder()
|
QueryProductDetailsParams.Product.newBuilder()
|
||||||
@@ -111,16 +146,23 @@ class BillingManager(
|
|||||||
.build()
|
.build()
|
||||||
|
|
||||||
billingClient.queryProductDetailsAsync(params, ProductDetailsResponseListener { result, detailsList ->
|
billingClient.queryProductDetailsAsync(params, ProductDetailsResponseListener { result, detailsList ->
|
||||||
|
log("queryProductDetails result: ${result.responseCode} ${result.debugMessage}")
|
||||||
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
|
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
|
||||||
_productDetails.value = detailsList.associateBy { it.productId }
|
_productDetails.value = detailsList.associateBy { it.productId }
|
||||||
|
log("Product details loaded: ${detailsList.size} items")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun queryActivePurchases() {
|
private fun queryActivePurchases() {
|
||||||
if (!billingClient.isReady) return
|
log("queryActivePurchases()")
|
||||||
|
if (!billingClient.isReady) {
|
||||||
|
log("Billing client not ready")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val listener = PurchasesResponseListener { result, purchases ->
|
val listener = PurchasesResponseListener { result, purchases ->
|
||||||
|
log("Query purchases result: ${result.responseCode} ${result.debugMessage}")
|
||||||
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
|
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
|
||||||
handlePurchases(purchases)
|
handlePurchases(purchases)
|
||||||
}
|
}
|
||||||
@@ -137,12 +179,15 @@ class BillingManager(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun handlePurchases(purchases: List<Purchase>) {
|
private fun handlePurchases(purchases: List<Purchase>) {
|
||||||
|
log("handlePurchases() count=${purchases.size}")
|
||||||
val active = purchases.any { p ->
|
val active = purchases.any { p ->
|
||||||
p.purchaseState == Purchase.PurchaseState.PURCHASED &&
|
p.purchaseState == Purchase.PurchaseState.PURCHASED &&
|
||||||
p.products.any { it == BillingProducts.SUB_REMOVE_ADS_MONTHLY || it == BillingProducts.INAPP_REMOVE_ADS_FOREVER }
|
p.products.any { it == BillingProducts.SUB_REMOVE_ADS_MONTHLY || it == BillingProducts.INAPP_REMOVE_ADS_FOREVER }
|
||||||
}
|
}
|
||||||
|
log("Active entitlement: $active")
|
||||||
|
|
||||||
if (_hasNoAds.value != active) {
|
if (_hasNoAds.value != active) {
|
||||||
|
log("Entitlement changed: ${_hasNoAds.value} -> $active")
|
||||||
_hasNoAds.value = active
|
_hasNoAds.value = active
|
||||||
onNoAdsEntitlementChanged(active)
|
onNoAdsEntitlementChanged(active)
|
||||||
}
|
}
|
||||||
@@ -150,6 +195,7 @@ class BillingManager(
|
|||||||
// Acknowledge where needed
|
// Acknowledge where needed
|
||||||
purchases.filter { it.purchaseState == Purchase.PurchaseState.PURCHASED && !it.isAcknowledged }
|
purchases.filter { it.purchaseState == Purchase.PurchaseState.PURCHASED && !it.isAcknowledged }
|
||||||
.forEach { purchase ->
|
.forEach { purchase ->
|
||||||
|
log("Acknowledging purchase: ${purchase.purchaseToken}")
|
||||||
val params = AcknowledgePurchaseParams.newBuilder()
|
val params = AcknowledgePurchaseParams.newBuilder()
|
||||||
.setPurchaseToken(purchase.purchaseToken)
|
.setPurchaseToken(purchase.purchaseToken)
|
||||||
.build()
|
.build()
|
||||||
|
|||||||
@@ -34,12 +34,26 @@ class SettingsRepository(private val context: Context) {
|
|||||||
?: 5,
|
?: 5,
|
||||||
forceAudioWithoutHeadphones = p[Keys.forceAudioWithoutHeadphones] ?: false,
|
forceAudioWithoutHeadphones = p[Keys.forceAudioWithoutHeadphones] ?: false,
|
||||||
showImmersiveProgressBar = p[Keys.showImmersiveProgressBar] ?: true,
|
showImmersiveProgressBar = p[Keys.showImmersiveProgressBar] ?: true,
|
||||||
immersiveBrightnessPercent = (p[Keys.immersiveBrightnessPercent] ?: 100).coerceIn(5, 100),
|
immersiveBrightnessPercent = (p[Keys.immersiveBrightnessPercent] ?: 50).coerceIn(5, 100),
|
||||||
lastPresetId = p[Keys.lastPresetId],
|
lastPresetId = p[Keys.lastPresetId],
|
||||||
noAds = p[Keys.noAds] ?: false,
|
noAds = p[Keys.noAds] ?: false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called on app startup.
|
||||||
|
* Ensures a first-run default of 50% brightness for immersive sessions.
|
||||||
|
*
|
||||||
|
* Only applies when the user has never set a value.
|
||||||
|
*/
|
||||||
|
suspend fun ensureFirstRunDefaults() {
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
if (prefs[Keys.immersiveBrightnessPercent] == null) {
|
||||||
|
prefs[Keys.immersiveBrightnessPercent] = 50
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun acknowledgeSafety(version: Int) {
|
suspend fun acknowledgeSafety(version: Int) {
|
||||||
context.dataStore.edit {
|
context.dataStore.edit {
|
||||||
it[Keys.safetyAcknowledged] = true
|
it[Keys.safetyAcknowledged] = true
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ data class AppSettings(
|
|||||||
val countdownSeconds: Int = 5,
|
val countdownSeconds: Int = 5,
|
||||||
val forceAudioWithoutHeadphones: Boolean = false,
|
val forceAudioWithoutHeadphones: Boolean = false,
|
||||||
val showImmersiveProgressBar: Boolean = true,
|
val showImmersiveProgressBar: Boolean = true,
|
||||||
val immersiveBrightnessPercent: Int = 100,
|
// Default used only before DataStore emits (and as a fallback).
|
||||||
|
// We want the first-run experience to be less blinding.
|
||||||
|
val immersiveBrightnessPercent: Int = 50,
|
||||||
val lastPresetId: String? = null,
|
val lastPresetId: String? = null,
|
||||||
val noAds: Boolean = false,
|
val noAds: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -91,6 +91,12 @@ class MainViewModel(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
billingManager.start()
|
billingManager.start()
|
||||||
|
|
||||||
|
// One-time first-run defaults.
|
||||||
|
viewModelScope.launch {
|
||||||
|
settingsRepository.ensureFirstRunDefaults()
|
||||||
|
}
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
combine(settingsRepository.settings, userProgramRepository.userPrograms) { settings, userPrograms ->
|
combine(settingsRepository.settings, userProgramRepository.userPrograms) { settings, userPrograms ->
|
||||||
settings to userPrograms
|
settings to userPrograms
|
||||||
|
|||||||
Reference in New Issue
Block a user