Compare commits
24 Commits
Apr15Codex
...
First_Publ
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a6f28d39f | ||
|
|
0357da5158 | ||
|
|
c468840359 | ||
|
|
260d0a1e84 | ||
|
|
7d7057f640 | ||
|
|
a1a3f2ab8c | ||
|
|
d3f21094ff | ||
|
|
3876d68183 | ||
|
|
6ccf19707b | ||
|
|
95cb3de029 | ||
|
|
bc014951e5 | ||
|
|
18f5aa213b | ||
|
|
06c3dbb079 | ||
|
|
3230803c2c | ||
|
|
3a40ee5a0c | ||
|
|
f2a9e3daa7 | ||
|
|
937689cbfa | ||
|
|
011e4ba349 | ||
|
|
0fff8c141b | ||
|
|
3cbefd67b5 | ||
|
|
2ec47df39d | ||
|
|
703cbcfb5b | ||
|
|
6d9f9b4632 | ||
|
|
138da56024 |
@@ -108,3 +108,29 @@ Important: **Billing only works reliably when the app is installed from Google P
|
|||||||
6. **Verify in the app**
|
6. **Verify in the app**
|
||||||
- Trigger the subscribe/buy flow and confirm the purchase dialog appears.
|
- Trigger the subscribe/buy flow and confirm the purchase dialog appears.
|
||||||
- Confirm acknowledgements/entitlements are applied as expected.
|
- Confirm acknowledgements/entitlements are applied as expected.
|
||||||
|
|
||||||
|
|
||||||
|
## Billing logging (ADB)
|
||||||
|
|
||||||
|
Billing logs are written to Android's system log (Logcat), not displayed in the app UI.
|
||||||
|
|
||||||
|
To view billing logs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# View only MindMachine billing logs
|
||||||
|
adb logcat -s MindMachineBilling:D
|
||||||
|
|
||||||
|
# Filter with silence for other tags
|
||||||
|
adb logcat -s MindMachineBilling:D *:S
|
||||||
|
```
|
||||||
|
|
||||||
|
Example log lines you'll see:
|
||||||
|
- `Billing setup finished: 0 OK`
|
||||||
|
- `Product details loaded: 2 items`
|
||||||
|
- `Query purchases returned X items`
|
||||||
|
- `Active entitlement: true/false`
|
||||||
|
- `Entitlement changed: false -> true`
|
||||||
|
|
||||||
|
Billing log tags:
|
||||||
|
- `MindMachineBilling` for debug/info messages
|
||||||
|
- Errors use `Log.e()` and appear as `E/MindMachineBilling` in logcat
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("org.jetbrains.kotlin.android")
|
id("org.jetbrains.kotlin.android")
|
||||||
|
id("org.jetbrains.kotlin.plugin.serialization")
|
||||||
|
id("org.jetbrains.kotlin.plugin.compose")
|
||||||
}
|
}
|
||||||
|
|
||||||
import java.util.Properties
|
import java.util.Properties
|
||||||
@@ -13,8 +15,8 @@ android {
|
|||||||
applicationId = "solutions.tretter.mindmachine"
|
applicationId = "solutions.tretter.mindmachine"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 6
|
versionCode = 18
|
||||||
versionName = "1.0.1"
|
versionName = "1.0.13"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
@@ -33,6 +35,9 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
debug {
|
||||||
|
buildConfigField("boolean", "DEBUG_AD_FREE", "true")
|
||||||
|
}
|
||||||
release {
|
release {
|
||||||
signingConfig = signingConfigs.getByName("release")
|
signingConfig = signingConfigs.getByName("release")
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = false
|
||||||
@@ -40,6 +45,7 @@ android {
|
|||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro"
|
"proguard-rules.pro"
|
||||||
)
|
)
|
||||||
|
buildConfigField("boolean", "DEBUG_AD_FREE", "false")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
compileOptions {
|
compileOptions {
|
||||||
@@ -51,9 +57,10 @@ android {
|
|||||||
}
|
}
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
|
buildConfig = true
|
||||||
}
|
}
|
||||||
composeOptions {
|
composeOptions {
|
||||||
kotlinCompilerExtensionVersion = "1.5.14"
|
kotlinCompilerExtensionVersion = "2.1.0"
|
||||||
}
|
}
|
||||||
packaging {
|
packaging {
|
||||||
resources {
|
resources {
|
||||||
@@ -83,7 +90,7 @@ dependencies {
|
|||||||
|
|
||||||
// Monetization
|
// Monetization
|
||||||
implementation("com.google.android.gms:play-services-ads:23.1.0")
|
implementation("com.google.android.gms:play-services-ads:23.1.0")
|
||||||
implementation("com.android.billingclient:billing-ktx:7.1.1")
|
implementation("com.android.billingclient:billing-ktx:8.0.0")
|
||||||
|
|
||||||
testImplementation("junit:junit:4.13.2")
|
testImplementation("junit:junit:4.13.2")
|
||||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1")
|
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1")
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import android.util.Log
|
|||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
|
import solutions.tretter.mindmachine.BuildConfig
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
@@ -48,6 +49,7 @@ import androidx.compose.foundation.Image
|
|||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
@@ -158,9 +160,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
onNoAdsEntitlementChanged = { hasNoAds ->
|
onNoAdsEntitlementChanged = { hasNoAds ->
|
||||||
lifecycleScope.launch { settingsRepo.updateNoAds(hasNoAds) }
|
lifecycleScope.launch { settingsRepo.updateNoAds(hasNoAds) }
|
||||||
},
|
},
|
||||||
)
|
).also { it.start() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private val vm: MainViewModel by viewModels {
|
private val vm: MainViewModel by viewModels {
|
||||||
MainViewModel.Factory(
|
MainViewModel.Factory(
|
||||||
settingsRepo,
|
settingsRepo,
|
||||||
@@ -173,7 +174,9 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
if (!BuildConfig.DEBUG_AD_FREE) {
|
||||||
MobileAds.initialize(this) {}
|
MobileAds.initialize(this) {}
|
||||||
|
}
|
||||||
setContent {
|
setContent {
|
||||||
MaterialTheme(colorScheme = MindMachineColors) {
|
MaterialTheme(colorScheme = MindMachineColors) {
|
||||||
App(vm, billingManager)
|
App(vm, billingManager)
|
||||||
@@ -228,7 +231,7 @@ fun App(vm: MainViewModel = viewModel(), billingManager: solutions.tretter.mindm
|
|||||||
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)
|
val interstitial = rememberInterstitialController(enabled = !ui.settings.noAds && !BuildConfig.DEBUG_AD_FREE)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
@@ -343,11 +346,13 @@ fun App(vm: MainViewModel = viewModel(), billingManager: solutions.tretter.mindm
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!BuildConfig.DEBUG_AD_FREE) {
|
||||||
AdBanner(
|
AdBanner(
|
||||||
enabled = !ui.settings.noAds,
|
enabled = !ui.settings.noAds,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (confirmDeleteProgramId != null) {
|
if (confirmDeleteProgramId != null) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
@@ -376,7 +381,7 @@ fun App(vm: MainViewModel = viewModel(), billingManager: solutions.tretter.mindm
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
composable("setup") {
|
composable("setup") {
|
||||||
val interstitial = rememberInterstitialController(enabled = !ui.settings.noAds)
|
val interstitial = rememberInterstitialController(enabled = !ui.settings.noAds && !BuildConfig.DEBUG_AD_FREE)
|
||||||
SetupScreen(
|
SetupScreen(
|
||||||
vm = vm,
|
vm = vm,
|
||||||
onStart = {
|
onStart = {
|
||||||
@@ -598,6 +603,8 @@ fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit, onBackToEntry:
|
|||||||
}
|
}
|
||||||
var brightnessOverlayVisible by remember { mutableStateOf(false) }
|
var brightnessOverlayVisible by remember { mutableStateOf(false) }
|
||||||
var brightnessOverlayToken by remember { mutableIntStateOf(0) }
|
var brightnessOverlayToken by remember { mutableIntStateOf(0) }
|
||||||
|
var stopButtonVisible by remember { mutableStateOf(false) }
|
||||||
|
var stopButtonToken by remember { mutableIntStateOf(0) }
|
||||||
val timingLogTag = "MindMachineTiming"
|
val timingLogTag = "MindMachineTiming"
|
||||||
|
|
||||||
LaunchedEffect(brightnessOverlayToken) {
|
LaunchedEffect(brightnessOverlayToken) {
|
||||||
@@ -643,12 +650,24 @@ fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit, onBackToEntry:
|
|||||||
detectTapGestures {
|
detectTapGestures {
|
||||||
brightnessOverlayVisible = true
|
brightnessOverlayVisible = true
|
||||||
brightnessOverlayToken++
|
brightnessOverlayToken++
|
||||||
|
if (ui.settings.showImmersiveStopButton && immersiveFullscreen) {
|
||||||
|
stopButtonVisible = true
|
||||||
|
stopButtonToken++
|
||||||
|
}
|
||||||
if (!immersiveFullscreen) {
|
if (!immersiveFullscreen) {
|
||||||
showOverlay = !showOverlay
|
showOverlay = !showOverlay
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
// Auto-hide stop button after 3 seconds of visibility
|
||||||
|
LaunchedEffect(stopButtonToken) {
|
||||||
|
if (stopButtonVisible) {
|
||||||
|
delay(3000)
|
||||||
|
stopButtonVisible = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isVisualMode) {
|
if (isVisualMode) {
|
||||||
AndroidView(
|
AndroidView(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -716,6 +735,26 @@ fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit, onBackToEntry:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop button in immersive mode (top-right)
|
||||||
|
if (stopButtonVisible && ui.settings.showImmersiveStopButton) {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
vm.stop()
|
||||||
|
onFinish()
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.padding(top = 16.dp, end = 16.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Close,
|
||||||
|
contentDescription = "Stop",
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(28.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (showOverlay) {
|
if (showOverlay) {
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
@@ -1037,6 +1076,7 @@ private class SplitFlashRenderView(context: Context) : View(context) {
|
|||||||
|
|
||||||
private fun paletteColor(color: solutions.tretter.mindmachine.session.FlashPaletteColor): Int = when (color) {
|
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.BLACK -> android.graphics.Color.BLACK
|
||||||
|
solutions.tretter.mindmachine.session.FlashPaletteColor.WHITE -> android.graphics.Color.WHITE
|
||||||
solutions.tretter.mindmachine.session.FlashPaletteColor.RED -> android.graphics.Color.RED
|
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.GREEN -> android.graphics.Color.GREEN
|
||||||
solutions.tretter.mindmachine.session.FlashPaletteColor.YELLOW -> android.graphics.Color.YELLOW
|
solutions.tretter.mindmachine.session.FlashPaletteColor.YELLOW -> android.graphics.Color.YELLOW
|
||||||
@@ -1233,6 +1273,18 @@ fun SettingsScreen(vm: MainViewModel, onRemoveAds: () -> Unit) {
|
|||||||
Text("Show immersive bottom progress bar", color = MaterialTheme.colorScheme.onBackground)
|
Text("Show immersive bottom progress bar", color = MaterialTheme.colorScheme.onBackground)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Checkbox(checked = ui.settings.showImmersiveStopButton, onCheckedChange = vm::updateShowImmersiveStopButton)
|
||||||
|
Column {
|
||||||
|
Text("Show stop button in immersive mode", color = MaterialTheme.colorScheme.onBackground)
|
||||||
|
Text(
|
||||||
|
"If off, swipe from edge to display back button",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Checkbox(checked = ui.settings.preferLowestRefreshRate, onCheckedChange = vm::updatePreferLowestRefreshRate)
|
Checkbox(checked = ui.settings.preferLowestRefreshRate, onCheckedChange = vm::updatePreferLowestRefreshRate)
|
||||||
Text("Prefer lowest display refresh rate during sessions", color = MaterialTheme.colorScheme.onBackground)
|
Text("Prefer lowest display refresh rate during sessions", color = MaterialTheme.colorScheme.onBackground)
|
||||||
@@ -1255,7 +1307,6 @@ 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 subDetails = details[solutions.tretter.mindmachine.billing.BillingProducts.SUB_REMOVE_ADS_MONTHLY]
|
||||||
val subPrice = subDetails
|
val subPrice = subDetails
|
||||||
@@ -1307,21 +1358,6 @@ 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,15 @@ package solutions.tretter.mindmachine.billing
|
|||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
import com.android.billingclient.api.AcknowledgePurchaseParams
|
import com.android.billingclient.api.AcknowledgePurchaseParams
|
||||||
import com.android.billingclient.api.BillingClient
|
import com.android.billingclient.api.BillingClient
|
||||||
import com.android.billingclient.api.BillingClientStateListener
|
import com.android.billingclient.api.BillingClientStateListener
|
||||||
import com.android.billingclient.api.BillingFlowParams
|
import com.android.billingclient.api.BillingFlowParams
|
||||||
import com.android.billingclient.api.BillingResult
|
import com.android.billingclient.api.BillingResult
|
||||||
import com.android.billingclient.api.ProductDetails
|
import com.android.billingclient.api.ProductDetails
|
||||||
import com.android.billingclient.api.ProductDetailsResponseListener
|
import com.android.billingclient.api.QueryProductDetailsResult
|
||||||
|
import com.android.billingclient.api.PendingPurchasesParams
|
||||||
import com.android.billingclient.api.Purchase
|
import com.android.billingclient.api.Purchase
|
||||||
import com.android.billingclient.api.PurchasesResponseListener
|
import com.android.billingclient.api.PurchasesResponseListener
|
||||||
import com.android.billingclient.api.QueryProductDetailsParams
|
import com.android.billingclient.api.QueryProductDetailsParams
|
||||||
@@ -16,9 +18,6 @@ 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,
|
||||||
@@ -32,15 +31,12 @@ 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) {
|
private fun log(message: String) {
|
||||||
val timestamp = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()).format(Date())
|
Log.d("MindMachineBilling", message)
|
||||||
_logs.value = _logs.value + "[$timestamp] $message"
|
|
||||||
if (_logs.value.size > 50) {
|
|
||||||
_logs.value = _logs.value.takeLast(50)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun logError(message: String) {
|
||||||
|
Log.e("MindMachineBilling", message)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val billingClient: BillingClient = BillingClient.newBuilder(appContext)
|
private val billingClient: BillingClient = BillingClient.newBuilder(appContext)
|
||||||
@@ -49,27 +45,48 @@ class BillingManager(
|
|||||||
handlePurchases(purchases)
|
handlePurchases(purchases)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.enablePendingPurchases()
|
.enablePendingPurchases(
|
||||||
|
PendingPurchasesParams.newBuilder()
|
||||||
|
.enableOneTimeProducts()
|
||||||
|
.build()
|
||||||
|
)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var isConnecting = false
|
||||||
|
|
||||||
fun start() {
|
fun start() {
|
||||||
log("BillingManager.start()")
|
log("BillingManager.start() - isReady=${billingClient.isReady}, isConnecting=$isConnecting")
|
||||||
|
|
||||||
if (billingClient.isReady) {
|
if (billingClient.isReady) {
|
||||||
log("Billing client already ready")
|
log("Billing client already ready")
|
||||||
refresh()
|
refresh()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isConnecting) {
|
||||||
|
log("Billing client connection already in progress")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isConnecting = true
|
||||||
|
log("Calling startConnection()")
|
||||||
|
|
||||||
billingClient.startConnection(object : BillingClientStateListener {
|
billingClient.startConnection(object : BillingClientStateListener {
|
||||||
override fun onBillingSetupFinished(result: BillingResult) {
|
override fun onBillingSetupFinished(result: BillingResult) {
|
||||||
|
isConnecting = false
|
||||||
log("Billing setup finished: ${result.responseCode} ${result.debugMessage}")
|
log("Billing setup finished: ${result.responseCode} ${result.debugMessage}")
|
||||||
|
|
||||||
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
|
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
|
||||||
refresh()
|
refresh()
|
||||||
|
} else {
|
||||||
|
logError("Billing setup failed: ${result.responseCode} ${result.debugMessage}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBillingServiceDisconnected() {
|
override fun onBillingServiceDisconnected() {
|
||||||
|
isConnecting = false
|
||||||
log("Billing service disconnected")
|
log("Billing service disconnected")
|
||||||
// No-op, will retry on next call.
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -120,7 +137,11 @@ class BillingManager(
|
|||||||
.build()
|
.build()
|
||||||
|
|
||||||
val result = billingClient.launchBillingFlow(activity, params)
|
val result = billingClient.launchBillingFlow(activity, params)
|
||||||
log("launchBillingFlow result: ${result.responseCode} ${result.debugMessage}")
|
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
|
||||||
|
log("launchBillingFlow succeeded")
|
||||||
|
} else {
|
||||||
|
logError("launchBillingFlow failed: ${result.responseCode} ${result.debugMessage}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun queryProductDetails() {
|
private fun queryProductDetails() {
|
||||||
@@ -130,28 +151,60 @@ class BillingManager(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val products = listOf(
|
val featureResult = billingClient.isFeatureSupported(BillingClient.FeatureType.PRODUCT_DETAILS)
|
||||||
QueryProductDetailsParams.Product.newBuilder()
|
log("PRODUCT_DETAILS support: ${featureResult.responseCode} ${featureResult.debugMessage}")
|
||||||
.setProductId(BillingProducts.SUB_REMOVE_ADS_MONTHLY)
|
if (featureResult.responseCode != BillingClient.BillingResponseCode.OK) {
|
||||||
.setProductType(BillingClient.ProductType.SUBS)
|
logError("PRODUCT_DETAILS feature not supported")
|
||||||
.build(),
|
return
|
||||||
QueryProductDetailsParams.Product.newBuilder()
|
}
|
||||||
.setProductId(BillingProducts.INAPP_REMOVE_ADS_FOREVER)
|
|
||||||
.setProductType(BillingClient.ProductType.INAPP)
|
queryProductDetailsForType(
|
||||||
.build(),
|
productType = BillingClient.ProductType.SUBS,
|
||||||
|
productIds = listOf(BillingProducts.SUB_REMOVE_ADS_MONTHLY)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
queryProductDetailsForType(
|
||||||
|
productType = BillingClient.ProductType.INAPP,
|
||||||
|
productIds = listOf(BillingProducts.INAPP_REMOVE_ADS_FOREVER)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun queryProductDetailsForType(
|
||||||
|
productType: String,
|
||||||
|
productIds: List<String>
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
log("Querying $productType product details for $productIds")
|
||||||
|
|
||||||
|
val products = productIds.map { productId ->
|
||||||
|
QueryProductDetailsParams.Product.newBuilder()
|
||||||
|
.setProductId(productId)
|
||||||
|
.setProductType(productType)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
val params = QueryProductDetailsParams.newBuilder()
|
val params = QueryProductDetailsParams.newBuilder()
|
||||||
.setProductList(products)
|
.setProductList(products)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
billingClient.queryProductDetailsAsync(params, ProductDetailsResponseListener { result, detailsList ->
|
billingClient.queryProductDetailsAsync(params) { billingResult, queryResult ->
|
||||||
log("queryProductDetails result: ${result.responseCode} ${result.debugMessage}")
|
log("queryProductDetailsAsync($productType) result: ${billingResult.responseCode} ${billingResult.debugMessage}")
|
||||||
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
|
|
||||||
_productDetails.value = detailsList.associateBy { it.productId }
|
val detailsList = queryResult.productDetailsList
|
||||||
log("Product details loaded: ${detailsList.size} items")
|
log("Fetched $productType products: ${detailsList?.map { it.productId } ?: emptyList()}")
|
||||||
|
|
||||||
|
if (queryResult.unfetchedProductList.isNotEmpty()) {
|
||||||
|
logError("Unfetched $productType products: ${queryResult.unfetchedProductList}")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
|
||||||
|
_productDetails.value = _productDetails.value + (detailsList?.associateBy { it.productId } ?: emptyMap())
|
||||||
|
log("Product details cache now has: ${_productDetails.value.keys}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Log.e("MindMachineBilling", "Exception querying $productType product details", t)
|
||||||
}
|
}
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun queryActivePurchases() {
|
private fun queryActivePurchases() {
|
||||||
@@ -162,9 +215,11 @@ class BillingManager(
|
|||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
||||||
|
log("Query purchases returned ${purchases.size} items")
|
||||||
handlePurchases(purchases)
|
handlePurchases(purchases)
|
||||||
|
} else {
|
||||||
|
logError("Query purchases failed: ${result.responseCode} ${result.debugMessage}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,7 +234,7 @@ class BillingManager(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun handlePurchases(purchases: List<Purchase>) {
|
private fun handlePurchases(purchases: List<Purchase>) {
|
||||||
log("handlePurchases() count=${purchases.size}")
|
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 }
|
||||||
@@ -199,7 +254,13 @@ class BillingManager(
|
|||||||
val params = AcknowledgePurchaseParams.newBuilder()
|
val params = AcknowledgePurchaseParams.newBuilder()
|
||||||
.setPurchaseToken(purchase.purchaseToken)
|
.setPurchaseToken(purchase.purchaseToken)
|
||||||
.build()
|
.build()
|
||||||
billingClient.acknowledgePurchase(params) { /* no-op */ }
|
billingClient.acknowledgePurchase(params) { billingResult ->
|
||||||
|
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
|
||||||
|
log("Purchase acknowledged successfully")
|
||||||
|
} else {
|
||||||
|
logError("Failed to acknowledge purchase: ${billingResult.responseCode} ${billingResult.debugMessage}")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class SettingsRepository(private val context: Context) {
|
|||||||
val countdownSeconds = intPreferencesKey("countdown_seconds")
|
val countdownSeconds = intPreferencesKey("countdown_seconds")
|
||||||
val forceAudioWithoutHeadphones = booleanPreferencesKey("force_audio_without_headphones")
|
val forceAudioWithoutHeadphones = booleanPreferencesKey("force_audio_without_headphones")
|
||||||
val showImmersiveProgressBar = booleanPreferencesKey("show_immersive_progress_bar")
|
val showImmersiveProgressBar = booleanPreferencesKey("show_immersive_progress_bar")
|
||||||
|
val showImmersiveStopButton = booleanPreferencesKey("show_immersive_stop_button")
|
||||||
val preferLowestRefreshRate = booleanPreferencesKey("prefer_lowest_refresh_rate")
|
val preferLowestRefreshRate = booleanPreferencesKey("prefer_lowest_refresh_rate")
|
||||||
val monochromeFlashMode = booleanPreferencesKey("monochrome_flash_mode")
|
val monochromeFlashMode = booleanPreferencesKey("monochrome_flash_mode")
|
||||||
val immersiveBrightnessPercent = intPreferencesKey("immersive_brightness_percent")
|
val immersiveBrightnessPercent = intPreferencesKey("immersive_brightness_percent")
|
||||||
@@ -36,6 +37,7 @@ 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,
|
||||||
|
showImmersiveStopButton = p[Keys.showImmersiveStopButton] ?: true,
|
||||||
preferLowestRefreshRate = p[Keys.preferLowestRefreshRate] ?: true,
|
preferLowestRefreshRate = p[Keys.preferLowestRefreshRate] ?: true,
|
||||||
monochromeFlashMode = p[Keys.monochromeFlashMode] ?: false,
|
monochromeFlashMode = p[Keys.monochromeFlashMode] ?: false,
|
||||||
immersiveBrightnessPercent = (p[Keys.immersiveBrightnessPercent] ?: 50).coerceIn(5, 100),
|
immersiveBrightnessPercent = (p[Keys.immersiveBrightnessPercent] ?: 50).coerceIn(5, 100),
|
||||||
@@ -58,6 +60,9 @@ class SettingsRepository(private val context: Context) {
|
|||||||
if (prefs[Keys.preferLowestRefreshRate] == null) {
|
if (prefs[Keys.preferLowestRefreshRate] == null) {
|
||||||
prefs[Keys.preferLowestRefreshRate] = true
|
prefs[Keys.preferLowestRefreshRate] = true
|
||||||
}
|
}
|
||||||
|
if (prefs[Keys.showImmersiveStopButton] == null) {
|
||||||
|
prefs[Keys.showImmersiveStopButton] = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +85,10 @@ class SettingsRepository(private val context: Context) {
|
|||||||
it[Keys.showImmersiveProgressBar] = value
|
it[Keys.showImmersiveProgressBar] = value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun updateShowImmersiveStopButton(value: Boolean) = context.dataStore.edit {
|
||||||
|
it[Keys.showImmersiveStopButton] = value
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun updatePreferLowestRefreshRate(value: Boolean) = context.dataStore.edit {
|
suspend fun updatePreferLowestRefreshRate(value: Boolean) = context.dataStore.edit {
|
||||||
it[Keys.preferLowestRefreshRate] = value
|
it[Keys.preferLowestRefreshRate] = value
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ 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 showImmersiveStopButton: Boolean = true,
|
||||||
val preferLowestRefreshRate: Boolean = true,
|
val preferLowestRefreshRate: Boolean = true,
|
||||||
val monochromeFlashMode: Boolean = false,
|
val monochromeFlashMode: Boolean = false,
|
||||||
// Default used only before DataStore emits (and as a fallback).
|
// Default used only before DataStore emits (and as a fallback).
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package solutions.tretter.mindmachine.session
|
|||||||
|
|
||||||
enum class FlashPaletteColor {
|
enum class FlashPaletteColor {
|
||||||
BLACK,
|
BLACK,
|
||||||
|
WHITE,
|
||||||
RED,
|
RED,
|
||||||
GREEN,
|
GREEN,
|
||||||
YELLOW,
|
YELLOW,
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ private fun FlashPaletteColor.next(): FlashPaletteColor {
|
|||||||
|
|
||||||
private fun FlashPaletteColor.previewColor(): Color = when (this) {
|
private fun FlashPaletteColor.previewColor(): Color = when (this) {
|
||||||
FlashPaletteColor.BLACK -> Color.Black
|
FlashPaletteColor.BLACK -> Color.Black
|
||||||
|
FlashPaletteColor.WHITE -> Color.White
|
||||||
FlashPaletteColor.RED -> Color(0xFFD32F2F)
|
FlashPaletteColor.RED -> Color(0xFFD32F2F)
|
||||||
FlashPaletteColor.GREEN -> Color(0xFF2E7D32)
|
FlashPaletteColor.GREEN -> Color(0xFF2E7D32)
|
||||||
FlashPaletteColor.YELLOW -> Color(0xFFF9A825)
|
FlashPaletteColor.YELLOW -> Color(0xFFF9A825)
|
||||||
|
|||||||
@@ -304,6 +304,7 @@ class MainViewModel(
|
|||||||
settingsRepository.updateForceAudioWithoutHeadphones(value)
|
settingsRepository.updateForceAudioWithoutHeadphones(value)
|
||||||
}
|
}
|
||||||
fun updateShowImmersiveProgressBar(value: Boolean) = viewModelScope.launch { settingsRepository.updateShowImmersiveProgressBar(value) }
|
fun updateShowImmersiveProgressBar(value: Boolean) = viewModelScope.launch { settingsRepository.updateShowImmersiveProgressBar(value) }
|
||||||
|
fun updateShowImmersiveStopButton(value: Boolean) = viewModelScope.launch { settingsRepository.updateShowImmersiveStopButton(value) }
|
||||||
fun updatePreferLowestRefreshRate(value: Boolean) = viewModelScope.launch { settingsRepository.updatePreferLowestRefreshRate(value) }
|
fun updatePreferLowestRefreshRate(value: Boolean) = viewModelScope.launch { settingsRepository.updatePreferLowestRefreshRate(value) }
|
||||||
fun updateMonochromeFlashMode(value: Boolean) = viewModelScope.launch { settingsRepository.updateMonochromeFlashMode(value) }
|
fun updateMonochromeFlashMode(value: Boolean) = viewModelScope.launch { settingsRepository.updateMonochromeFlashMode(value) }
|
||||||
fun updateImmersiveBrightnessPercent(value: Int) = viewModelScope.launch {
|
fun updateImmersiveBrightnessPercent(value: Int) = viewModelScope.launch {
|
||||||
|
|||||||
@@ -13,5 +13,5 @@ object ParameterRanges {
|
|||||||
const val CARRIER_HZ_MAX = 1200f
|
const val CARRIER_HZ_MAX = 1200f
|
||||||
|
|
||||||
const val BINAURAL_HZ_MIN = 0.5f
|
const val BINAURAL_HZ_MIN = 0.5f
|
||||||
const val BINAURAL_HZ_MAX = 30f
|
const val BINAURAL_HZ_MAX = 20f
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -266,18 +266,28 @@ internal fun TimelineGraph(
|
|||||||
(context as? android.app.Activity)?.window?.decorView?.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
|
(context as? android.app.Activity)?.window?.decorView?.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
|
||||||
}
|
}
|
||||||
|
|
||||||
val yLabels = if (isVisual) {
|
// Y-axis labels - dynamic based on which curve is selected. For audio: carrier (left) or binaural (right). For visual: flash interval.
|
||||||
|
val yAxisLabels = if (isVisual) {
|
||||||
listOf(
|
listOf(
|
||||||
formatMsLabel(ParameterMapping.logMap01(1f, ParameterRanges.FLASH_INTERVAL_MS_MIN, ParameterRanges.FLASH_INTERVAL_MS_MAX)),
|
formatMsLabel(ParameterMapping.logMap01(1f, ParameterRanges.FLASH_INTERVAL_MS_MIN, ParameterRanges.FLASH_INTERVAL_MS_MAX)),
|
||||||
formatMsLabel(ParameterMapping.logMap01(0.5f, ParameterRanges.FLASH_INTERVAL_MS_MIN, ParameterRanges.FLASH_INTERVAL_MS_MAX)),
|
formatMsLabel(ParameterMapping.logMap01(0.5f, ParameterRanges.FLASH_INTERVAL_MS_MIN, ParameterRanges.FLASH_INTERVAL_MS_MAX)),
|
||||||
formatMsLabel(ParameterMapping.logMap01(0f, ParameterRanges.FLASH_INTERVAL_MS_MIN, ParameterRanges.FLASH_INTERVAL_MS_MAX)),
|
formatMsLabel(ParameterMapping.logMap01(0f, ParameterRanges.FLASH_INTERVAL_MS_MIN, ParameterRanges.FLASH_INTERVAL_MS_MAX)),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
// Audio: switch labels based on selected side
|
||||||
|
if (selectedLeft) {
|
||||||
listOf(
|
listOf(
|
||||||
formatHzLabel(ParameterMapping.logMap01(1f, ParameterRanges.CARRIER_HZ_MIN, ParameterRanges.CARRIER_HZ_MAX)),
|
formatHzLabel(ParameterMapping.logMap01(1f, ParameterRanges.CARRIER_HZ_MIN, ParameterRanges.CARRIER_HZ_MAX)),
|
||||||
formatHzLabel(ParameterMapping.logMap01(0.5f, ParameterRanges.CARRIER_HZ_MIN, ParameterRanges.CARRIER_HZ_MAX)),
|
formatHzLabel(ParameterMapping.logMap01(0.5f, ParameterRanges.CARRIER_HZ_MIN, ParameterRanges.CARRIER_HZ_MAX)),
|
||||||
formatHzLabel(ParameterMapping.logMap01(0f, ParameterRanges.CARRIER_HZ_MIN, ParameterRanges.CARRIER_HZ_MAX)),
|
formatHzLabel(ParameterMapping.logMap01(0f, ParameterRanges.CARRIER_HZ_MIN, ParameterRanges.CARRIER_HZ_MAX)),
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
listOf(
|
||||||
|
formatHzLabel(ParameterMapping.logMap01(1f, ParameterRanges.BINAURAL_HZ_MIN, ParameterRanges.BINAURAL_HZ_MAX)),
|
||||||
|
formatHzLabel(ParameterMapping.logMap01(0.5f, ParameterRanges.BINAURAL_HZ_MIN, ParameterRanges.BINAURAL_HZ_MAX)),
|
||||||
|
formatHzLabel(ParameterMapping.logMap01(0f, ParameterRanges.BINAURAL_HZ_MIN, ParameterRanges.BINAURAL_HZ_MAX)),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val xStart = formatTimeLabel(viewport.startSec)
|
val xStart = formatTimeLabel(viewport.startSec)
|
||||||
val xMid = formatTimeLabel(viewport.startSec + viewport.secondsPerScreen / 2f)
|
val xMid = formatTimeLabel(viewport.startSec + viewport.secondsPerScreen / 2f)
|
||||||
@@ -288,12 +298,13 @@ internal fun TimelineGraph(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
|
// Left Y-axis labels
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.height(190.dp).width(48.dp),
|
modifier = Modifier.height(190.dp).width(48.dp),
|
||||||
verticalArrangement = Arrangement.SpaceBetween,
|
verticalArrangement = Arrangement.SpaceBetween,
|
||||||
horizontalAlignment = Alignment.End,
|
horizontalAlignment = Alignment.End,
|
||||||
) {
|
) {
|
||||||
yLabels.forEach { label ->
|
yAxisLabels.forEach { label ->
|
||||||
Text(
|
Text(
|
||||||
text = label,
|
text = label,
|
||||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f),
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f),
|
||||||
@@ -414,7 +425,7 @@ internal fun TimelineGraph(
|
|||||||
height = h,
|
height = h,
|
||||||
curveId = curveId,
|
curveId = curveId,
|
||||||
)
|
)
|
||||||
val updated = targetCurve.movePointVerticalWithPropagation(pointIndex, constrainedY, h)
|
val updated = targetCurve.movePointVertical(pointIndex, constrainedY, h)
|
||||||
if (curveId == leftId) curveLeft = updated else curveRight = updated
|
if (curveId == leftId) curveLeft = updated else curveRight = updated
|
||||||
currentOnUpdateCurve(curveId, updated)
|
currentOnUpdateCurve(curveId, updated)
|
||||||
onCurveEditedHaptic()
|
onCurveEditedHaptic()
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ object TimelineParameters {
|
|||||||
ParameterId.BRIGHTNESS to Range(min = 0.0001f, max = 1.0f, logScale = true, units = "%"),
|
ParameterId.BRIGHTNESS to Range(min = 0.0001f, max = 1.0f, logScale = true, units = "%"),
|
||||||
ParameterId.BLINK_RATE to Range(min = 0.1f, max = 30f, logScale = true, units = "Hz"),
|
ParameterId.BLINK_RATE to Range(min = 0.1f, max = 30f, logScale = true, units = "Hz"),
|
||||||
ParameterId.CARRIER_FREQUENCY to Range(min = 200f, max = 1200f, logScale = true, units = "Hz"),
|
ParameterId.CARRIER_FREQUENCY to Range(min = 200f, max = 1200f, logScale = true, units = "Hz"),
|
||||||
ParameterId.BINAURAL_BEAT to Range(min = 0.5f, max = 30f, logScale = true, units = "Hz"),
|
ParameterId.BINAURAL_BEAT to Range(min = 0.5f, max = 20f, logScale = true, units = "Hz"),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun parameterForCurve(curve: TimelineEditorState.ActiveCurve): ParameterId {
|
fun parameterForCurve(curve: TimelineEditorState.ActiveCurve): ParameterId {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id("com.android.application") version "8.5.2" apply false
|
id("com.android.application") version "8.5.2" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
|
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
|
||||||
id("org.jetbrains.kotlin.plugin.serialization") version "1.9.24" apply false
|
id("org.jetbrains.kotlin.plugin.serialization") version "2.1.0" apply false
|
||||||
|
id("org.jetbrains.kotlin.plugin.compose") version "2.1.0" apply false
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user